WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

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:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
@@ -0,0 +1,24 @@
title: "Panel\nLift"
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to `liftexport` -- the Revit half of the
# panellift boundary -- is visible without a Revit restart. It trades press
# latency (engine construction instead of engine reuse) for restart-freedom.
# The CPython half already needs no restart; this is for the half that does.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,910 @@
# -*- coding: utf-8 -*-
#
# Handling check for one assembly: stripping, transportation, erection.
#
# What the selection says decides how this runs. One assembly preselected --
# or any member of one -- is checked straight away. Several preselected run
# as a background batch on exactly those pieces. Nothing preselected asks:
# one piece, or a batch across the assemblies the job's DECLARED SCOPE says
# are this engineer's (`vibe.scope`). The batch list is the TOOL choosing, so
# it is scoped and says what it left off; a preselected batch is the USER
# choosing, so it is never filtered.
#
# In the single-panel flow the piece is measured out of the model, you pick the
# inserts that carry each stage by clicking them ON the piece, you say where the
# report goes, and the calculation runs on CPython -- see vibe.liftexport for why
# it is not in here.
#
# The only thing it writes to the model is a temporary isolate of the piece, in
# whatever view you are already in, so that you are clicking inserts on the piece
# rather than hunting them in a view of the whole building. It writes that twice:
# on, and off again. Nothing else is changed -- including the inserts this report
# recommends moving.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Panel\nLift'
import io
import json
import os
from Autodesk.Revit.DB import (AssemblyInstance, ElementId,
OverrideGraphicSettings, Reference,
TemporaryViewMode)
from Autodesk.Revit.UI.Selection import ISelectionFilter, ObjectType
from Autodesk.Revit.Exceptions import OperationCanceledException
from System.Collections.Generic import List
from pyrevit import revit, script, forms
from vibe import (batchrun, configmodel, knowledge, liftexport, ticketqc,
designspec, tx)
from vibe import scope as scopelib
from vibe.compat import as_text
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
def die(message, title='Panel Lift Check'):
"""Usage error, not a calculation refusal (Claude 08-28).
die() is ONLY for 'the user has not given us something yet' -- no
assembly selected, nothing isolated, nothing in scope to offer. A
refusal to CALCULATE (missing prestress / weight gate / no stages /
no Python / no concrete strength) must go through not_run(), which
leaves a row in the report saying what was not done and why. The
report must survive the dialog.
"""
forms.alert(message, title=title, exitscript=True)
def not_run(message, title='Panel Lift Check'):
"""'Could not be calculated' is a RESULT, not a failure of the piece
(Claude 08-28: exit code non-zero != the panel failed). Print a
NOT RUN row with the reason that tells the user what to do next,
then stop. The five-word discipline from Ticket QC applies here too:
the tool must be able to say what it did not do.
"""
output.print_md('# Panel Lift Check — NOT RUN')
output.print_md('**NOT RUN** — the check did not run for this piece.')
output.print_md('> {0}'.format(message))
script.exit()
# ------------------------------------------------- background batch engine
from System.Threading import Thread, ThreadStart
def _design_of(asm, groups):
"""The design key an assembly belongs to, or None if it is in none."""
for key, group in groups.items():
for member in group:
try:
if member.Id == asm.Id:
return key
except Exception:
pass
return None
def _run_batch(preselected=None):
"""Run the full check on many panels in the background.
Called with no argument, the user picks from the assemblies inside the
declared scope, and the page says how many were left off and of what
kind. Given a selection -- several assemblies highlighted before the
button was pressed -- those ARE the batch, whatever their marks: nobody
is asked which pieces they meant and nothing is dropped.
"""
know = knowledge.load(doc)
_summaries, sheets = ticketqc.collect_context(doc)
groups = ticketqc.assemblies_by_design(doc, know) or {}
if preselected:
seen = set()
items = []
for asm in preselected:
try:
asm_key = asm.Id.IntegerValue
except Exception:
asm_key = None
if asm_key is None:
try:
asm_key = asm.Id.Value
except Exception:
continue
if asm_key in seen:
continue
seen.add(asm_key)
items.append([str(asm.Name), asm, _design_of(asm, groups)])
items.sort(key=lambda r: (str(r[2]), r[0]))
else:
# Nobody preselected anything, so the TOOL is choosing what to offer
# -- and it offers what the job's DECLARED SCOPE says is this
# engineer's. What it left off the list is printed, by kind, rather
# than quietly missing. A PRESELECTED batch (the branch above) is
# never filtered: the selection is the request.
the_scope = scopelib.of(know)
items, left_alone = [], []
for design, asm_list in groups.items():
for asm in asm_list:
name = str(asm.Name)
(items if the_scope.includes(name)
else left_alone).append([name, asm, design])
items.sort(key=lambda r: (r[2], r[0]))
scope_line = the_scope.line([r[0] for r in left_alone],
total=len(items) + len(left_alone),
unit='piece')
if not items:
die('There is nothing to offer for a batch.\n\n{0}'
.format(scope_line))
names = [r[0] for r in items]
output.print_md('> ' + scope_line)
picked = forms.SelectFromList.show(
names, multiselect=True,
title='Batch - choose pieces (Ctrl/Shift to multi-select)')
if not picked:
script.exit()
picked_set = set(picked)
items = [r for r in items if r[0] in picked_set]
if not items:
script.exit()
chosen = items
start_dir = knowledge.get(know, 'handling.report_dir') or \
designspec.sub_dir(doc, 'handling')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
python_exe = liftexport.find_python(know)
if not python_exe:
not_run('CPython 3.9 or newer was not found.\n\nSet '
'handling.python_exe in the knowledge file to the full '
'path of python.exe.')
compute = []
total = len(chosen)
with forms.ProgressBar(title='Batch: measuring {0} panels...'.format(total),
cancellable=True) as pb:
for i, (name, asm, design) in enumerate(chosen):
if pb.cancelled:
break
pb.update_progress(i, total)
job, problems = liftexport.export(doc, asm, know, sheets,
design, batch=True)
if job is None:
continue
stages, _rows, stage_problems = liftexport.default_stages(job, know)
problems = list(problems or []) + list(stage_problems or [])
missing = [s['name'] for s in stages
if s.get('enabled', True) and not s.get('fc_psi')]
if problems or missing:
continue
job['stages'] = stages
job_path = os.path.join(start_dir or '',
'{0}.job.json'.format(name))
blob = json.dumps(liftexport.strip_private(job), indent=1,
sort_keys=True)
with io.open(job_path, 'w', encoding='utf-8') as fh:
fh.write(as_text(blob))
compute.append([name, job_path])
pb.update_progress(len(chosen), total)
if not compute:
not_run('Nothing was measured for batch (all refused or '
'cancelled).')
status_path = os.path.join(start_dir or '', 'batch-status.txt')
# The thread that runs the batch outlives this command. pyRevit tears
# the command module's scope down when the command returns, so a thread
# that looks ANY module global up from that scope -- `io`, `os`,
# `liftexport` -- raises UnboundNameException, on a NON-MAIN thread,
# which Revit cannot catch: the whole process dies (measured
# 2026-08-27). The worker therefore lives in vibe.batchrun, whose module
# scope is the session's, not the command's, and every name it needs is
# bound as a default parameter at definition time. `run_one` does the
# same for the two names that are this file's to pass on.
#
# recommend=False, and this is the whole reason a batch is usable.
#
# The position search is 93% of a run -- 12.6 s of a 13.6 s median piece,
# 49 s on the worst. Measured through this exact path on the 184-piece
# reference sweep: 38 minutes with the search on, 2.5 minutes without,
# and about half a minute without it and eight at a time. The verdicts
# are untouched -- 111 OK / 59 WARN / 13 FAIL / 1 refused either way --
# because every stage is still analysed and every check still runs. A
# batch is a triage pass: WHICH pieces need looking at. "Then where?" is
# a question about ONE piece, and the single-piece run answers it in
# thirteen seconds.
#
# The reports say so on the page. A batch of reports whose Recommended
# positions section had quietly vanished would read, 184 times over, as
# 184 pieces with nothing to recommend.
def run_one(name, job_path, out_path, lx=liftexport, px=python_exe):
lx.run(px, job_path, out_path, recommend=False)
# Several at once. Each job is already an isolated CPython subprocess
# writing its own files, so this is a scheduling change, not a numerical
# one -- and two cores are left alone so Revit stays usable while it runs.
at_once = batchrun.worker_count()
worker = batchrun.make_worker(compute, status_path, run_one,
workers=at_once)
Thread(ThreadStart(worker)).Start()
output.print_md('**Batch started in the background.**')
# The scope, on the page, every time. A batch of the pieces this engineer
# owns and a batch of everything in the model look identical once they
# are running.
_scope = scopelib.of(know)
_outside = [name for name, _asm, _d in chosen if not _scope.includes(name)]
output.print_md('> ' + _scope.line([], unit='piece'))
if _outside:
output.print_md('> ' + (_scope.annotation(_outside, unit='piece') or ''))
output.print_md('Panels were measured on the UI thread; each report is '
'now computed in the background ({0} CPython subprocesses '
'at a time, two cores left free), so Revit stays usable.'
.format(at_once))
# Said here as well as in every report. Somebody who starts a batch and
# then opens one of its reports looking for the move list has to find out
# why it is not there, and the place they are looking at the moment they
# wonder is this page.
output.print_md('> **Insert positions are not searched in a batch.** That '
'search is 93% of a run — it is what made a sweep of '
'this model take 38 minutes instead of half a minute. '
'Every stage is still '
'analysed and every check still runs, so the verdicts are '
'the whole answer to *which pieces need looking at*. Run a '
'piece on its own to be told **where** its inserts should '
'go; each report says so where the positions would be.')
output.print_md('Reports + job files go to:\n\n `{0}`'.format(start_dir))
output.print_md('Progress is written to:\n\n `{0}`'.format(status_path))
script.close()
# -------------------------------------------------- mode: batch / assembly
# The selection decides. One assembly preselected (any member of one
# counts) runs straight through as the single-panel check. Several
# preselected run as a background batch on exactly what is highlighted.
# Nothing preselected asks.
#
# Deliberately off the preselection rather than a list: the piece is the
# subject of the whole run, and picking it from several hundred assemblies
# is worse than clicking it in the model.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not assemblies:
owners = []
for el in selection:
try:
owner = doc.GetElement(el.AssemblyInstanceId)
except Exception:
owner = None
if isinstance(owner, AssemblyInstance) and owner not in owners:
owners.append(owner)
assemblies = owners
if len(assemblies) > 1:
_run_batch(assemblies)
script.exit()
if not assemblies:
_modes = ['Batch (many pieces)', 'Assembly (one piece)']
_mode = forms.SelectFromList.show(_modes, multiselect=False,
title='Panel Lift Check')
if not _mode:
script.exit()
if _mode.startswith('Batch'):
_run_batch()
script.exit()
die('Select the assembly you want checked, then run this again.\n\n'
'Selecting any member of it works too.')
asm = assemblies[0]
# ------------------------------------------------------------------ read it
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
not_run(problem)
if not (knowledge.get(know, 'handling.lifter_groups', {}) or {}):
not_run('No handling.lifter_groups in the knowledge files.\n\n'
'The check needs to know which family is a face lifter and '
'which is an edge anchor. Add them to\n\n{0}\n\nand run again.'
.format(designspec.plant_knowledge_path()))
with forms.ProgressBar(title='Measuring {0}...'.format(asm.Name)) as pb:
pb.update_progress(0, 3)
_summaries, sheets = ticketqc.collect_context(doc)
design = None
for key, group in ticketqc.assemblies_by_design(doc, know).items():
if any(a.Id == asm.Id for a in group):
design = key
break
pb.update_progress(1, 3)
job, problems = liftexport.export(doc, asm, know, sheets, design)
pb.update_progress(3, 3)
if job is None:
not_run('\n\n'.join(problems) +
'\n\nFix the cause above and run again.')
panel = job['panel']
# Before anything is calculated: does the geometry we read weigh what the
# model says it does? A blockout read wrong is silent, and it makes the
# section modulus wrong in the direction that says a panel is fine.
ok, message, _numbers = liftexport.weight_check(job, know)
if not ok:
not_run(message + '\n\nFix the weight mismatch and run again.',
title='Panel Lift Check - {0}'.format(panel['mark']))
stages, _rows, stage_problems = liftexport.default_stages(job, know)
if not stages:
not_run('No handling.stages in the knowledge files, so there is nothing to '
'check the piece through.')
if not [s for s in stages if s.get('enabled', True)]:
# Some stages are configured and every one of them is switched off. That
# is a different fact from "no stages", and it belongs here rather than as
# a JobError out of the calculation, where it would arrive as a stderr
# line in a dialog about a job file the engineer never opened.
not_run('Every handling stage is switched off in the configuration '
'({0}), so there is nothing to check. Enable at least one stage '
'in Project Config.'
.format(', '.join(s['name'] for s in stages)))
if stage_problems:
not_run('\n\n'.join(stage_problems),
title='Panel Lift Check - plant dunnage setting')
# The audit used to refuse the run. It was guarding an automatic pick nobody
# could see; now every insert is clicked on the piece, so a bad default costs
# a few clicks rather than a wrong answer. It is still worth saying out loud,
# because it means the highlighted defaults are not to be trusted here.
audit = liftexport.stage_audit(stages)
# --------------------------------------------------------- confirm the picks
# The inserts for each stage are picked IN THE MODEL, one stage at a time.
#
# This used to be a checklist of rows. It was wrong twice over: a row is the
# tool's guess about which inserts share a hook, and the label named one family
# when the row held two -- which is how one edge anchor and two face lifters an
# inch apart read as three edge anchors, and a whole support line went missing.
#
# Nobody can audit a checkbox that says 5'-8". Everybody can audit three
# inserts lit up on the piece in front of them. The knowledge rules still
# choose the defaults, so the usual answer is still one click; they just no
# longer get the last word, and they no longer get it silently.
by_mark = dict((l['mark'], l) for l in panel['lifters'])
insert_ids = set(int(m) for m in by_mark)
class OnlyTheseInserts(ISelectionFilter):
"""Only the lifting inserts of this piece can be clicked.
Without it the panel itself, its rebar and its neighbours are all pickable,
and a stray click becomes a support line the piece does not have.
"""
def AllowElement(self, element):
try:
return ticketqc.eid(element) in insert_ids
except Exception:
return False
def AllowReference(self, reference, point):
return False
pick_filter = OnlyTheseInserts()
def elements_of(marks):
found = []
for mark in marks:
try:
el = doc.GetElement(ElementId(int(mark)))
except Exception:
el = None
if el is not None:
found.append(el)
return found
def zoom_state():
"""The active view's zoom, so it can be put back after the fit."""
try:
for uiview in uidoc.GetOpenUIViews():
if uiview.ViewId == uidoc.ActiveView.Id:
corners = uiview.GetZoomCorners()
return (uiview, corners[0], corners[1])
except Exception:
pass
return None
def id_int(element_id):
try:
return element_id.IntegerValue
except Exception:
return element_id.Value
def show_the_piece():
"""Isolate the piece in the view the user is already in, and see into it.
Their view, not one this picks for them and not a new one: the view on
screen is the view they chose to be in.
Isolating is not enough on its own. An insert is cast INSIDE the concrete,
so in any shaded view the panel is standing in front of every one of them
-- you can still click them, because the filter passes the click through
to the insert, but the highlight that says which one you got is painted
behind six inches of opaque concrete. The pick works and shows you
nothing. So everything that is not an insert goes 85% transparent, which
leaves the panel and its openings as an outline to place them against.
A view template can lock the display style -- wireframe is not available
to us here -- but per-element overrides go through it, which is why this
is done insert by insert rather than by changing how the view draws.
Those two things are all this button writes to the model, and it writes
them twice: on, and off again. Everything else here reads.
Returns the callable that puts the view back.
"""
view = uidoc.ActiveView
ids = List[ElementId]()
ids.Add(asm.Id)
others = []
for member in asm.GetMemberIds():
ids.Add(member)
if id_int(member) not in insert_ids:
others.append(member)
ghost = OverrideGraphicSettings()
ghost.SetSurfaceTransparency(85)
try:
with tx('Show assembly'):
view.IsolateElementsTemporary(ids)
for member in others:
try:
view.SetElementOverrides(member, ghost)
except Exception:
pass
except Exception:
die('This view cannot isolate the piece, so there is nothing to pick '
'on. Schedules and sheets have no geometry to hide.\n\nOpen a view '
'that shows {0} and run the check again.'.format(asm.Name))
zoom = zoom_state()
# ZoomToFit and not ShowElements: with everything else hidden the fit IS
# the piece, and ShowElements can raise a modal dialog when it disagrees
# about whether a view shows something.
if zoom is not None:
try:
zoom[0].ZoomToFit()
except Exception:
pass
def restore():
plain = OverrideGraphicSettings()
try:
with tx('Show assembly'):
for member in others:
try:
view.SetElementOverrides(member, plain)
except Exception:
pass
view.DisableTemporaryViewMode(
TemporaryViewMode.TemporaryHideIsolate)
except Exception:
pass
if zoom is not None:
try:
zoom[0].ZoomAndCenterRectangle(zoom[1], zoom[2])
except Exception:
pass
return restore
def pick_inserts(stage, preset):
"""Click the inserts for one stage. None if the user pressed Esc."""
pre = List[Reference]()
ids = List[ElementId]()
for el in elements_of(preset):
try:
pre.Add(Reference(el))
ids.Add(el.Id)
except Exception:
pass
# Said twice on purpose. PickObjects takes the pre-selected references and
# returns them, but does not reliably paint them, and a default the user
# cannot see is a default they cannot accept. Setting the selection first
# puts them on the screen in the ordinary blue.
try:
uidoc.Selection.SetElementIds(ids)
uidoc.RefreshActiveView()
except Exception:
pass
prompt = ('{0} of {1}: select the inserts that carry the piece, then '
'click Finish'.format(stage['name'].upper(), panel['mark']))
try:
try:
refs = uidoc.Selection.PickObjects(
ObjectType.Element, pick_filter, prompt, pre)
except TypeError: # older binding: no pre-selected overload
refs = uidoc.Selection.PickObjects(
ObjectType.Element, pick_filter, prompt)
except OperationCanceledException:
return None
picked = []
for ref in refs:
el = doc.GetElement(ref.ElementId)
if el is None:
continue
mark = str(ticketqc.eid(el))
if mark in by_mark and mark not in picked:
picked.append(mark)
return picked
def tally(marks):
"""Families counted, and the stations they stand at, in order."""
counts = {}
stations = []
for mark in sorted(marks, key=lambda m: by_mark[m]['x']):
lifter = by_mark[mark]
counts[lifter['family']] = counts.get(lifter['family'], 0) + 1
where = ticketqc.fmt_feet(lifter['x'])
if where not in stations:
stations.append(where)
return (', '.join('{0} x{1}'.format(f, c)
for f, c in sorted(counts.items())),
', '.join(stations))
put_it_back = show_the_piece()
support_note = ''
supported = [s for s in stages
if s.get('supports') and s.get('enabled', True)]
if supported:
support_note = ('\n\nNot picked, read from plant knowledge: ' +
'; '.join(
'{0} on dunnage at {1}'.format(
s['name'],
', '.join(ticketqc.fmt_feet(x)
for x in s['supports']))
for s in supported))
forms.alert(
'You will be asked to select the inserts for each stage in turn, on the '
'piece itself:\n\n {0}\n\nWhat the rules expect is highlighted '
'already. Ctrl-click adds, Shift-click removes, then Finish on the '
'options bar. Esc cancels the whole run.{1}{2}'
.format(' -> '.join(
s['name'] if s.get('enabled', True)
else '{0} (not analysed)'.format(s['name']) for s in stages),
'' if not audit else
'\n\nThe defaults need a look on this piece:\n\n ' +
'\n '.join(audit),
support_note),
title='{0} - inserts'.format(panel['mark']))
chosen = []
try:
for stage in stages:
# A stage switched off in the plant config is not analysed, so there
# is nothing to pick for it. It still travels into the job file, and
# panellift still prints it -- as the sentence "not performed", in
# its place among the stages that ran. Asking for inserts here would
# be asking the engineer to configure a check nobody is running.
if stage.get('enabled', True) is False:
chosen.append(stage)
continue
if stage.get('supports'):
chosen.append(stage)
continue
preset = list(stage['lifters'])
while True:
marks = pick_inserts(stage, preset)
if marks is None:
script.exit()
if len(marks) < 2:
forms.alert('{0} needs at least two inserts to be a pick, '
'and {1} were selected.'
.format(stage['name'], len(marks)),
title='Panel Lift Check')
preset = marks or preset
continue
if len(marks) < len(stage['lifters']):
families, stations = tally(marks)
# Fewer inserts than the rules expected is a real pick and
# often the right one -- a crane that cannot reach the top
# row, an insert cast over. It is worth one question because
# a support line dropped by accident is invisible in the
# result: the report is complete, the diagrams are drawn, and
# the piece was checked on a pick it does not have.
if not forms.alert(
'{0}: {1} inserts selected where the rules expected '
'{2}.\n\n {3}\n at {4}\n\nUse these?'
.format(stage['name'], len(marks),
len(stage['lifters']), families, stations),
title='{0} - {1}'.format(panel['mark'],
stage['name']),
ok=False, yes=True, no=True):
preset = marks
continue
stage['lifters'] = marks
chosen.append(stage)
break
finally:
# Esc out of a pick raises through here. Leaving a temporary isolate
# switched on in somebody's view because they changed their mind is not
# something they should have to work out how to undo.
if put_it_back is not None:
put_it_back()
job['stages'] = chosen
missing = [s['name'] for s in chosen
if s.get('enabled', True) and not s.get('fc_psi')]
if missing:
not_run('No concrete strength for: {0}.\n\nThe design summary for '
'{1} did not give an f\'c or f\'ci that these stages could '
'use.'.format(', '.join(missing), design or 'this piece'))
# ------------------------------------------------------------ where it goes
start_dir = knowledge.get(know, 'handling.report_dir') or \
designspec.sub_dir(doc, 'handling')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
out_path = forms.save_file(
file_ext='html', init_dir=start_dir or '',
default_name='{0}.html'.format(panel['mark']),
title='Save the handling report as')
if not out_path:
script.exit()
# ------------------------------------------------------------------- run it
python_exe = liftexport.find_python(know)
if not python_exe:
not_run('CPython 3.9 or newer was not found.\n\n'
'The calculation runs outside Revit so it can be tested '
'without one. Install Python, or set handling.python_exe in '
'the knowledge file to the full path of python.exe.')
# The job file is kept beside the report on purpose. It is the whole input to
# the calculation, so a number anybody disputes can be re-run, by hand, on a
# machine with no Revit on it.
job_path = os.path.splitext(out_path)[0] + '.job.json'
blob = json.dumps(liftexport.strip_private(job), indent=1, sort_keys=True)
with io.open(job_path, 'w', encoding='utf-8') as fh:
fh.write(as_text(blob))
# Whether this run is willing to spend the 13 seconds. ON by default and ON
# after any failure to read the file, exactly like expensive_geometry: an
# unreadable options file must never quietly take half the report away.
search_positions = True
try:
search_positions = bool(configmodel.load_run_options(
designspec.root_dir(doc))['recommend_positions'])
except Exception:
search_positions = True
# The position search is the long part and it can genuinely run for minutes on
# a piece with many movable rows. It runs on Revit's UI thread, so without a
# progress bar being ticked the window is simply frozen, which nobody can tell
# apart from a crash -- and a run that looks crashed gets killed.
#
# The title tells the truth about which of the two it is doing. A bar that says
# "searching insert positions" on a run that is not searching them is the same
# lie as a report section that quietly disappears.
with forms.ProgressBar(title='{0}: {1}... {{value}}s'.format(
panel['mark'],
'searching insert positions' if search_positions
else 'checking the piece (position search off)'),
cancellable=True) as pb:
cancelled = []
def tick(elapsed_ms):
if pb.cancelled:
cancelled.append(True)
pb.update_progress(elapsed_ms // 1000, max(60, elapsed_ms // 1000))
pb.update_progress(0, 60)
code, summary, stderr = liftexport.run(python_exe, job_path, out_path,
on_tick=tick,
recommend=search_positions)
if code == 2 or summary is None:
not_run('The calculation could not run.\n\n{0}\n\n'
'Job file kept at:\n{1}'.format(stderr or 'no error text',
job_path))
# exit 1 is a RESULT (a stage failed its check), not a crash -- it is
# shown as FAIL in the table below, which is 'calculated and failed',
# distinct from the NOT RUN above.
if knowledge.get(know, 'handling.open_report', True):
liftexport.open_file(out_path)
# -------------------------------------------------------------------- report
output.print_md('# {0} &mdash; handling'.format(panel['mark']))
# The scope, on the single-piece report too. This piece was SELECTED, so it
# was never a candidate for being set aside -- but if it is outside the
# declared scope the reader is told that the numbers rest on knowledge nobody
# validated for that kind of piece.
_scope = scopelib.of(know)
output.print_md('> ' + _scope.line([], unit='piece'))
if not _scope.includes(panel['mark']):
output.print_md('> ' + (_scope.annotation([panel['mark']], unit='piece')
or ''))
output.print_md('Design **{0}** &middot; {1} &times; {2} &times; {3}" '
'&middot; {4} lb'
.format(panel.get('design_number') or '-',
ticketqc.fmt_feet(panel['length_ft']),
ticketqc.fmt_feet(panel['width_ft']),
panel['thickness_in'],
'{0:,.0f}'.format(panel['weight_lb'] or 0)))
# A bare Windows path is not a URL: with backslashes and no scheme a
# browser will not navigate to it, so the link rendered but did nothing.
# The text stays the real path -- that is what the engineer copies -- and
# only the href gets the file:/// form.
_href = 'file:///' + out_path.replace('\\', '/')
output.print_md('**{0}** &nbsp; [{1}]({2})'.format(summary['verdict'],
out_path, _href))
# The verdict above is the answer for the stages that RAN. If one was
# switched off, saying only "OK" here claims the piece, and a reader who
# takes the button's one bold word and closes the window has been told the
# panel is fine when a whole handling condition was never looked at.
_not_run = summary.get('not_run') or []
if _not_run:
output.print_md(
'> **{0} of {1} stages were not analysed** &mdash; {2}. The verdict '
'above covers the stages that were.'
.format(len(_not_run), len(summary['stages']),
'; '.join('{0}{1}'.format(
s['name'], ' ({0})'.format(s['reason'])
if s.get('reason') else '') for s in _not_run)))
# WHICH END IS UP, when the ruled mechanism did not answer it.
#
# `knowledge.up_end_from_origin` is the project's flagship declared-coverage
# refusal, and `liftexport.erected_orientation` falls back to global elevation
# when it declines. The fallback is ALLOWED -- global elevation is the sound
# mechanism for a piece erected on end, which is the case the rule hands over
# -- but it may never be silent, or it is a guess wearing the refusal's
# credibility. The key is written only when the fallback answered, so this
# block never fires on a piece the rule settled.
_basis = summary.get('basis')
if _basis:
output.print_md(
'> **Which end is up came from a fallback, not the rule.** Read from '
'{0}. {1}<br>What it decides: {2}. Stripping and transportation are '
'analysed flat and are unchanged by it.'
.format(_basis.get('basis'), _basis.get('why') or '',
_basis.get('affects') or 'the erected stage'))
for problem in problems:
output.print_md('> {0}'.format(problem))
def _stage_row(s):
"""One row of the summary table. A stage that was not analysed gets
words across the numeric columns, not dashes: a dash in a column of
numbers is read as a number that came out small."""
if not s.get('analysed', True):
return [s['name'], '-', '-', 'not analysed', '-', '-', 'NOT RUN']
return [s['name'], '{0:.2f}'.format(s['multiplier']),
'{0:,.0f}'.format(s['fc_psi']),
'-' if s['peak_tension_psi'] is None
else '{0:,.0f}'.format(s['peak_tension_psi']),
'{0:,.0f}'.format(s['allowable_psi']),
'-' if s['ratio'] is None else '{0:.0%}'.format(s['ratio']),
s['verdict']]
output.print_table(
[_stage_row(s) for s in summary['stages']],
columns=['stage', 'mult', "f'c", 'peak psi', 'allowable', 'of allow',
'result'])
# What was actually picked, in writing. The numbers above mean nothing without
# it, and it is the one part of the run that came from a human.
#
# A stage carried on dunnage was not picked and has no 'lifters' key at all --
# it is dropped rather than emptied, because panellift refuses a stage that
# gives both a support line and an insert. So it gets its own row saying where
# it sat, rather than an empty one implying nobody chose anything.
output.print_md('## Inserts picked')
rows = []
for s in chosen:
if s.get('enabled', True) is False:
rows.append([s['name'], '-', 'not analysed - no pick was made', '-'])
elif s.get('supports'):
rows.append([s['name'], '-', 'on dunnage (plant knowledge)',
', '.join(ticketqc.fmt_feet(x) for x in s['supports'])])
else:
marks = s.get('lifters') or []
rows.append([s['name'], str(len(marks))] + list(tally(marks)))
output.print_table(rows, columns=['stage', 'n', 'inserts', 'at'])
rec = summary.get('recommendation') or {}
if rec.get('searched') is False:
# `searched` is written by panellift ONLY when the search was skipped, so
# `is False` is the whole test and a summary from a build that predates
# the flag reads as "it ran" -- which it did. Printed under the heading it
# would have filled, not dropped: a missing section reads as a piece with
# nothing to recommend, and this piece was never asked.
output.print_md('## Recommended insert positions')
output.print_md(
'**NOT COMPUTED** &mdash; the position search is switched off for '
'this run (`recommend_positions` in Project Config &rarr; run-time '
'options). Every stage above was checked on the inserts exactly as '
'drawn and the verdict stands; what is missing is only where they '
'should go instead. Switch it back on and run the piece again to get '
'it.')
elif rec.get('note') and not rec.get('moves'):
output.print_md('> {0}'.format(rec['note']))
elif rec.get('moves'):
output.print_md('## Recommended insert positions')
if rec.get('worth_moving'):
output.print_md(
'Moving them takes the governing stage from **{0:.0%}** of '
'allowable to **{1:.0%}**.'.format(rec['as_built_ratio'],
rec['recommended_ratio']))
else:
output.print_md('The inserts are already where the search would put '
'them.')
output.print_table(
[[m['mark'], m['family'],
ticketqc.fmt_feet(m['from_x_ft']), ticketqc.fmt_feet(m['from_y_ft']),
ticketqc.fmt_feet(m['to_x_ft']), ticketqc.fmt_feet(m['to_y_ft']),
'-' if abs(m['dx_in']) < 0.005 else '{0:+.2f}'.format(m['dx_in']),
'-' if abs(m['dy_in']) < 0.005 else '{0:+.2f}'.format(m['dy_in']),
'pinned' if m['pinned'] else ('move' if m['moved'] else '')]
for m in rec['moves']],
columns=['insert', 'family', 'x', 'y', 'to x', 'to y',
'dx in', 'dy in', ''])
for row in rec.get('rows') or []:
if row.get('note'):
output.print_md('- {0}: {1}'.format(
ticketqc.fmt_feet(row['to_x_ft']), row['note']))
output.print_md(
'> The recommendation is one position per insert, solved for every '
'stage at once. **x** is searched to minimise the governing '
'utilisation; **y** is geometry only &mdash; a beam model has no '
'transverse mechanics, so whether the piece hangs level is stated '
'from the layout and not calculated.')
for stage in summary['stages']:
bad = stage['findings']
if not bad:
continue
output.print_md('### {0}'.format(stage['name']))
for f in bad:
output.print_md('- **{0}** `{1}` &mdash; {2}'
.format(f['severity'], f['code'], f['message']))
output.print_md('---')
output.print_md('Report: `{0}` \nJob file: `{1}`'.format(out_path, job_path))
@@ -0,0 +1,11 @@
title: Reinforce
# 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. It is the only large button on this ribbon that does
# not break, and it fits inside the width the two-line ones already take.
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,425 @@
# -*- coding: utf-8 -*-
#
# Reinforcing suggestion for one assembly: which already-reinforced panels,
# between them, account for every feature of this one.
#
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
# a similarity ranking. Every detail of the target has to be covered by
# something, and the number of donors should be as small as possible. The
# search lives in `vibe.reinforcematch`, the candidate index in
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
# the Revit end and nothing more: it resolves the selection, decides how
# current the index has to be, and prints.
#
# READ ONLY, AND THAT IS THE POINT
# --------------------------------
# No Transaction is opened anywhere in this file or anything it calls. Nothing
# is written to the model -- not a parameter, not a rebar, not a copy of a
# donor's cage. The tool proposes; a human decides and a human draws it. This
# is the single most important constraint on the feature, because a suggestion
# tool is exactly the thing somebody will ask to "just copy it in for me", and
# the middle band of a wall panel's vertical reinforcing is explicitly PER
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
# automatically would look like an ordinary suggestion and would be the most
# dangerous thing this feature could do.
#
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
# ----------------------------------------------------
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
# on which a button press deserves. Which one to spend is a UI policy question
# (how many seconds may a click cost, and what does a modifier key mean), so it
# belongs at the UI, in one readable place, not buried in the library where two
# callers would end up disagreeing.
#
# The policy, with the measured numbers it rests on:
#
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
# moved. Nothing moved is the common case and then nothing is rebuilt and
# nothing is rewritten: about a second. A full rebuild on every press is
# 10 s of frozen UI to answer a question that usually has not changed.
# * cold or empty index -- a full build regardless, ~10 s, with a progress
# bar. There is no cheaper honest answer available.
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
#
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
# ---------------------------------------------------
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
# only between donor sets the geometry cannot separate. Owner's ruling: fit
# wins. The report marks it and says so.
#
# The staleness signature is `reinforced | on_hold | master | member-count |
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
# catch a geometry edit that leaves the member set alone -- an opening moved or
# resized in place, a panel stretched. Those pieces serve a stale measurement
# until something else about them moves.
#
# So an ordinary press can be wrong in exactly that one way, and the report
# says so every time, in `reinforcereport.staleness_note`, along with the
# instruction to shift-click. The report also always prints when the index was
# built, so a reader who knows he has been editing geometry can tell whether
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
# inconvenience, not a last resort.
#
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
# -----------------------------------------------------
# 1. One target per press. The engineer is reinforcing one piece; a batch of
# suggestions is a report nobody reads. Several assemblies preselected asks
# which one rather than fanning out.
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
# told, so it is in the footer of every report and in the staleness note
# the report prints when the index may be out of date. It used to be in the
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
# the report is now the only place that says it. A dialog
# on every press would be worse: the answer is the same 99 times out of 100.
# 3. The outline audit runs automatically the first time only. It costs one
# geometry read per indexed piece, and its answer changes only when the
# model does. Never-audited and audited-and-found-none are different
# answers, so the report says which one it has.
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
# four cannot account for everything, the report prints the best four and
# names what they miss -- it does not relax to a fifth. That overflow rule
# is INFERRED from the ruling, not stated by the owner, and is the thing to
# overrule first if this behaves wrongly on a real piece.
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
# 6. A target that is itself already reinforced, or on hold, is answered
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
# rules -- but silently treating an already-reinforced piece as a fresh
# request would hide a misclick.
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
# count as a problem is `reinforcereport`'s decision and is stated there,
# section by section; the one to argue with first is the staleness rule,
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
# a folded header names what it holds, and the whole report is written
# unfolded to `last-report.txt` beside the index on every press.
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
# "not judged" and an exit. It used to lose the index stamp and the
# staleness note on the way out, which are exactly what a reader needs in
# order to know whether to believe a refusal.
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
# before a single comparison is made, and when no same-design donor exists
# this button REFUSES -- it names the design, counts what the pool actually
# held, and says the way out. It never falls back to the nearest other
# design, not even labelled as one. Packet suffixes do not split a design:
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
# `specformat.design_key` reduction Ticket QC already uses.
# A design number nobody could read is UNJUDGED, never a match -- an index
# whose design numbers are all empty (which is what a build with no
# knowledge layer produces) refuses and asks for a rebuild.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinforce'
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
# the force-full-rebuild gesture. See the notes at the top of this file.
__shiftclick__ = True
import os
from Autodesk.Revit.DB import AssemblyInstance, ElementId
from pyrevit import revit, script, forms
from vibe import compat
from vibe import foldreport as fold
from vibe import knowledge
from vibe import scope as scopelib
from vibe import reinforcebind as bind
from vibe import reinforcecore as core
from vibe import reinforceindex as index
from vibe import reinforcereport as R
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = R.TITLE
def die(message):
"""A usage error: the user has not given us something yet. Not a refusal
to judge -- those go in the report, where they survive the dialog."""
forms.alert(message, title=TITLE, exitscript=True)
# `not_judged` used to live here: a short page and an exit, for a target that
# could not be measured. It is gone, and deliberately -- 'could not judge' is
# a RESULT, and printing it on a stripped-down page dropped the index stamp
# and the staleness note, which are precisely what tells a reader whether to
# believe the refusal. `reinforcereport.render` now returns the whole page for
# that case too, with the headline saying NOT JUDGED, and this file prints it
# the same way it prints an answer.
# ------------------------------------------------------------ force rebuild
def _forced():
"""True when the button was shift-clicked. Never fatal if unavailable."""
try:
from pyrevit import EXEC_PARAMS
return bool(EXEC_PARAMS.config_mode)
except Exception:
return False
# ------------------------------------------------------------- the selection
#
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
# straight through, a picked member resolves to its owning assembly, nothing
# picked offers a list. The one difference is that this tool answers about
# ONE piece, so several preselected assemblies ask which.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not picked_assemblies:
owners = []
for el in selection:
try:
owner = doc.GetElement(el.AssemblyInstanceId)
except Exception:
owner = None
if isinstance(owner, AssemblyInstance) and owner not in owners:
owners.append(owner)
picked_assemblies = owners
if selection and not picked_assemblies:
die('What is selected is not part of an assembly, so there is no piece '
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
'any member of it -- and run this again.')
all_assemblies = index.assemblies(doc)
by_name = dict(all_assemblies)
# The knowledge file is read here rather than further down, because the pick
# list below needs the DECLARED SCOPE before it can offer anything.
try:
know = knowledge.load(doc)
except Exception as exc:
know = None
output.print_md('> The knowledge file could not be read ({0}). No scope '
'is declared, so every assembly in the model is offered '
'&mdash; and **design numbers will be blank**, which '
'since 2026-09-01 means this run will REFUSE rather than '
'answer: a donor has to carry the same design number as '
'the target, and an unread design number is never assumed '
'to match. Fix the knowledge file, or shift-click to '
'rebuild the index once it reads.'.format(exc))
the_scope = scopelib.of(know) if know else scopelib.Scope()
if not picked_assemblies:
# The list this button OFFERS is scoped: this is the TOOL choosing, and
# it chooses inside the declared scope. What the user picks HIMSELF is
# never filtered -- see the branches below and the report's annotation.
names, left_alone = the_scope.split_names(
sorted(n for n, _a in all_assemblies))
if not names:
die('There is nothing here to pick from.\n\n{0}'
.format(the_scope.line(left_alone, total=len(all_assemblies),
unit='assembly')))
chosen = forms.SelectFromList.show(
names, multiselect=False, title=TITLE + ' - which piece',
button_name='Suggest donors')
if not chosen:
script.exit()
target_name = chosen
elif len(picked_assemblies) > 1:
names = sorted(str(a.Name) for a in picked_assemblies)
chosen = forms.SelectFromList.show(
names, multiselect=False,
title=TITLE + ' - one piece at a time',
button_name='Suggest donors')
if not chosen:
script.exit()
target_name = chosen
else:
target_name = str(picked_assemblies[0].Name)
target_asm = by_name.get(target_name)
if target_asm is None:
die('{0} is not an assembly in this model.'.format(target_name))
def _flag(el, pname):
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
try:
p = el.LookupParameter(pname)
return None if p is None else int(p.AsInteger())
except Exception:
return None
target_reinforced = _flag(target_asm, index.P_REINFORCED)
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
# ------------------------------------------------------------------ the index
#
# See the notes at the top of this file for the policy. The cheap plan is
# computed here rather than inside `refresh` so that the commonest press --
# nothing changed -- can skip `refresh` entirely and avoid a second
# signature pass, and so the progress bar knows how many pieces it is about
# to build.
forced = _forced()
# `know` was loaded above, for the pick list. Past the scope it is used for
# one optional thing -- reading each piece's design number for the report --
# so a missing or broken one costs a label, not the answer. It is not worth
# stopping the run over, unlike in Ticket QC where every check depends on it.
cache = index.read(doc)
if cache.get('_load_error'):
output.print_md('> The index on disk could not be read ({0}); it is being '
'rebuilt from the model. A cache is derived data, so this '
'costs time and nothing else.'
.format(cache['_load_error']))
forced = True
live = index.live_signatures(doc, all_assemblies, know=know)
the_plan = core.plan(cache, live)
todo = len(the_plan['build'])
refreshed = bool(todo or the_plan['drop']) or forced
refresh_report = None
if refreshed:
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
bar = 'Indexing donors ({value} of {max_value})'
if forced:
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
with forms.ProgressBar(title=bar, cancellable=False) as pb:
state = {'n': 0}
def _tick(_name):
state['n'] += 1
pb.update_progress(state['n'], max(total, 1))
pb.update_progress(0, max(total, 1))
cache, refresh_report = index.refresh(
doc, rebuild_all=forced, know=know, on_piece=_tick)
# The outline audit: once, unless forced. It is what keeps an approximated
# edge condition from ever being printed as a measured exact match.
audit_needed = forced or (bind.load_audit(doc) is None)
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
indeterminate=True):
suggestion, info = bind.suggest_for_piece(
doc, target_name, refresh=False, audit=audit_needed, know=know)
# ------------------------------------------------------------------- report
#
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
#
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
#
# So the target and the donor proposals are the page, printed big and first
# through pyRevit's own markdown and tables -- which is also what can linkify
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
# section that has a problem in it opens itself. That turns the page into a
# contract: if nothing below the answer is open, nothing needs him.
#
# The rule lives in `vibe.foldreport`, not here, and the sections come from
# `reinforcereport.render` -- the same sections the offline CLI prints as
# plain text, so the two cannot say different things.
#
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
# -----------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
# hosted in WinForms, running in IE7 document mode unless the host process
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
# element renders inline and the "collapsed" content is simply always visible.
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
# but the toggle has NOT been exercised inside Revit, so it is designed for
# failure rather than trusted:
#
# * a section with a problem in it renders OPEN, as plain HTML, and does not
# need the script to run at all;
# * the whole report, nothing folded, is written to `last-report.txt` beside
# the index every press, and the page says where. A fold that will not
# open therefore costs a file open, never a lost sentence.
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
'last-report.txt')
def _donor_link(_name, record):
"""pyRevit's clickable element id, for a donor's line in the headline."""
if not (record or {}).get('asm_id'):
return None
try:
return output.linkify(ElementId(record['asm_id']), 'select assembly')
except Exception:
return None
best = suggestion.best if suggestion is not None else None
rows = bind.explain(suggestion, info, best) if best is not None else []
sections = R.render(suggestion, info, rows, cache,
model=doc.Title,
refresh_report=refresh_report,
rebuilt_all=forced, refreshed=refreshed,
offline=False,
reinforced=target_reinforced, on_hold=target_on_hold,
full_path=full_path, donor_link=_donor_link)
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
# even if the printing throws.
try:
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
# string, and a piece mark or design number carrying a non-ASCII character
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
payload = compat.as_text(fold.text(sections, show_all=True))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
except Exception as exc:
output.print_md('> The full report could not be written to disk ({0}). '
'Everything is still on this page; use the fold headers.'
.format(exc))
# ---- the answer, through pyRevit, big and first.
for block in sections[0].blocks:
if block[0] == fold.TABLE:
output.print_table(block[2], columns=block[1])
else:
output.print_md(block[1])
# ---- everything else, folded.
tail = sections[1:]
if tail:
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
if opened:
output.print_md('---')
output.print_md('**{0} of the {1} section(s) below opened themselves '
'&mdash; they have something in them that needs you.** '
'The rest are folded; click a header to open one.'
.format(len(opened), len(tail)))
else:
output.print_md('---')
output.print_md('**Nothing below needs you.** Every section is folded; '
'click a header to open one. The whole report is also '
'in `{0}`.'.format(full_path))
output.print_html(fold.html(tail))
@@ -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.')
@@ -0,0 +1,24 @@
title: "Ticket\nQC"
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses is visible without
# a Revit restart. It trades press latency (engine construction instead of
# engine reuse) for restart-freedom, and it is the hottest button here --
# 51 commits to its six modules in the seven days to 2026-08-30.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,742 @@
# -*- coding: utf-8 -*-
#
# Check shop tickets against their design spec.
#
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
# then compares every piece of the chosen design numbers against them.
#
# Every CHECK here is pure arithmetic - the same model always gives the same
# answer, and every row of the checklist table is computed. One thing in the
# report is not: with the run-time option `vision_escalation` on, the checks
# code cannot settle (5.5) are escalated to a small model reading one rendered
# view. That result is quarantined - its own list, its own codes, its own
# section, and it can never set a checklist row. See vibe.ticketvision and
# docs/ticket-checklist.md section 8bis.
#
# Read-only. Nothing here opens a transaction, including the image export.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Ticket\nQC'
import os
from Autodesk.Revit.DB import AssemblyInstance, ElementId
from pyrevit import revit, script, forms
from vibe import ticketqc as Q
from vibe import configdump
from vibe import designspec, knowledge
from vibe import scope as scopelib
from vibe import ticketchecks as TC
from vibe import ticketvision as TV
from vibe import ticketpage as TP
from vibe import foldreport as fold
from vibe import compat
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
# ------------------------------------------------------------------ context
# The published checklist has to exist before anything else here is worth
# doing: every row this button ever prints comes off it. On a knowledge-less
# trial install (tools/make_dist.py ships no knowledge/catalogue.yaml) this
# is the ordinary, expected state -- refuse loudly and say so, rather than
# rendering a report with zero rows that would look exactly like a piece with
# nothing wrong on it. See vibe.ticketpage's CHECKLIST_UNAVAILABLE_REASON.
if TP.CHECKLIST_UNAVAILABLE_REASON:
forms.alert(TP.CHECKLIST_UNAVAILABLE_REASON, title='Ticket QC',
exitscript=True)
# The knowledge file has to come first: it holds the mapping from role to this
# office's parameter names, and without it nothing can even be grouped.
know = knowledge.load(doc)
# A project config that has gone missing means this run would use a
# configuration nobody chose, and look entirely normal doing it.
for problem in knowledge.config_problems(doc, know):
forms.alert(problem, title='Ticket QC', exitscript=True)
missing, not_applicable = knowledge.unmapped_roles(know)
if missing:
forms.alert(
'These parameter roles are not mapped in knowledge.yaml, so the '
'checks that need them cannot run:\n\n {0}\n\n'
'Edit the "parameters:" section of\n{1}\n\n'
'Run Self Check for the full picture.'
.format('\n '.join(missing), designspec.knowledge_path(doc)),
title='Ticket QC', exitscript=('panel.design_number' in missing))
# ------------------------------------------------------------- the selection
# The selection decides, exactly as Panel Lift Check does: assemblies picked
# straight through, a picked member resolves to its owning assembly, and
# nothing picked means a checklist of every assembly. The one deliberate
# difference from Panel Lift Check: there is no "batch or single" question
# here -- Ticket QC is always a batch of one or more pieces.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not assemblies:
owners = []
for el in selection:
try:
owner = doc.GetElement(el.AssemblyInstanceId)
except Exception:
owner = None
if isinstance(owner, AssemblyInstance) and owner not in owners:
owners.append(owner)
assemblies = owners
specs = designspec.list_specs(doc) if doc.PathName else {}
summaries, _sheets = Q.collect_context(doc)
grouped = Q.assemblies_by_design(doc, know=know)
# ------------------------------------------------------------- the scope
#
# The DECLARED SCOPE, off the job knowledge. Two different things are done
# with it, and the difference is the whole point:
#
# * the CHECKLIST this button offers when nothing is preselected is the
# tool choosing what to look at, so it offers what the scope declares.
# What it left off the list is stated on the report, by kind.
# * a piece the USER preselected is judged whatever its prefix. The
# selection is the request, and dropping it would be the silent
# exclusion this whole tool exists to refuse. It is judged and the
# report says the verdict rests on knowledge nobody validated for that
# kind of piece.
the_scope = scopelib.of(know)
scope_line = None # what was left off the checklist
scope_annotation = None # what was judged outside the scope anyway
if not assemblies:
# Nothing preselected: a checklist of every assembly, usable at 238
# pieces -- mark, design number, and whether a spec or only a regex
# summary backs it (the information the old design dialog showed,
# moved onto the pieces).
items = []
for design, asm_list in sorted(grouped.items()):
for asm in sorted(asm_list, key=lambda a: a.AssemblyTypeName):
if design in specs:
state = 'spec'
elif design in summaries:
state = 'regex fallback'
else:
state = 'NO spec/summary'
items.append((asm, '{0} - {1} - {2}'
.format(asm.AssemblyTypeName, design, state)))
items.sort(key=lambda r: r[1])
offered, left_alone = [], []
for row in items:
(offered if the_scope.includes(row[0].AssemblyTypeName)
else left_alone).append(row)
scope_line = the_scope.line(
[r[0].AssemblyTypeName for r in left_alone], total=len(items),
unit='piece')
items = offered
if not items:
forms.alert('There is nothing to check.\n\n{0}'.format(scope_line),
title='Ticket QC', exitscript=True)
names = [r[1] for r in items]
picked = forms.SelectFromList.show(
names, multiselect=True, title='Ticket QC - choose pieces',
button_name='Check tickets')
if not picked:
script.exit()
picked_set = set(picked)
assemblies = [r[0] for r in items if r[1] in picked_set]
if not assemblies:
script.exit()
if not grouped and not assemblies:
forms.alert(
'No assembly carries a value for the "panel.design_number" role '
'(mapped to: {0}).\n\nEither the mapping is wrong or this model does '
'not group pieces that way.'
.format(Q.role_label(know, 'panel.design_number')), exitscript=True)
# --------------------------------------------------------------------- run
# Per-piece design intent (Phase 3): the picked pieces may span designs, so
# each assembly's design number is looked up on its own -- not under one
# design's summary. Progress is visible and cancellable: context is
# collected once (not per piece), each piece runs in turn, and the bar
# advances per piece so the user sees movement rather than a frozen window.
# The first piece of an untouched design pays Revit's one-time geometry
# generation, which can take a while -- the title says so.
context = Q.collect_context(doc)
results, missing = [], []
total = len(assemblies)
bar_title = 'Checking tickets ({0} pieces) - first of a design can be slow'
with forms.ProgressBar(title=bar_title.format(total),
cancellable=True) as pb:
for i, asm in enumerate(assemblies):
if pb.cancelled:
break
pb.update_progress(i, total)
batch, miss = Q.run_for_assemblies(
doc, [asm], specs=specs, know=know, context=context)
results.extend(batch)
missing.extend(miss)
pb.update_progress(total, total)
# ------------------------------------------- the escalation, when asked for
# Checks no comparison can settle (5.5) escalate to a small model reading one
# rendered view. Its answer is NEVER a pass and NEVER a fail: the checklist
# row stays as it was and a WARN says a person must look
# (docs/ticket-checklist.md section 8bis). Kept in `model_read`, never in
# `findings`, so nothing computed and nothing read can be mistaken for each
# other. OFF unless the run option says otherwise -- a model call spends
# money and leaves the building.
_vision_on, _ = TV.run_settings(designspec.root_dir(doc))
for r in results:
r['model_read'] = []
if _vision_on and results:
with forms.ProgressBar(title='Escalating to a model reading the drawing',
cancellable=True) as pb:
for i, r in enumerate(results):
if pb.cancelled:
break
pb.update_progress(i, len(results))
try:
r['model_read'] = TV.read_for_ticket(
r['ticket'], know, designspec.root_dir(doc))
except Exception as _ex:
r['model_read'] = [TV.not_run_row(
'AI-BARMARK', 'the drawing',
'the escalation itself failed ({0})'
.format(type(_ex).__name__))]
# ------------------------------------------------------------------ report
#
# THE CHECKLIST TABLE IS THE PAGE. Everything else is appendix.
#
# Owner, 2026-08-30, after reading a real run:
#
# "大量的配置信息也都往后放,并且默认折叠。我最在乎的是那张检查的表。
# 那张检查的表放在最前面,并且字儿可以再大一点。OK用绿色,
# WARN,FOR_ENGINEER用黄色,NOTRUN,FAIL用红色。"
#
# So: the tables first and big; the design intent, the scope, the knowledge
# path, the tally and the caveats after them, folded. The fold rule is not
# this button's -- it lives in `vibe.foldreport` and it is the standing
# pattern for every report this tool prints: default folded, a section that
# carries a problem opens itself, and a folded header still says in one line
# what is inside it. The reader's contract is that if nothing below the
# tables is open, nothing below the tables needs him.
#
# WHY THE HTML IS HAND-WRITTEN AND LOOKS DATED
# ---------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine
# in WinForms, in IE7 document mode unless the host sets
# FEATURE_BROWSER_EMULATION, which pyRevit does not. `<details>` /
# `<summary>` DO NOT EXIST here and fail SILENTLY: an unknown element
# renders inline and the "collapsed" content is simply always visible. So
# the fold is an inline `onclick` plus `style.display`, which is IE5-era DOM
# and works in every mode this control can be in.
#
# The checklist table itself uses NO script and NO HTML5 -- a plain <table>
# with inline colours. If the toggle is dead, the product is still on the
# page, in full, in colour. That is the whole point of putting it outside
# the fold machinery.
#
# WHAT THE COLOURS SAY (vibe.ticketpage.state_color owns the grouping):
# OK green
# WARN, FOR ENGINEER yellow
# NOT RUN, FAIL RED -- both of them, deliberately. An unrun check
# tells you nothing about the piece, so it must never
# look calmer than a failed one. Silence is not a pass.
# Muting: what this job has DECLARED it does not want asked, with a reason
# (`checks.muted` in the job knowledge, edited on the Project Config screen).
# It filters ROWS ONLY. `r['findings']` is untouched from here to the JSON
# writer and to the clean/failing/warning tally, so a muted check that
# failed still fails the ticket and still lands on disk.
# THE MUTES, RESOLVED TO MASTER IDS AS THEY ARE READ.
#
# Owner, 2026-08-31: "以后引用需要前面加 plant-member。" A mute may be written
# as a bare master id (`3.3` -- every existing one is) or qualified against a
# plant's own board (`sample_plant-wall_panel 3.3`). Both end up keyed by the
# MASTER id, which is the whole point: when the plant renumbers its board
# tomorrow, the mute follows the QUESTION and not the POSITION. A ruling can
# no longer be orphaned by a renumbering.
_plant_id = knowledge.plant_id(doc)
muted = TP.muted_checks(know, resolver=knowledge.resolve_check_reference,
plant=_plant_id)
_mute_problems = TP.muted_reference_problems(know, plant=_plant_id)
# Applicability: what a KNOWLEDGE LAYER declares is not a question about this
# kind of piece, so the check was never run (`checks.not_applicable`). It is
# not muting and it is not scope -- see the block over
# `ticketpage.apply_applicability` for where each of the five states sits.
#
# WHICH LAYER DECLARED IT is computed rather than typed. `declared_by` in the
# entry says who RULED; this says which FILE the tool read it from, which is
# the file somebody has to edit to change it. It is resolved once per press,
# not per row -- it re-reads the layer files.
try:
_layers = configdump.layer_values(doc)
except Exception:
_layers = []
def _declaring_layer(item, condition):
if not _layers or not condition:
return None
# KEYS, NOT A DOTTED PATH: a checklist row number contains a dot.
for branch in ('not_applicable', 'only_applies_to'):
found = knowledge.declaring_layer(
_layers, ['checks', branch, item, condition])
if found:
return found
return None
for r in results:
_rows = TC.checklist_states(r['findings'], r.get('run_log'))
# A model read never sets a status. It only marks the row it looked at,
# so a reader following that row knows to read the quarantined section
# below rather than mistaking silence for a verdict.
_rows = TV.annotate_rows(_rows, r.get('model_read'))
# THE SAME RESOLUTION `run_all` USED. Two independent resolutions of
# "does this row apply" is how a page comes to describe a run that did
# not happen, so both sides call one function.
_decisions, _classification, _member, _profile = \
TC.applicability_decisions_for(r['ticket'], know)
for _num in _decisions:
_d = _decisions[_num]
if _d.get('not_applicable'):
_d['layer'] = _declaring_layer(_num, _d.get('condition'))
_rows, _na_notes = TP.apply_applicability(_rows, _decisions)
r['applicability_notes'] = _na_notes
r['applicability_problems'] = TP.applicability_problems(_decisions)
r['classification'] = _classification
r['member'] = _member
r['profile'] = _profile
r['off_board'] = TP.off_board(_decisions)
_kept, _muted_notes = TP.apply_muting(_rows, muted)
r['rows'] = _kept
# The same rows BEFORE muting. Nothing on the page reads this; it is what
# lets `qc-run.json` record the state a muted row really reported as a
# STATE rather than as a sentence buried in an English note. A grey row
# whose original answer a machine cannot read back has thrown the answer
# away, which is the thing muting is specifically not allowed to do.
r['rows_unmuted'] = _rows
r['muted_notes'] = _muted_notes
# LAST, AND IT ONLY REORDERS AND LABELS. The stable master id stays at
# `row[0]` -- `checklist_details`, the note folds, the JSON and every
# rule key on it -- and the plant's own number rides at `row[5]`.
r['rows'] = TP.profile_view(r['rows'], _profile)
r['rows_unmuted'] = TP.profile_view(r['rows_unmuted'], _profile)
r['details'] = TP.checklist_details(r['findings'])
r['counts'] = TP.checklist_tally(r['rows'])
def _loudness(r):
"""(fails, not-runs, warns, for-engineer) -- how much this ticket wants."""
c = r['counts']
return (c.get('FAIL', 0), c.get('NOT RUN', 0), c.get('WARN', 0),
c.get('FOR ENGINEER', 0))
loud, all_green = [], []
for r in results:
(loud if any(_loudness(r)) else all_green).append(r)
loud.sort(key=lambda r: (-_loudness(r)[0], -_loudness(r)[1],
-_loudness(r)[2], r['ticket']['mark']))
clean, failed, warned = [], [], []
tally = {}
for r in results:
bad = [f for f in r['findings'] if f.severity != 'OK']
for f in bad:
tally[f.code] = tally.get(f.code, 0) + 1
if not bad:
clean.append(r)
elif any(f.severity == 'FAIL' for f in bad):
failed.append(r)
else:
warned.append(r)
output.add_style(TP.CHECKLIST_STYLE)
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
# ---- the headline, then the tables. Nothing folded above this line.
output.print_md('# Shop ticket QC')
output.print_md('Model **{0}** &middot; {1} ticket(s) across {2} design '
'number(s) &middot; {3} clean &middot; {4} failing '
'&middot; {5} warning only'
.format(doc.Title, len(results),
len(set([r['design'] for r in results])),
len(clean), len(failed), len(warned)))
output.print_html(TP.checklist_key_html())
for r in loud:
t = r['ticket']
head = '**{0}** [{1}] {2} x {3} &nbsp;&mdash;&nbsp; {4}'.format(
t['mark'], r['design'],
Q.fmt_feet(t.get('length')), Q.fmt_feet(t.get('width')),
TP.checklist_headline(r['counts']))
if t.get('sheet') is not None:
head += ' &nbsp; ' + output.linkify(t['sheet'].Id, 'open ticket')
if t.get('panel_id'):
head += ' &nbsp; ' + output.linkify(ElementId(t['panel_id']), 'panel')
output.print_md(head)
output.print_html(TP.checklist_table_html(r['rows'], r['details']))
# A ticket whose every row is green needs no table -- there is nothing on it
# to read. It gets a line, and the line says the table was all green rather
# than that the ticket was "clean", which is a different and weaker claim.
if all_green:
# "green" is a claim, so it is only made when it is true. A piece whose
# quiet rows include one a knowledge layer switched off has not had every
# question answered, and saying so here is cheaper than a reader finding
# out from the section below.
_quiet = sum([r['counts'].get(TP.NOT_APPLICABLE_STATE, 0)
for r in all_green])
output.print_md('**{0} ticket(s) with nothing to read on the checklist:** '
'{1}{2}'
.format(len(all_green),
', '.join([r['ticket']['mark']
for r in all_green]),
(' &mdash; and {0} row(s) across them were NOT '
'ASKED because a knowledge layer declares they '
'do not apply; see below'.format(_quiet))
if _quiet else ' &mdash; every row green'))
# ------------------------------------------------- everything else, folded
#
# Built as `foldreport.Section`s so the same content renders as HTML here and
# as plain text into the file below -- one source, so the page and the file
# cannot say different things.
sections = []
def _section(key, title, summary, blocks, problem=None):
if not blocks:
return
sections.append(fold.Section(key, title, summary=summary, blocks=blocks,
problem=problem))
# -- pieces that could not be checked. Always a problem when it is not empty:
# 'could not be checked' is a result, not an absence.
if missing:
_section('missing', 'Pieces that could not be checked',
'{0} piece(s)'.format(len(missing)),
[(fold.MD, '- **{0}** (id {1}): {2}'
.format(m.get('mark', '?'), m.get('id', '?'),
m.get('why', 'unknown')))
for m in missing],
problem='{0} piece(s) were not judged at all'.format(len(missing)))
# -- weaker readings. A caveat that actually bites, so it opens itself.
regex_designs = sorted(set([r['design'] for r in results
if r['spec_source'] == 'regex']))
if regex_designs:
_section('regex', 'Designs read with regexes, not an extracted spec',
', '.join(regex_designs),
[(fold.MD, '**{0}** had no extracted spec, so the summary was '
'read with regexes instead. That reading is weaker '
'-- run **Extract Design** on them.'
.format(', '.join(regex_designs)))],
problem='{0} design(s) are being judged against a weaker '
'reading'.format(len(regex_designs)))
# -- WHICH PROFILE RAN, and what it switched off.
#
# Required once per run, per piece, and as loud as the scope line: a
# mistaken applicability rule is a safety check disabled in silence, and the
# only thing standing between that and a signed report is this section. So it
# names the classification AND the rows, and it opens itself whenever
# anything was switched off or any declaration was refused -- unlike muting,
# which folds when it is tidy, because a mute leaves the check running.
_na_notes, _na_problems, _profile_lines = [], [], []
_off_board = []
_seen_na = set()
for r in results:
# WHICH BOARD, THEN WHICH KIND OF PIECE. The profile line says what the
# numbers in the table even mean; the classification line says how the
# piece was placed within it. A renumbered table with neither has shown
# the reader a checklist without telling him whose.
_profile_lines.append(TP.profile_line(
r['ticket'].get('mark') or '(no mark)', _plant_id,
r.get('member'), r.get('profile'), r.get('off_board')))
for _num in (r.get('off_board') or []):
if _num not in _off_board:
_off_board.append(_num)
_profile_lines.append(TP.classification_line(
r['ticket'].get('mark') or '(no mark)', r.get('classification'),
r.get('applicability_notes')))
for note in (r.get('applicability_notes') or []):
if note[0] not in _seen_na:
_seen_na.add(note[0])
_na_notes.append(note)
for problem in (r.get('applicability_problems') or []):
if problem not in _na_problems:
_na_problems.append(problem)
_pp = (r.get('profile') or {}).get('problem')
if _pp and ('profile', _pp) not in _na_problems:
_na_problems.append(('profile', _pp))
for _written, _why in _mute_problems:
_entry = ('mute {0!r}'.format(_written), _why)
if _entry not in _na_problems:
_na_problems.append(_entry)
_na_blocks = [(fold.MD, line) for line in _profile_lines]
if _na_notes:
_na_blocks.append((fold.MD, '**Rows not asked, and who said so:**'))
_na_blocks.extend([(fold.MD, line)
for line in TP.applicability_lines(_na_notes)])
if _na_problems:
_na_blocks.append((fold.MD, '**Declarations that were REFUSED, so their '
'rows RAN:**'))
_na_blocks.extend([(fold.MD, '**{0}** &mdash; {1}'.format(num, text))
for (num, text) in _na_problems])
_na_blocks.append(
(fold.MD, '_A row shown as NOT APPLICABLE was **not run at all**. That '
'is not muting (a muted check runs and is repainted grey), not '
'scope (which sets aside a whole piece), and not dormancy (a '
'check whose enabling knowledge is unset). It is a knowledge '
'layer declaring that this question is not about this kind of '
'piece. A row that FAILED is never switched off -- the '
'declaration is declined and says so. A row scoped to a panel '
'type, on a piece nothing could classify, is NOT RUN in red, '
'never quiet: nobody could ask it._'))
_section('applicability', 'Which checklist ran on each piece, and what it '
'did not ask',
'{0} row(s) not asked; {1} declaration(s) refused'
.format(len(_na_notes), len(_na_problems)),
_na_blocks,
problem=TP.applicability_problem(_na_notes, _na_problems))
# -- muted on this job. Folded when the muting is tidy; open when it is not.
_all_muted = []
_seen_muted = set()
for r in results:
for note in r['muted_notes']:
if note[0] not in _seen_muted:
_seen_muted.add(note[0])
_all_muted.append(note)
if _all_muted:
_section('muted', 'Checklist items muted on this job',
'{0} item(s): {1}'.format(
len(_all_muted), ', '.join([n[0] for n in _all_muted])),
[(fold.MD, line) for line in TP.muting_lines(_all_muted)] +
[(fold.MD, '_Muting does not remove a row -- it stays on the '
'table, repainted grey as MUTED, with this note '
'saying what the check really reported. The check '
'still runs every time; only the presentation '
'changes. It never removes a finding: everything '
'below still reaches the machine-readable JSON '
'(`qc-run.json`, written every press) and still '
'counts in the header line, and a muted item that '
'FAILED keeps its own red on the table -- muting '
'never softens a failure._')],
problem=TP.muting_problem(_all_muted))
# -- the issue tally. Routine; folded.
if tally:
_section('tally', 'Issues by code',
'{0} code(s) across {1} ticket(s)'
.format(len(tally), sum(tally.values())),
[(fold.TABLE, ['issue', 'tickets'],
[[c, str(n)] for c, n in
sorted(tally.items(), key=lambda kv: -kv[1])])])
# -- what each design is being held to. Configuration: folded, by the rule.
seen, intent_rows = [], []
for r in results:
intent = r['intent']
if intent['name'] in seen:
continue
seen.append(intent['name'])
env = intent.get('envelope') or {}
def bound(key, _env=env):
return _env[key][1] if key in _env else '-'
intent_rows.append([
intent['name'], r['spec_source'],
Q._fmt_num(intent.get('fc')), Q._fmt_num(intent.get('fci')),
Q._fmt_num(intent.get('pcf')),
Q.strand_signature(intent.get('strands') or []),
intent.get('wwf_mesh') or '-',
'{0} .. {1}'.format(bound('MIN_LENGTH'), bound('MAX_LENGTH')),
'{0} .. {1}'.format(bound('MIN_WIDTH'), bound('MAX_WIDTH')),
])
_section('intent', 'Design intent being enforced',
'{0} design(s), from {1}'.format(
len(intent_rows),
', '.join(sorted(set([r[1] for r in intent_rows]))) or '-'),
[(fold.TABLE,
['design', 'from', "f'c", "f'ci", 'pcf', 'strands', 'W.W.F.',
'length', 'width'], intent_rows)])
# -- scope and knowledge. The context every report carries; folded unless
# the scope actually removed or annotated something.
if scope_annotation is None:
scope_annotation = the_scope.annotation(
[str(a.AssemblyTypeName) for a in assemblies
if not the_scope.includes(a.AssemblyTypeName)], unit='piece')
_scope_blocks = [(fold.MD, '> ' + (scope_line or
the_scope.line([], unit='piece')))]
if scope_annotation:
_scope_blocks.append((fold.MD, '> ' + scope_annotation))
for _note in the_scope.declaration_lines():
_scope_blocks.append((fold.MD, '_{0}_'.format(_note)))
if doc.PathName:
_scope_blocks.append((fold.MD, 'Knowledge file: `{0}`'
.format(designspec.knowledge_path(doc))))
_section('scope', 'Scope and knowledge',
'what this run was and was not asked to judge',
_scope_blocks,
problem=(scope_annotation or None))
# -- lifting/handling provenance. Only when HANDLE-* actually fired.
if any(f.code.startswith('HANDLE-') for r in results for f in r['findings']):
_section('handling', 'How the lifting/handling result was obtained',
'`HANDLE-*` rows on this run',
[(fold.MD,
'A current **Panel Lift Check** or sweep result is reused when '
'one is on file; otherwise a default-stage check runs on the '
'spot, rate limited so this does not hang on a large design. '
'`HANDLE-STALE` means a saved result no longer matches the '
'piece and was not used. A result not tagged "reviewed Panel '
'Lift Check" is good enough to find a problem, not good enough '
'to sign -- see docs/lift-check.md.')])
# ------------------------------- read by a model, quarantined from the above
# Its own section, its own columns, and a column that names what read it. A
# computed verdict and a model-read one fail differently -- a wrong computed
# answer is a bug, a wrong read is a hallucination -- so a reader has to be
# able to tell them apart at a glance. They are never in the same table, and
# this section opens itself: a model read is never a verdict, so somebody has
# to look at it.
_read = [(r['ticket']['mark'], f)
for r in results for f in (r.get('model_read') or [])]
if _read:
_head, _read_rows = TV.report_block(_read)
_section('modelread', 'Read by a model (never a verdict)',
'{0} row(s) a person must judge'.format(len(_read_rows)),
[(fold.MD, _head),
(fold.TABLE, list(TV.REPORT_COLUMNS), _read_rows)],
problem='a model was asked to look at {0} thing(s); nothing '
'here is a pass or a fail'.format(len(_read_rows)))
elif _vision_on:
output.print_md('> The vision escalation was on and produced nothing, '
'which should not happen - every escalation reports a '
'reason even when it does not run.')
# ---- THE MACHINE-READABLE COPY. Written on EVERY press, escalation or not.
# The same separation as the page, on disk, for anything that reads the run
# programmatically: two keys, never one list, and `computed` on every entry
# so even a consumer that merges them cannot lose the difference. When no
# model was consulted `model_read` is an empty list -- which is itself the
# answer to "was one?", and is why this no longer waits for a vision row:
# the muted section promises every run's findings reach this file, and
# vision defaults off.
#
# AND THE DISPOSITION, added 2026-08-31. Until then this file held findings
# and NOTHING ELSE -- no checklist rows, no mutes, no `run_all` log -- so a
# consumer reading it could not tell a row that PASSED from a row NOBODY
# ASKED. Both were an absence of findings. That is this project's cardinal
# failure reproduced inside its own output, and `tickets` below is the fix:
# every checklist row of every piece, with its state and, where it produced
# no verdict, its reason verbatim. See the block above `qc_json`.
#
# NOTHING HERE CHANGES WHAT A HUMAN SEES. The page is already printed by this
# point and `last-ticketqc.txt` below is written from the same `r['rows']`
# this reads; `qc_json` builds a new document and never touches a row, a
# finding or a note.
#
# NOTE ON MUTING: this is built from `r['findings']`, which muting never
# touches. A muted checklist row is a rendering decision; the finding
# behind it still reaches this file -- and now the ROW does too, in grey,
# saying what the check really reported.
try:
_doc = TV.qc_json([f for r in results for f in r['findings']],
[f for r in results
for f in (r.get('model_read') or [])],
tickets=results,
envelope=Q.run_envelope(TV.RUN_SCHEMA, doc=doc))
_path = TV.write_run_json(designspec.root_dir(doc), _doc)
output.print_md('_Machine-readable: `{0}`_'.format(_path))
except Exception as _ex:
output.print_md('_(the JSON copy could not be written: {0})_'
.format(type(_ex).__name__))
# ---- THE EVIDENCE FILE. Migration step 1, docs/architecture-migration.md.
#
# Everything this press MEASURED, as a versioned JSON document with no live
# Revit object in it, beside the model at `_ticketqc/evidence/run-<stamp>.json`.
# One file per press, never one per piece.
#
# NOTHING READS IT. Not this button, not `ticketchecks`, not a runner -- the
# reader is step 2. That is why this can ship on its own: a file nobody reads
# cannot change what the press above reported. No judgement moved, `read_ticket`
# is untouched, and `Q.ticket_to_json` builds a new dict rather than editing
# the ticket the checks were run against.
#
# It carries no findings, deliberately. `qc-run.json` above owns the verdicts;
# this file owns the inputs, and keeping the two apart is what will let step 2
# re-judge one against the other.
#
# Wrapped exactly as the JSON copy above is: a failure here prints a line and
# loses nothing. The report is already computed by this point.
try:
_ev_doc = Q.run_evidence(results, doc=doc)
_ev_path = Q.write_evidence(
designspec.sub_dir(doc, Q.EVIDENCE_FOLDER), _ev_doc)
output.print_md('_Evidence (nothing reads it yet): `{0}`_'
.format(_ev_path))
except Exception as _ex:
output.print_md('_(the evidence file could not be written: {0})_'
.format(type(_ex).__name__))
# ---- FOLDED IS NOT GONE. The whole page, nothing folded, every checklist
# table in full text with long notes wrapped rather than cut, written to a
# file every press. A fold that will not open then costs a file open, never
# a lost sentence.
full_path = None
try:
full_path = os.path.join(designspec.root_dir(doc), 'last-ticketqc.txt')
_lines = ['SHOP TICKET QC -- ' + str(doc.Title), '']
for r in loud + all_green:
_t = r['ticket']
_lines.append('')
_lines.append('{0} [{1}] {2}'.format(
_t['mark'], r['design'],
TP.checklist_headline(r['counts']).replace('&middot;', '-')))
_lines.append('-' * 78)
_lines.append(TP.checklist_table_text(r['rows'], r['details']))
_lines.append('')
_lines.append(fold.text(sections, show_all=True))
payload = compat.as_text('\n'.join(_lines))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
except Exception as _exc:
full_path = None
output.print_md('> The full report could not be written to disk ({0}). '
'Everything is still on this page; use the fold headers.'
.format(_exc))
if sections:
opened = fold.sections_with_problems(sections)
output.print_md('---')
if opened:
output.print_md('**{0} of the {1} section(s) below opened themselves '
'&mdash; they have something in them that needs you.** '
'The rest are folded; click a header to open one.'
.format(len(opened), len(sections)))
else:
output.print_md('**Nothing below the tables needs you.** Every section '
'is folded; click a header to open one.')
if full_path:
output.print_md('_The whole report, nothing folded, is in_ `{0}`'
.format(full_path))
output.print_html(fold.html(sections))
@@ -0,0 +1,19 @@
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
# `title:` here -- `uimaker._produce_ui_panels` calls
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
#
# THE FOUR FINISHED TOOLS, all as large icons, in the owner's own order
# (2026-08-30: "reinforce, LIFT, ticketqc, snapshot 四个是大图标, 放在一起").
# Nothing half-built may join them -- that is what Setup and Design are for.
#
# Every title here keeps each line under ~9 characters. A large ribbon button
# is as wide as its widest line of text, so one long title makes one button
# dwarf its neighbours, which is exactly what the owner objected to when
# these four were spread across three panels.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ReinforceSuggest
- PanelLiftCheck
- TicketQC
- SnapshotClose