Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
title: Snapshot
|
||||
# No tooltip and no author on purpose. pyRevit renders both on hover;
|
||||
# the owner wants nothing there. Guarded by
|
||||
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
|
||||
#
|
||||
# Two lines, short ones. A large ribbon button is as wide as its widest line
|
||||
# of text, so a long title makes one button dwarf its neighbours -- which is
|
||||
# exactly what the owner objected to. Keep every line under ~9 characters.
|
||||
#
|
||||
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
|
||||
# four large buttons on this panel all come out the same width.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,838 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Index the model as it stands, then hand the sandbox off -- or close Revit.
|
||||
#
|
||||
# WHAT THIS BUTTON IS NOW, 2026-09-01
|
||||
# ------------------------------------
|
||||
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
|
||||
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
|
||||
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
|
||||
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
|
||||
#
|
||||
# So the whole cycle moved out of this button and into
|
||||
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
|
||||
# it, and can be started by Claude. What is left here is the one job that
|
||||
# genuinely requires the live session and cannot be done anywhere else:
|
||||
# handing over the model's IDENTITY. Only the open document can cheaply report
|
||||
# its cloud region and GUIDs, and without those nothing else can find the
|
||||
# model to copy.
|
||||
#
|
||||
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
|
||||
# ----------------------------------------------
|
||||
# The design this replaced expected an in-session background open --
|
||||
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
|
||||
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
|
||||
# does not exist for this model, and the reason is not a version guard:
|
||||
#
|
||||
# * a cloud model cannot be opened detached at all. Any
|
||||
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
|
||||
# is not valid for cloud model"; Autodesk's answer is that the Revit team
|
||||
# does not allow it and that SaveAs is the way; and the UI agrees --
|
||||
# "Detach from Central" is greyed out for anything from Autodesk Docs.
|
||||
# * Revit holds one Document per model per session, so the live session
|
||||
# cannot open the model it already has open.
|
||||
# * the only call that could copy the document already in memory is
|
||||
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
|
||||
# here it would leave HIS Revit sitting in a local copy, disconnected
|
||||
# from the cloud. There is no SaveCopyAs.
|
||||
#
|
||||
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
|
||||
# The copy is therefore made by a Revit that is not his, and this button's
|
||||
# phase C is a spawn rather than an open.
|
||||
#
|
||||
# THE OLD RITUAL, KEPT
|
||||
# ---------------------
|
||||
# `snapshot.after_close` is NOT retired. The close-model path below is
|
||||
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
|
||||
# day, where the answer really is "close everything first". It is simply no
|
||||
# longer the only way to get a copy, and it is no longer the default.
|
||||
#
|
||||
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
|
||||
# ------------------------------------------------
|
||||
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
|
||||
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
|
||||
# session and a detached sandbox ran side by side all morning. What nobody has
|
||||
# measured is three, so the pipeline sequences to keep it to two and this
|
||||
# button never opens a model at all.
|
||||
#
|
||||
# The close-model ritual, still here as one of the options:
|
||||
#
|
||||
# he syncs his act, outside this button. This button NEVER
|
||||
# calls SynchronizeWithCentral and never asks about it.
|
||||
# phase A this button indexes the OPEN document. The model is
|
||||
# already in RAM and already paid for; a snapshot read
|
||||
# from the live document at sync state and one read
|
||||
# from a detached copy of that same state contain the
|
||||
# same numbers. Seconds, not minutes, and no copy.
|
||||
# phase B Revit closes. The press IS the authorisation for the
|
||||
# close -- that is what the button is called.
|
||||
# phase C AFTER Revit has exited and the 10 GB is free, a
|
||||
# deferred host process opens the model detached, saves
|
||||
# a timestamped copy and quits. Optional, off unless
|
||||
# the job layer configures it, and its outcome lands in
|
||||
# a log this button reads out on the next press.
|
||||
#
|
||||
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
|
||||
# --------------------------------------------------------------------
|
||||
# No Transaction is opened in this file or in anything it calls. Nothing is
|
||||
# written to the model -- not a parameter, not a workset, not a save. The only
|
||||
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
|
||||
# file, in another process, after this one has exited. The sync that used to
|
||||
# live here has been removed entirely at the owner's instruction.
|
||||
#
|
||||
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
|
||||
# ----------------------------------------------------
|
||||
# Revit is closed LAST and only after phase A has reported complete. A crash
|
||||
# halfway through the index must leave the session open, with the traceback on
|
||||
# screen, because the alternative is a closed Revit and no way to find out
|
||||
# what went wrong. `PostCommand` is therefore reached on exactly one path.
|
||||
#
|
||||
# The other half of that discipline is phase C. It runs when nobody is
|
||||
# watching, in a process this one has already outlived, and a day it silently
|
||||
# did not run looks exactly like a day it worked. So the request is journalled
|
||||
# BEFORE the close and the deferred process journals its own outcome; a
|
||||
# request with nothing after it is reported, in capitals, at the top of the
|
||||
# next press. Silence is not an acceptable report.
|
||||
#
|
||||
# JUDGEMENT CALLS, for the owner to overrule
|
||||
# -------------------------------------------
|
||||
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
|
||||
# press elsewhere trusts the cheap signature, which cannot see an opening
|
||||
# moved or resized in place. This is the daily archival snapshot and it is
|
||||
# the one that gets trusted months later, so it pays the measured 6-10 s
|
||||
# for an answer that owes nothing to yesterday's cache.
|
||||
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
|
||||
# hang on a mis-click, and "snapshot but stay open" is a real want (a
|
||||
# lunchtime checkpoint). Cancel writes nothing at all.
|
||||
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
|
||||
# declared scope and no parameter-role mapping, so the index would measure
|
||||
# the whole model and mark every piece could-not-judge on the review
|
||||
# signal -- and would overwrite a good index to do it. That is worth a
|
||||
# dialog. It is not worth a refusal on a machine that legitimately has no
|
||||
# job layer yet.
|
||||
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
|
||||
# default and None means not configured, never "pick somewhere". The button
|
||||
# says so instead of guessing a folder for a 300 MB file.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Snapshot'
|
||||
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import knowledge
|
||||
from vibe import liftexport
|
||||
from vibe import reinforcecore as core
|
||||
from vibe import reinforceindex as index
|
||||
from vibe import snapshotmeta as S
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Snapshot & Close'
|
||||
|
||||
# Where this button sits, and therefore where the repo is. Hoisted to the top
|
||||
# because three separate places need it now (the deferred tool, the cycle
|
||||
# pipeline and the runner) and computing it three times is how two of them end
|
||||
# up one directory apart.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
|
||||
REPO_ROOT = os.path.dirname(EXT_ROOT)
|
||||
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
|
||||
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
|
||||
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
|
||||
|
||||
CYCLE = 'Snapshot, then run the sandbox cycle'
|
||||
CLOSE = 'Snapshot, then close Revit'
|
||||
STAY = 'Snapshot only, stay open'
|
||||
CANCEL = 'Cancel'
|
||||
|
||||
|
||||
def die(message):
|
||||
forms.alert(message, title=TITLE, exitscript=True)
|
||||
|
||||
|
||||
if doc is None:
|
||||
die('No model is open, so there is nothing to snapshot.')
|
||||
|
||||
|
||||
# --------------------------------------------------------------- versions
|
||||
|
||||
def _revit_version():
|
||||
try:
|
||||
app = doc.Application
|
||||
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pyrevit_version():
|
||||
try:
|
||||
from pyrevit import versionmgr
|
||||
return versionmgr.get_pyrevit_version().get_formatted()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _revit_pid():
|
||||
try:
|
||||
from System.Diagnostics import Process
|
||||
return Process.GetCurrentProcess().Id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------ what already happened
|
||||
#
|
||||
# Read and printed FIRST, before anything is decided, because the one thing
|
||||
# this design can lose is a phase C that never ran -- and the only moment
|
||||
# anybody is looking is the moment they press this button again.
|
||||
|
||||
know = knowledge.load(doc)
|
||||
job_folder = None
|
||||
try:
|
||||
from vibe import designspec
|
||||
job_folder = designspec.root_dir(doc)
|
||||
except Exception:
|
||||
job_folder = None
|
||||
|
||||
log = S.log_path(job_folder) if job_folder else None
|
||||
history = S.read(log) if log else []
|
||||
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
for line in S.summary(history):
|
||||
if line.startswith('WARNING'):
|
||||
output.print_md('**{0}**'.format(line))
|
||||
else:
|
||||
output.print_md('- {0}'.format(line))
|
||||
|
||||
|
||||
# ---------------------------------------------------- is this job set up
|
||||
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
die('\n\n'.join(problems))
|
||||
|
||||
has_job_layer = False
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
has_job_layer = True
|
||||
|
||||
if not has_job_layer:
|
||||
keep_going = forms.alert(
|
||||
'This model has no job knowledge file.\n\n'
|
||||
'Without it there is no declared scope and no parameter-name '
|
||||
'mapping, so the index will measure every assembly in the model and '
|
||||
'will record "could not judge" for the review signal on all of them '
|
||||
'-- and it will overwrite whatever index is there now.\n\n'
|
||||
'Snapshot anyway?',
|
||||
title=TITLE, yes=True, no=True)
|
||||
if not keep_going:
|
||||
script.exit()
|
||||
|
||||
|
||||
# --------------------------------------------------------- phase C settings
|
||||
#
|
||||
# Resolved BEFORE the confirmation so the dialog can say what will actually
|
||||
# happen after the close, rather than promising a detached copy that turns out
|
||||
# not to be configured.
|
||||
|
||||
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
|
||||
after_close = knowledge.get(know, 'snapshot.after_close', False)
|
||||
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
|
||||
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
|
||||
# TEXT, so two call sites spelling the same value differently are reported as
|
||||
# disagreeing about what "unset" means.
|
||||
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
|
||||
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
|
||||
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
|
||||
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
|
||||
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
|
||||
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
|
||||
or S.revit_exe_guess(revit_year)
|
||||
if not revit_year:
|
||||
# `pyrevit run` (both the cycle route's detach step and the close-model
|
||||
# route) takes an optional `--revit=<year>`, and a job layer that states
|
||||
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
|
||||
# job's shape -- used to leave it off entirely. On a machine with more
|
||||
# than one Revit version attached that is not a neutral omission: see
|
||||
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
|
||||
# is named for. `revit_exe`'s own path almost always carries the year.
|
||||
revit_year = S.revit_year_from_exe(revit_exe)
|
||||
batch = knowledge.get(know, 'snapshot.batch', []) or []
|
||||
|
||||
|
||||
def _pyrevit_cli():
|
||||
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
|
||||
if pyrevit_exe and os.path.isfile(pyrevit_exe):
|
||||
return pyrevit_exe
|
||||
if pyrevit_exe:
|
||||
return None # stated and wrong: never fall back
|
||||
return S.pyrevit_exe_guess()
|
||||
|
||||
|
||||
def _copy_blocker():
|
||||
"""What stops ANY detached copy being made, whichever route. Or None."""
|
||||
if not detached_dir:
|
||||
return ('not configured -- set snapshot.detached_dir in the job '
|
||||
'knowledge file to switch it on')
|
||||
if not os.path.isdir(detached_dir):
|
||||
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
|
||||
if _pyrevit_cli() is None:
|
||||
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
|
||||
if liftexport.find_python(know) is None:
|
||||
return 'no host Python was found to run the deferred step'
|
||||
return None
|
||||
|
||||
|
||||
def phase_c_blocker():
|
||||
"""Why the OLD after-close route cannot run, or None.
|
||||
|
||||
`snapshot.after_close` is deliberately still consulted here and nowhere
|
||||
else: it gates the close-model ritual, which is the thing it has always
|
||||
meant. The cycle route below does not read it -- it is not chained to an
|
||||
exit, so "after close" has nothing to say about it, and making one key
|
||||
mean two things is how a setting becomes untrustworthy.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not after_close:
|
||||
return ('switched off -- snapshot.after_close is false in the job '
|
||||
'knowledge file')
|
||||
return None
|
||||
|
||||
|
||||
def cycle_blocker():
|
||||
"""Why the sandbox cycle cannot be started, or None.
|
||||
|
||||
Note what is NOT in here: `after_close`, and any check on other Revits.
|
||||
The cycle runs beside this session on purpose -- that is the whole point
|
||||
of it -- and it sequences its own steps so that at most two Revits are
|
||||
ever resident.
|
||||
"""
|
||||
stop = _copy_blocker()
|
||||
if stop:
|
||||
return stop
|
||||
if not revit_exe:
|
||||
return ('no Revit.exe to open the sandbox with -- set '
|
||||
'snapshot.revit_exe or snapshot.revit_year')
|
||||
if not os.path.isfile(revit_exe):
|
||||
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
|
||||
if not batch:
|
||||
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
|
||||
if not os.path.isfile(CYCLE_TOOL):
|
||||
return 'tools/snapshot_cycle.py is not where this button expects it'
|
||||
return None
|
||||
|
||||
|
||||
blocker = phase_c_blocker()
|
||||
cycle_stop = cycle_blocker()
|
||||
|
||||
|
||||
# -------------------------------------------------------------- the decision
|
||||
|
||||
if cycle_stop is None:
|
||||
cycle_tail = (
|
||||
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
|
||||
'own, then opened in a sandbox session, then {1} runs inside it, then '
|
||||
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
|
||||
.format(detached_dir, ' + '.join(batch)))
|
||||
else:
|
||||
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
|
||||
|
||||
if blocker is None:
|
||||
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
|
||||
'copy is written to:\n{0}'.format(detached_dir))
|
||||
else:
|
||||
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
|
||||
|
||||
choice = forms.alert(
|
||||
'Sync first if you have not -- this button does not sync, and it never '
|
||||
'touches the model.\n\n'
|
||||
'It rebuilds the reinforcing index from the model as it stands now and '
|
||||
'stamps it with where it came from.' + cycle_tail + close_tail,
|
||||
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
|
||||
|
||||
if choice not in (CYCLE, CLOSE, STAY):
|
||||
output.print_md('---')
|
||||
output.print_md('Cancelled. Nothing was read, written or closed.')
|
||||
script.exit()
|
||||
|
||||
closing = (choice == CLOSE)
|
||||
cycling = (choice == CYCLE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase A
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase A - indexing the open model')
|
||||
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
|
||||
'an earlier press. Measured at 6-10 s on this model.')
|
||||
|
||||
# The index AS IT STOOD BEFORE this press, captured so the update statistics
|
||||
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
|
||||
# only loads the file, it never touches the model. Absent or damaged is fine:
|
||||
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
|
||||
try:
|
||||
old_cache = index.read(doc)
|
||||
except Exception:
|
||||
old_cache = None
|
||||
|
||||
report = None
|
||||
cache = None
|
||||
failure = None
|
||||
try:
|
||||
# An upper bound for the bar: a full rebuild measures at most every
|
||||
# assembly in the model. The cheap collector pass costs ~0.3 s here and
|
||||
# buys a progress bar that moves, instead of one pinned at 100%.
|
||||
total = max(len(index.assemblies(doc)), 1)
|
||||
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
|
||||
cancellable=False) as bar:
|
||||
state = {'n': 0}
|
||||
|
||||
def tick(_name):
|
||||
state['n'] += 1
|
||||
bar.update_progress(state['n'], total)
|
||||
|
||||
bar.update_progress(0, total)
|
||||
cache, report = index.refresh(doc, rebuild_all=True, know=know,
|
||||
on_piece=tick)
|
||||
except Exception:
|
||||
failure = traceback.format_exc()
|
||||
|
||||
if failure is not None:
|
||||
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
|
||||
output.print_md('## Phase A FAILED - Revit has not been closed')
|
||||
output.print_md('```\n{0}\n```'.format(failure))
|
||||
if log:
|
||||
S.append(log, {'event': 'phase-a-failed',
|
||||
'error': failure.splitlines()[-1]})
|
||||
script.exit()
|
||||
|
||||
|
||||
# ------------------------------------------------- stamp it with where it came from
|
||||
#
|
||||
# `refresh` writes the index; this adds the block that says whose numbers
|
||||
# those are, and rewrites. It is written on EVERY press, including the press
|
||||
# where nothing changed and `refresh` therefore did not save -- an index whose
|
||||
# provenance is a week older than its numbers is worse than none.
|
||||
|
||||
source_path = None
|
||||
try:
|
||||
source_path = doc.PathName or None
|
||||
except Exception:
|
||||
source_path = None
|
||||
|
||||
prov = S.provenance(
|
||||
'A', S.LIVE_DOC,
|
||||
source_path=source_path,
|
||||
source_stamp=None, # a live document has no snapshot stamp
|
||||
source_title=index.doc_key(doc),
|
||||
revit=_revit_version(),
|
||||
pyrevit=_pyrevit_version(),
|
||||
job_folder=job_folder,
|
||||
note=('read from the open document immediately after the engineer synced; '
|
||||
'no detached copy was involved in these numbers'))
|
||||
|
||||
pieces = len(cache.get('pieces') or {})
|
||||
try:
|
||||
cache['provenance'] = prov
|
||||
core.save(report['path'], cache)
|
||||
stamped = True
|
||||
except Exception:
|
||||
stamped = False
|
||||
|
||||
output.print_md('- {0} assemblies seen, {1} pieces in the index'
|
||||
.format(report['assemblies'], pieces))
|
||||
output.print_md('- scope: {0}'.format(report['scope']))
|
||||
output.print_md('- built in {0} s, written to `{1}`'
|
||||
.format(report['seconds_total'], report['path']))
|
||||
if stamped:
|
||||
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
|
||||
else:
|
||||
output.print_md('- **the provenance stamp could not be written** -- this '
|
||||
'index cannot say where it came from')
|
||||
|
||||
missing = (report.get('coverage') or {}).get('missing') or []
|
||||
if missing:
|
||||
output.print_md('- **{0} pieces could not be measured** and are not in '
|
||||
'this snapshot'.format(len(missing)))
|
||||
|
||||
if log:
|
||||
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
|
||||
'path': report['path'], 'scope': report['scope'],
|
||||
'seconds': report['seconds_total'],
|
||||
'provenance': prov})
|
||||
|
||||
|
||||
# ------------------------------------------------- update statistics
|
||||
#
|
||||
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
|
||||
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
|
||||
# press changed: which pieces are newly donors, how many features each carries,
|
||||
# and which feature-IDs went into the shared vocabulary for the first time
|
||||
# versus which were already known. READ ONLY -- this only reads the two caches
|
||||
# and one cheap signature pass; nothing about the model is written.
|
||||
#
|
||||
# `index.refresh` already grew the registry (via `core.register_features`), so
|
||||
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
|
||||
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
|
||||
# runs, redone here so the diff sees the model exactly as it stands now.
|
||||
|
||||
output.print_md('## Update statistics -- what this snapshot marked')
|
||||
try:
|
||||
live = index.live_signatures(doc, know=know)
|
||||
stats = core.snapshot_stats(old_cache, cache, live)
|
||||
except Exception:
|
||||
stats = None
|
||||
output.print_md('- **the update statistics could not be computed** -- the '
|
||||
'index was still written; only this summary is missing')
|
||||
|
||||
if stats is not None:
|
||||
newly = stats['newly']
|
||||
if stats['first_snapshot']:
|
||||
output.print_md('- **first snapshot for this index** -- there was '
|
||||
'nothing to diff against, so every reinforced piece '
|
||||
'below is new to the donor pool, not a change since '
|
||||
'last press.')
|
||||
if not newly:
|
||||
output.print_md('- no piece was newly marked reinforced since the last '
|
||||
'snapshot. (This is "nothing changed", not "nothing '
|
||||
'is reinforced".)')
|
||||
else:
|
||||
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
|
||||
'{1}'.format(len(newly), ', '.join(newly)))
|
||||
output.print_md('- feature-IDs new to the vocabulary this press: '
|
||||
'**{0}**; already known: **{1}**'
|
||||
.format(len(stats['new_ids']),
|
||||
len(stats['known_ids'])))
|
||||
for panel in stats['panels']:
|
||||
if panel['feature_count'] is None:
|
||||
output.print_md(' - **{0}** -- could not be measured; no '
|
||||
'features recorded'.format(panel['name']))
|
||||
continue
|
||||
bits = ['{0} feature(s)'.format(panel['feature_count'])]
|
||||
if panel['new_ids']:
|
||||
bits.append('new IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
|
||||
if panel['known_ids']:
|
||||
bits.append('known IDs {0}'.format(
|
||||
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
|
||||
if panel['unregistered']:
|
||||
bits.append('{0} unregistered'.format(panel['unregistered']))
|
||||
output.print_md(' - **{0}** -- {1}'.format(
|
||||
panel['name'], '; '.join(bits)))
|
||||
|
||||
if log:
|
||||
# Journal the same figures. Per-panel id lists are kept so the ledger
|
||||
# can be replayed without the model; `first_snapshot` distinguishes a
|
||||
# cold index from a genuine no-change press.
|
||||
S.append(log, {'event': 'phase-a-update-stats',
|
||||
'first_snapshot': stats['first_snapshot'],
|
||||
'newly': newly,
|
||||
'new_ids': stats['new_ids'],
|
||||
'known_ids': stats['known_ids'],
|
||||
'panels': stats['panels']})
|
||||
|
||||
|
||||
# ---------------------------------------------- handing the model over
|
||||
#
|
||||
# Both routes below need the same two things: WHERE the copy goes (a plan) and
|
||||
# HOW to find the model again (its identity). Built once, here, so the cycle
|
||||
# and the close-model ritual cannot drift into naming files differently.
|
||||
|
||||
|
||||
def _source_block():
|
||||
"""How another session is to find this model again.
|
||||
|
||||
Cloud GUIDs and file path both, when both are available: a cloud model is
|
||||
reopened from its GUIDs, a file-based one from its path, and which one a
|
||||
job is was never something to assume. A cloud model reports no usable
|
||||
local path, which is the ordinary case here, not an error.
|
||||
"""
|
||||
src = {'path': source_path, 'title': index.doc_key(doc)}
|
||||
try:
|
||||
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
|
||||
mp = doc.GetCloudModelPath()
|
||||
src['cloud'] = {
|
||||
'region': str(mp.Region),
|
||||
'project_guid': str(mp.GetProjectGUID()),
|
||||
'model_guid': str(mp.GetModelGUID()),
|
||||
}
|
||||
except Exception as exc:
|
||||
# Not a cloud model, or this Revit does not expose the GUIDs. The file
|
||||
# path is then the only handle and the runner says so if it is absent.
|
||||
src['cloud'] = None
|
||||
src['cloud_error'] = str(exc)
|
||||
return src
|
||||
|
||||
|
||||
def _write_request(run_id, plan, source, extra=None):
|
||||
"""The handoff file. Same schema for both routes -- one runner reads it."""
|
||||
request = {
|
||||
'run': run_id,
|
||||
'base': plan['title'],
|
||||
'layout': layout,
|
||||
'worksets': worksets,
|
||||
'detached_dir': detached_dir,
|
||||
'plan': plan,
|
||||
'log': log,
|
||||
'job_folder': job_folder,
|
||||
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'requested_by_revit': _revit_version(),
|
||||
'source': source,
|
||||
}
|
||||
request.update(extra or {})
|
||||
req_dir = os.path.join(detached_dir, '_requests')
|
||||
req_path = os.path.join(req_dir, run_id + '.json')
|
||||
if not os.path.isdir(req_dir):
|
||||
os.makedirs(req_dir)
|
||||
import json
|
||||
handle = open(req_path, 'wb')
|
||||
try:
|
||||
handle.write(json.dumps(request, indent=2).encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
# And the identity on its own, where the host pipeline looks for it.
|
||||
# A cloud model is found by its region and two GUIDs, and NOTHING on this
|
||||
# machine knows those except a session with the model open -- this one.
|
||||
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
|
||||
# later with no Revit anywhere and still find the model to copy. It is
|
||||
# identity, never state: it says which model, never anything about a copy.
|
||||
try:
|
||||
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
|
||||
try:
|
||||
cache.write(json.dumps(source, indent=2,
|
||||
sort_keys=True).encode('utf-8'))
|
||||
finally:
|
||||
cache.close()
|
||||
except Exception:
|
||||
pass # a lost cache costs a flag, not a snapshot
|
||||
return req_path
|
||||
|
||||
|
||||
def _spawn(argv):
|
||||
"""Start a host process that outlives this one. Returns its pid.
|
||||
|
||||
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
|
||||
minutes with nobody watching, and a console window popping up over the
|
||||
engineer's Revit is the kind of thing that gets a tool switched off.
|
||||
"""
|
||||
from System.Diagnostics import Process, ProcessStartInfo
|
||||
psi = ProcessStartInfo()
|
||||
psi.FileName = argv[0]
|
||||
psi.Arguments = S.quote(argv[1:])
|
||||
psi.UseShellExecute = False
|
||||
psi.CreateNoWindow = True
|
||||
psi.WorkingDirectory = REPO_ROOT
|
||||
return Process.Start(psi).Id
|
||||
|
||||
|
||||
def _job_knowledge_path():
|
||||
"""The job knowledge file, so the host pipeline reads the same layer.
|
||||
|
||||
Handed over as a path rather than as resolved values: the pipeline needs
|
||||
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
|
||||
block, and a button that copies six settings across is a button that
|
||||
forgets the seventh the day one is added.
|
||||
"""
|
||||
try:
|
||||
for label, path, exists in knowledge.sources(doc):
|
||||
if label == 'job' and exists:
|
||||
return path
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------- the sandbox cycle
|
||||
#
|
||||
# The new route, and the one the commission is about. Nothing here opens,
|
||||
# saves or closes a model: it writes the handoff and starts a host process
|
||||
# that does all of it in Revits that are not this one.
|
||||
|
||||
run_id = None
|
||||
if cycling and cycle_stop is None:
|
||||
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
started_pid = None
|
||||
argv = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block(),
|
||||
{'requested_by': 'SnapshotClose button, '
|
||||
'cycle route'})
|
||||
argv = [liftexport.find_python(know), CYCLE_TOOL,
|
||||
'--fresh-detach',
|
||||
'--request', req_path,
|
||||
'--detached-dir', detached_dir,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--revit-exe', revit_exe]
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
if log:
|
||||
argv.extend(['--log', log])
|
||||
job_knowledge = _job_knowledge_path()
|
||||
if job_knowledge:
|
||||
argv.extend(['--knowledge', job_knowledge])
|
||||
for command in batch:
|
||||
argv.extend(['--run', command])
|
||||
started_pid = _spawn(argv)
|
||||
except Exception:
|
||||
started_pid = None
|
||||
cycle_stop = 'the pipeline could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - running now, beside this session')
|
||||
if started_pid:
|
||||
output.print_md(
|
||||
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
|
||||
'-- it is not closed, not saved, and not read again.'
|
||||
.format(run_id, started_pid))
|
||||
output.print_md('It writes:')
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('then opens that copy in a Revit of its own, runs '
|
||||
'`{0}`, and closes it again. Its log lands beside the '
|
||||
'copy and its outcome in `{1}`.'
|
||||
.format(' + '.join(batch), log))
|
||||
output.print_md('The command it is running:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'cycle', 'host_pid': started_pid})
|
||||
else:
|
||||
output.print_md('**Not started: {0}**'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'cycle', 'reason': cycle_stop})
|
||||
elif cycling:
|
||||
output.print_md('---')
|
||||
output.print_md('## The sandbox cycle - not run')
|
||||
output.print_md('Not started: {0}'.format(cycle_stop))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
|
||||
'reason': cycle_stop})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase C
|
||||
#
|
||||
# The OLD route, kept: requested here, run later, by a process that outlives
|
||||
# this one. Everything it needs goes into a request file -- `pyrevit run`
|
||||
# passes no arguments to the script it runs, so a file named by an environment
|
||||
# variable is the only handoff there is.
|
||||
#
|
||||
# This is the only place `snapshot.after_close` still means anything.
|
||||
|
||||
if closing and blocker is None:
|
||||
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
|
||||
base = index.doc_key(doc) or 'untitled'
|
||||
plan = S.plan(detached_dir, base, layout=layout)
|
||||
launched = None
|
||||
try:
|
||||
req_path = _write_request(run_id, plan, _source_block())
|
||||
argv = [liftexport.find_python(know), DEFER_TOOL,
|
||||
'--request', req_path,
|
||||
'--runner', RUNNER,
|
||||
'--pyrevit', _pyrevit_cli(),
|
||||
'--wait-pid', str(_revit_pid()),
|
||||
'--wait-minutes', str(wait_minutes),
|
||||
# The close-model ritual is the one case where waiting for the
|
||||
# whole machine to be free is still the right answer: nobody
|
||||
# is at the keyboard and nothing else is queued behind it.
|
||||
'--machine-free', 'require']
|
||||
if revit_year:
|
||||
argv.extend(['--revit', str(revit_year)])
|
||||
_spawn(argv)
|
||||
launched = argv
|
||||
except Exception:
|
||||
launched = None
|
||||
blocker = 'the deferred process could not be started: {0}'.format(
|
||||
traceback.format_exc().splitlines()[-1])
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - the detached copy, after Revit exits')
|
||||
if launched:
|
||||
output.print_md('Requested as `{0}`. It waits for this Revit '
|
||||
'(pid {1}) to exit -- and then for the machine to be '
|
||||
'free of Revit altogether -- then opens the model in a '
|
||||
'session of its own and writes:'
|
||||
.format(run_id, _revit_pid()))
|
||||
output.print_md('`{0}`'.format(plan['path']))
|
||||
if plan.get('warning'):
|
||||
output.print_md('**{0}**'.format(plan['warning']))
|
||||
output.print_md('The command it will run:')
|
||||
output.print_md('```\n{0}\n```'.format(S.quote(
|
||||
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
|
||||
output.print_md('Its outcome is journalled to `{0}` and read out the '
|
||||
'next time this button is pressed.'.format(log))
|
||||
if log:
|
||||
S.append(log, {'event': S.REQUESTED, 'run': run_id,
|
||||
'path': plan['path'], 'request': req_path,
|
||||
'route': 'after-close', 'wait_pid': _revit_pid()})
|
||||
else:
|
||||
output.print_md('**Not requested: {0}**'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'run': run_id,
|
||||
'route': 'after-close', 'reason': blocker})
|
||||
elif closing:
|
||||
output.print_md('---')
|
||||
output.print_md('## Phase C - not run')
|
||||
output.print_md('No detached copy: {0}'.format(blocker))
|
||||
if log:
|
||||
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
|
||||
'reason': blocker})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ phase B
|
||||
|
||||
output.print_md('---')
|
||||
if not closing:
|
||||
if cycling:
|
||||
output.print_md('Revit stays open. The index is written and stamped, '
|
||||
'and the cycle is running in processes of its own -- '
|
||||
'nothing here was closed and your model was never '
|
||||
'touched.')
|
||||
else:
|
||||
output.print_md('Revit stays open, as asked. The index is written and '
|
||||
'stamped; nothing was closed.')
|
||||
script.exit()
|
||||
|
||||
output.print_md('## Phase B - closing Revit')
|
||||
output.print_md('Revit will ask you about unsaved changes if it has any. '
|
||||
'That question is yours to answer -- this button never syncs '
|
||||
'and never saves the model.')
|
||||
|
||||
posted = False
|
||||
# There is no PostableCommand for quitting, so the internal command id is
|
||||
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
|
||||
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
|
||||
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
|
||||
# helper is a version-dependent convenience and this is the one step of the
|
||||
# button that cannot be retried by pressing it again.
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
HOST_APP.post_command('ID_APP_EXIT')
|
||||
posted = True
|
||||
except Exception:
|
||||
try:
|
||||
from pyrevit import HOST_APP
|
||||
from Autodesk.Revit.UI import RevitCommandId
|
||||
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
|
||||
if cmd is not None:
|
||||
HOST_APP.uiapp.PostCommand(cmd)
|
||||
posted = True
|
||||
except Exception:
|
||||
posted = False
|
||||
|
||||
if not posted:
|
||||
output.print_md('**Revit would not accept the close command.** The '
|
||||
'snapshot is written and stamped; close Revit yourself.')
|
||||
Reference in New Issue
Block a user