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,14 @@
title: Gravity (WIP)
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration. This tool is buttons-and-docs only until the owner
# provides worked examples; the suffix is what stops it being mistaken for a
# finished tool by anyone who opens the DesignHub dropdown. It sits on the
# title rather than the second line now because a pulldown row has no second
# line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
#
# Gravity load chasing -- PLACEHOLDER. Nothing is implemented.
#
# It does not write to the Revit model and never will -- like Lateral
# Extract, this is meant to be an extractor: read the model, write a file
# elsewhere. This one's target is Excel -- it is meant to extract
# information and write an Excel workbook. The owner's framing, verbatim
# (2026-08-30):
#
# gravity-load-chasing 是设计,但是我一两句话说不清楚。这不是一个现在就要推的
# 项目,先做个按钮跟占位符出来。这将是一个不小于 panel_lift 的功能性按钮。
#
# "I cannot say it in a sentence or two" is a statement about the problem, so
# this file does not try to say it either. It states what is settled, states
# that the rest is not, and prints the list of open questions -- above all the
# ones only he can answer, because those are what the placeholder is FOR. A
# placeholder that quietly starts making engineering assumptions is worse than
# an empty one; a placeholder that collects the open questions is worth having.
#
# The full document is QNA/gravity-load-chasing.md. This script deliberately
# carries the substance rather than only a pointer: the person who presses the
# button is in Revit, not in a text editor, and "see the file" is how a
# placeholder becomes a dead end.
#
# WHAT THIS FILE MUST NOT GROW INTO
# ---------------------------------
# No arithmetic. No load model, no tributary rule, no accumulation. No
# knowledge key, no schema field, no config default, no data structure, no file
# format. None of that has been chosen by anybody, and structure invented ahead
# of the method is how a placeholder turns into a set of assumptions nobody
# agreed to. If something looks obviously needed, it goes in the QNA document
# as a PROPOSAL and waits.
#
# It also reads nothing. Not geometry, not parameters, not the knowledge
# layers. It opens no Transaction, because there is nothing to write and there
# never will be from this file. The only thing it touches is the model's title,
# so the report says which session printed it.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Gravity (WIP)'
import os
from pyrevit import revit, script
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Gravity Load Chasing'
NOTES = 'QNA/gravity-load-chasing.md'
def notes_path():
"""Absolute path to the design-notes document, if it can be found.
Four levels up from this file is the extension folder; one more is the
repo. Purely a convenience for the reader -- a miss costs a line, never
the report.
"""
try:
here = os.path.dirname(os.path.abspath(__file__))
repo = os.path.abspath(os.path.join(here, '..', '..', '..', '..'))
found = os.path.join(repo, 'QNA', 'gravity-load-chasing.md')
if os.path.isfile(found):
return found
except Exception:
pass
return None
# --------------------------------------------------------------- the report
output.print_md('# {0} — NOT IMPLEMENTED'.format(TITLE))
output.print_md(
'**None of this feature is built.** There is no calculation behind this '
'button, and nothing was read from the model to produce this page. That '
'is deliberate and it is the whole state of the work: a button, and a '
'document holding the questions.')
try:
output.print_md('Model **{0}** — opened, not read.'.format(doc.Title))
except Exception:
pass
output.print_md('---')
output.print_md('## What it will be')
output.print_md(
'Gravity load chasing, as the owner put it on 2026-08-30: *"it is '
'design, but I cannot say it in a sentence or two. It is not a project '
'to push right now — make a button and a placeholder first. It '
'will be a functional button no smaller than Panel Lift Check."*')
output.print_md(
'One thing beyond that quote is already settled: like Lateral Extract, '
'this tool does not write to the Revit model and never will. Both are '
'extractors — read the model, write a file elsewhere. This one\'s '
'target is **Excel**: it is meant to extract information and write an '
'Excel workbook. Nothing here does that yet.')
output.print_md(
'Only four things follow from the quote itself, and nothing here will '
'pretend to more:')
output.print_md(
'1. It is **design**, not checking.\n'
'2. It **cannot yet be specified**, and that is a fact about the '
'problem, not about anybody\'s time.\n'
'3. It is **not being pushed now**.\n'
'4. When it is built it is a **full feature** — its own '
'engineering core, its own report, its own tests — at least the '
'size of Panel Lift Check.')
output.print_md('## Two boundaries that are already settled')
output.print_md(
'**It is design, not checking, and the liability is different.** '
'Everything else on this panel reviews work that already exists — '
'Ticket QC compares a drawn ticket to the model, Panel Lift Check judges '
'rigging somebody placed, Reinforcing Suggestion proposes donors for a '
'human to accept. In each case the original work is still there to look '
'at if the tool is wrong. Gravity load chasing produces the answer '
'itself, and it goes out under an engineer\'s seal. That has to shape '
'the design from the first commit; it cannot be added at the end.')
output.print_md(
'**Its input is not the current model.** It needs old jobs and their '
'spreadsheets as samples and controls — mass-layout among them. '
'Nothing else in this extension works that way. Those files also carry '
'client and job identity, which may never enter the repository, so where '
'the controls live is a real constraint on the project layout and not a '
'detail for later.')
output.print_md(
'**Carried forward from the reinforcing work** (see '
'`QNA/reinforcing-suggestion-brief.md`): the plant standard bands '
'vertical reinforcing `0.2H / PER DESIGN / 0.2H`, and the middle band is '
'explicitly the engineer\'s own calculation for that one piece. The '
'reinforcing matcher is forbidden from proposing it, because presenting '
'one panel\'s design calculation as another panel\'s answer would look '
'exactly like an ordinary suggestion. Gravity load chasing stands on the '
'same ground — its entire output is that class of quantity. So the '
'rule here is the mirror image: **a result from one job must never '
'become an input to another by any automatic path**, and nothing this '
'tool produces may ever be presented with the confidence the checking '
'tools use.')
output.print_md('## What it will need from you before it can be built')
output.print_md(
'This is the useful half of a placeholder. None of these have answers '
'yet, and none will be invented:')
output.print_md(
'- **Old jobs, and the spreadsheets that go with them.** Which ones, and '
'where they can be reached from — they cannot live in the '
'repository.\n'
'- **Mass-layout: what it actually is.** A spreadsheet, a tool, a stage '
'of the job? Who produces it, when, in what format?\n'
'- **Which of those jobs are the controls**, and what counts as passing '
'— reproducing your number to within what tolerance, on which '
'quantities?\n'
'- **Whether the spreadsheets are machine-readable** — fixed '
'columns and stable sheet names, or hand-formatted per job. This is a '
'large fork: a parser, or an import UI.\n'
'- **What is being chased, over what unit, producing what.** One panel, '
'one wall line, a whole building; a number per piece, a table, a '
'drawing.\n'
'- **Which method governs** — which standard and edition, and '
'whether the plant has its own that supersedes it.\n'
'- **Which loads are in scope**, and what arrives as given rather than '
'computed.\n'
'- **Design, or a first pass an engineer edits.** Different products, '
'not different wordings.\n'
'- **Read-only, or not.** Everything in this extension is read-only '
'today. Is this the feature that changes that?')
output.print_md('## The five that block everything else')
output.print_md(
'The rest can be discovered while building. These cannot — each '
'one changes the shape of the whole thing, and getting one wrong is a '
'rewrite rather than a fix:')
output.print_md(
'1. What is being chased, over what unit, producing what.\n'
'2. What mass-layout is, and whether the sample spreadsheets can be read '
'by a program.\n'
'3. Where the controls live, given that they are client work.\n'
'4. Design or first pass; read-only or not.\n'
'5. Which method governs.')
output.print_md('## What was deliberately not built')
output.print_md(
'So it reads as a decision and not an oversight: no load model, no '
'tributary logic, no accumulation rule, no arithmetic. No knowledge key, '
'no schema field, no config default, no data structure, no file format. '
'No library module on either side of the CPython boundary. Anything that '
'looked obviously needed was written into the notes as a **proposal** '
'instead, so that it gets chosen rather than inherited.')
output.print_md('---')
found = notes_path()
if found:
output.print_md(
'The full scope, every open question, and the proposals are in '
'`{0}`.'.format(found))
else:
output.print_md(
'The full scope, every open question, and the proposals are in '
'`{0}` in the repository.'.format(NOTES))
output.print_md(
'Answers are welcome anywhere — in that file, or in '
'`QNA/questions.md` the usual way. Until they arrive this button stays '
'exactly as honest as it is now.')
@@ -0,0 +1,10 @@
title: Lateral (WIP)
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Half-built, dropdown only.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,312 @@
# -*- coding: utf-8 -*-
#
# Extract the lateral-resisting members and write an analysis input file.
#
# WHICH MEMBERS? The user says. Not this tool. A member is lateral-resisting
# when somebody has typed the marker into its identity description -- by
# default 'lateral-resistent' (the owner's spelling) or 'lateral-resistant'.
# Nothing here infers it from geometry, category or name.
#
# READ-ONLY. No Transaction is opened, nothing is written to the model, and
# the output files go beside the model, never into the repository.
#
# WHAT IT WRITES: a JSON intermediate model, two CSVs (nodes, members), and a
# RISA-3D draft that is deliberately named `.r3d.DRAFT.txt` so it cannot be
# loaded by accident. See vibe.latrisa for what is established about the RISA
# format and what is not.
#
# The selection idiom follows Ticket QC and Panel Lift Check: what is
# preselected is what is checked; nothing preselected scans the configured
# categories across the model. The difference from both is that this tool
# never asks the user to pick pieces from a list -- the marker IS the pick,
# and offering a second way to choose would let the two disagree.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Lateral (WIP)'
import io
import os
from pyrevit import revit, script, forms
from vibe import designspec, knowledge, latextract, latmodel, latrisa
from vibe import scope as scopelib
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = 'Lateral Extract'
def die(message):
"""The user has not given us something yet. Not a refusal to extract."""
forms.alert(message, title=TITLE, exitscript=True)
def not_run(message):
"""'Could not judge' -- a RESULT, and it must survive on the report.
The distinction this whole tool turns on: NOT RUN is not the same as
'ran and found nothing marked'. NOT RUN means the question could not be
asked at all, so the absence of marked members says nothing. A run that
asked and found nothing gets the ordinary report, with a nothing-found
banner and no files written.
"""
output.print_md('# Lateral Extract — NOT RUN')
output.print_md('**NOT RUN** — the extraction did not run.')
output.print_md('> {0}'.format(message))
script.exit()
# ------------------------------------------------------------------ context
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
forms.alert(problem, title=TITLE, exitscript=True)
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
scope = ('the {0} element(s) you selected'.format(len(selection))
if selection else 'the whole model, in the configured categories')
# --------------------------------------------------------------------- run
with forms.ProgressBar(title='Reading identity descriptions...') as pb:
pb.update_progress(0, 2)
model, found, why = latextract.gather(doc, selection=selection or None,
know=know)
pb.update_progress(2, 2)
cats = knowledge.get(know, 'lateral.categories', None) \
or latextract.DEFAULT_CATEGORIES
if why == latextract.NOT_RUN_UNMAPPED:
not_run(
'The `parameters.{0}` role is not mapped, so this does not know '
'which parameter holds the identity description the marker is '
'typed into.\n\n'
'It will not guess a name. A guessed name would work on the '
'template it was guessed from and silently find nothing on every '
'other one, and "found nothing" is indistinguishable from "nothing '
'is marked" once it is on the screen.\n\n'
'Map it in the plant knowledge file and run again. Self Check lists '
'which layers are in play.'.format(latextract.DESC_ROLE))
if why == latextract.NOT_RUN_ABSENT:
# A literal role path, not a variable, so knowledge_drift can see it.
names = knowledge.param_names(know, 'member.identity_description')
not_run(
'The `parameters.{0}` role is mapped to **{1}**, but not one '
'candidate element actually carries that parameter.\n\n'
'That is not the same as "nothing is marked". The field is not '
'there, so nothing could have been marked, and this tool has no '
'opinion of its own about which members resist lateral load.\n\n'
'Scope searched: {2}. Categories: `{3}`.\n\n'
'Either add the parameter to these families, or point the role at '
'whatever field this office really uses.'
.format(latextract.DESC_ROLE, ', '.join(names), scope,
', '.join(cats)))
# ------------------------------------------------------------------ report
output.print_md('# Lateral extraction')
output.print_md(
'Model **{0}** · scope: {1} · {2} candidate element(s) read'
.format(doc.Title, scope, found['candidates']))
output.print_md(
'Marker tokens accepted: {0}. Read from: {1}.'
.format(', '.join(['`' + t + '`' for t in found['tokens']]),
', '.join(['`' + p + '`' for p in found['params_tried']])))
# ---- the declared scope, and why it is NOT applied here ------------------
#
# Every other button on this panel narrows what it judges to the job's
# declared scope (`vibe.scope`). This one deliberately does not, and says so
# rather than leaving the reader to assume either way.
#
# The reason is that the marker IS the pick. A member is lateral-resisting
# because somebody typed it into the identity description, one element at a
# time -- that is a person choosing, not the tool choosing, and this whole
# tool's rule is that a filter never overrides what the user picked. Beyond
# that, a lateral system is columns, beams and shear walls by nature; scoping
# it to one discipline's prefix would delete most of the frame and produce an
# analysis model that looks complete and is not. The line below is printed
# every run so the difference is visible on the page.
_declared = scopelib.of(know)
output.print_md(
'> NOTHING WAS SET ASIDE FOR SCOPE. The declared scope is {0}, and it is '
'deliberately NOT applied here: a member is in this extraction because '
'somebody marked it, and a lateral system crosses every discipline on '
'the job. Every marked member is exported whatever its mark, and the '
'members another engineer owns are as much a part of the frame as '
'yours.'.format(_declared.label()))
n_marked = len(found['marked'])
n_amb = len(found['ambiguous'])
if not n_marked and not n_amb:
# Asked, and the answer was nothing. Distinct from NOT RUN above, and
# said in words rather than left as an empty table -- and critically,
# NO FILES ARE WRITTEN. An empty RISA file on disk looks like an answer.
output.print_md('## Nothing is marked')
output.print_md(
'> {0} element(s) were read and **not one** carries the marker. '
'The identity-description field exists, so the question was asked '
'properly and the answer is that no member has been marked as '
'lateral-resisting yet.\n\n'
'> **No files have been written.** An empty analysis file on disk '
'would look like a result.\n\n'
'> To mark a member, type `{1}` into its identity description '
'(`{2}`) and run this again.'
.format(found['candidates'], found['tokens'][0],
found['params_tried'][0]))
script.exit()
if n_amb:
output.print_md('## Ambiguous marks — not counted either way')
output.print_md(
'The marker appears in these fields but is preceded by a negating '
'word, so it may mean the opposite. They are **excluded** from the '
'export and named here for you to settle.')
output.print_table(
[[str(r['element_id']),
r['mark'] or '', r['category'] or '', r['desc'] or '']
for r in found['ambiguous']],
columns=['element id', 'mark', 'category', 'identity description'])
# ---- what was selected, element by element -------------------------------
output.print_md('## Marked as lateral-resisting ({0})'.format(n_marked))
output.print_table(
[[str(r['element_id']), r['mark'] or '', r['category'] or '',
r['desc_param'] or '', r['token'] or '', r['desc'] or '']
for r in found['marked']],
columns=['element id', 'mark', 'category', 'read from', 'matched',
'identity description'])
# ---- what was skipped, element by element, with the reason ---------------
output.print_md('## Skipped ({0})'.format(len(model['skipped'])))
if model['skipped']:
output.print_table(
[[str(s.get('element_id')), s.get('mark') or '',
s.get('category') or '', s.get('reason') or '']
for s in model['skipped']],
columns=['element id', 'mark', 'category', 'why it is not a member'])
else:
output.print_md('> Nothing was skipped.')
# ---- what became members -------------------------------------------------
output.print_md('## Members written ({0})'.format(len(model['members'])))
if model['members']:
output.print_table(
[[m['label'], m['i_node'], m['j_node'],
'{0:.3f}'.format(m['length_ft']),
(m.get('section') or {}).get('name') or '(none)',
m.get('material') or '(none)',
m.get('axis_source') or '',
'; '.join(m['unresolved'])]
for m in model['members']],
columns=['member', 'i', 'j', 'length ft', 'section label',
'material', 'axis from', 'NOT determined'])
else:
output.print_md(
'> **No member survived extraction.** Every marked element is in the '
'skipped table above with its reason. Nothing analysable came out of '
'this run.')
# ---- what could not be determined ----------------------------------------
rows = latmodel.unresolved_rows(model)
output.print_md('## Not determined ({0} item(s))'.format(len(rows)))
output.print_md(
'Every one of these is **left empty** in the output. None of them is '
'given a default value. A member with no section is not quietly assigned '
'one; the draft file writes `{0}` for it, which no analysis program can '
'resolve, so it fails to analyse rather than analysing wrongly.'
.format(latrisa.UNRESOLVED_SHAPE))
if rows:
output.print_table(
[[r['label'], str(r['element_id']), r['mark'] or '', r['missing']]
for r in rows],
columns=['member', 'element id', 'mark', 'what is missing'])
# ---- connectivity --------------------------------------------------------
conn = model['connectivity']
output.print_md('## Connectivity')
output.print_md(
'{0} node(s), {1} member(s), welded at {2} ft. **{3}** node(s) have only '
'one member reaching them. This tool does not extract supports, so it '
'cannot tell a support from a disconnected member -- both look like this.'
.format(conn['node_count'], conn['member_count'], model['node_tol_ft'],
len(conn['free_ends'])))
if not conn.get('near_miss_checked'):
output.print_md(
'> The near-miss search was **not run** ({0} nodes is too many for an '
'O(n^2) scan on Revit\'s UI thread). That is not a finding of "no '
'near misses" -- nobody looked.'.format(conn['node_count']))
elif conn['near_misses']:
output.print_md(
'**{0} pair(s) of nodes sit within 6 inches of each other but further '
'apart than the weld tolerance.** These are almost certainly meant to '
'be the same point. They are **not** merged here.'
.format(conn['near_miss_total']))
output.print_table(
[[r['a'], r['b'], '{0:.4f}'.format(r['gap_ft'])]
for r in conn['near_misses'][:40]],
columns=['node', 'node', 'gap ft'])
output.print_md(
'> A member ending part-way along another member is **not** connected to '
'it and no node is inserted to make it so.')
# ---- the standing caveats ------------------------------------------------
output.print_md('## What is deliberately not in the output')
for note in model['notes']:
output.print_md('- ' + note)
# ------------------------------------------------------------ where it goes
#
# Beside the MODEL, never in the repository.
start_dir = knowledge.get(know, 'lateral.output_dir', None) or \
designspec.sub_dir(doc, 'lateral')
try:
if start_dir and not os.path.isdir(start_dir):
os.makedirs(start_dir)
except Exception:
start_dir = ''
if not doc.PathName:
output.print_md(
'## Not written\n> This model has never been saved, so there is no '
'folder beside it to write to. Save the model and run again.')
script.exit()
stem = os.path.splitext(os.path.basename(doc.PathName))[0]
written = []
for name, text in latrisa.file_set(model, stem):
path = os.path.join(start_dir, name)
try:
f = io.open(path, 'w', encoding='utf-8')
try:
f.write(text if isinstance(text, type(u'')) else text.decode('utf-8'))
finally:
f.close()
written.append(path)
except Exception as ex:
output.print_md('- **could not write** `{0}`: {1}'.format(name, ex))
output.print_md('## Files written')
for path in written:
output.print_md('- `{0}`'.format(path))
output.print_md(
'> The `.json` and the two `.csv` files are complete statements of what '
'was extracted. The `.r3d.DRAFT.txt` is **not** a verified RISA input '
'file -- its section keywords follow RISA\'s published Appendix D but '
'the field order inside each record is unverified, so it is named so '
'that it cannot be opened into RISA by accident. Read its header before '
'renaming it.')
if not latmodel.is_analysable(model):
output.print_md(
'> **This model would not analyse correctly as written**, and that '
'is on purpose: it has unresolved sections, free ends, no supports '
'and no releases. Fix those in the analysis package, not here.')
@@ -0,0 +1,17 @@
title: Panel Analysis (WIP)
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration, same as its siblings in this pulldown. This button
# pops an Eriksson-Wall-shaped INPUT CONFIRMATION form -- one collapsed section
# per selected panel, pre-filled from the model where readable and blank
# otherwise, gated so a panel cannot run until its required fields (incl. >=2
# supports) are filled -- then feeds the confirmed inputs to the Stage-0
# self-weight solve. The in-place / wind / service engine behind the form is a
# later stage. See script.py's own comments for exactly what it does and does
# not do.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,17 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: DesignHub
# All three rows are half-built and all three say so in their own titles.
# Keep the (WIP) suffixes until the tool behind one actually runs: the arrow
# hides them from the ribbon, but a row that opens is still a row somebody
# clicks. PanelAnalysis differs from its two siblings in one way worth
# naming: it DOES run, end to end, and opens a real page -- what is
# unfinished is the analysis pipeline behind the page it opens, not the
# button itself. See that button's own script.py.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- GravityLoadChase
- LateralExtract
- PanelAnalysis
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,11 @@
# The panel's name on the ribbon is the FOLDER name ("Design").
#
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
# standing instruction is that unfinished tools must never sit on the same
# panel as the polished ones; gravity-load chasing and lateral extraction
# are buttons-and-docs only until he provides worked examples, so they live
# one click down, behind DesignHub, on a panel of their own.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- DesignHub
@@ -0,0 +1,8 @@
title: Design Extract
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,583 @@
# -*- coding: utf-8 -*-
#
# Turn a design summary into a reviewable spec file.
#
# THE PASS, mechanical, in-process: scrapes the summary drafting view and its
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
# no subprocess, nothing that leaves this process. What the parser could not
# place lands verbatim in the spec's 'unresolved' list rather than being
# dropped, which is what makes a narrow rule set honest instead of lossy.
# Read-only as far as Revit is concerned (ExportImage writes no model data).
#
# Every value this button writes is machine-read and UNVERIFIED. Check it
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
# is trusted to have used the right numbers.
#
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
# -----------------------------------------------------------
# The specs written here are what Ticket QC's design-intent comparison
# enforces, so a design extracted here enters the scoped path. Until
# 2026-08-31 this button offered EVERY design in the model with no mention of
# the scope at all -- a side door: an out-of-scope design could be extracted,
# reach Ticket QC, and be compared against, without ever being labelled.
#
# It is not a door that should be shut. The engineer may have a reason to
# extract somebody else's design -- to read it, to compare against it, to
# check a shared edge -- and a picker that hid designs would be exactly the
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
# nothing is blocked. Instead:
#
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
# choosing, before anything is written;
# * the report carries the scope line every other report carries, and,
# when one was actually extracted, the annotation saying the values in
# it rest on knowledge nobody validated for that kind of piece;
# * the fold opens ITSELF when that happened, by the standing display rule.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Design Extract'
import io
import os
from collections import OrderedDict
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
ViewSheet)
from pyrevit import revit, script, forms
from vibe import designspec, ticketqc, knowledge, specsave
from vibe import foldreport as fold
from vibe import scope as scopelib
doc = revit.doc
output = script.get_output()
output.close_others()
if not doc.PathName:
forms.alert('Save the model first - the spec files live beside it.',
exitscript=True)
# --------------------------------------------------------------- pick work
know = knowledge.load(doc)
grouped = ticketqc.assemblies_by_design(doc, know=know)
existing = designspec.list_specs(doc)
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
# The declared scope, off the job knowledge, read the same way Job Recon,
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
# note at the top of this file.
the_scope = scopelib.of(know)
def out_of_scope(design):
"""True when this design number is declared to be somebody else's.
Through `as_mark`, so a design key that does not read as a mark at all
is 'could not tell' and is never labelled as another engineer's.
"""
return not the_scope.includes(scopelib.as_mark(design))
def sheets_by_folder():
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
organization, so this picker is grouped the way the browser tree is.
A flat list of every drafting view is unusable on a real job -- there are
hundreds, and their names are not the design numbers anyway.
"""
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
out = {}
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if sheet.IsTemplate:
continue
number = (sheet.SheetNumber or '').strip()
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
continue
folder = ''
if org is not None:
try:
folder = ' / '.join(
[item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
folder = ''
out.setdefault(folder or '(no folder)', []).append(sheet)
return out
def packets(sheets):
"""{design key: [ViewSheet, ...]} in page order.
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
pages of design PNL1, and extracting any of them means extracting the
packet.
"""
out = {}
for sheet in sheets:
key = designspec.design_key(sheet.SheetNumber, know)
if key:
out.setdefault(key, []).append(sheet)
for key in out:
out[key].sort(key=lambda s: (s.SheetNumber or ''))
return out
DESIGNS = {} # row label -> design key, so nothing has to be split
def row(design, sheets=None):
bits = []
if sheets:
cover = sheets[0]
bits.append(cover.Name)
if len(sheets) > 1:
bits.append('{0} sheets ({1})'.format(
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
else:
bits.append('sheet {0}'.format(cover.SheetNumber))
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
else 'NO pieces')
if design in existing:
bits.append('spec exists - will be overwritten')
# At the point of choosing, and in capitals: this is the one moment the
# engineer can decide he did not mean to extract somebody else's design.
# It never removes the row.
if out_of_scope(design):
bits.append('OUTSIDE THE DECLARED SCOPE')
label = '{0} - {1}'.format(design, ', '.join(bits))
DESIGNS[label] = design
return label
groups = OrderedDict()
by_folder = sheets_by_folder()
for folder in sorted(by_folder):
found = packets(by_folder[folder])
if found:
groups['Sheets / ' + folder] = [row(key, found[key])
for key in sorted(found)]
# Designs the pieces claim that no sheet in the design folder accounts for.
# These would silently go unchecked, so they get their own group rather than
# being left off the list.
accounted = set()
for folder in by_folder:
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
accounted.update(packets(by_folder[folder]).keys())
orphans = [d for d in sorted(grouped) if d not in accounted]
if orphans:
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
if existing:
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
if leftover:
groups['Specs already on disk'] = leftover
if not groups:
forms.alert('No design numbers found in this model.', exitscript=True)
kwargs = {
'title': 'Extract which design summaries?',
'group_selector_title': 'Browser folder',
'multiselect': True,
'button_name': 'Extract',
'width': 900,
'height': 650,
}
preferred = 'Sheets / ' + DESIGN_FOLDER
if DESIGN_FOLDER and preferred in groups:
kwargs['default_group'] = preferred
picked = forms.SelectFromList.show(groups, **kwargs)
if not picked:
script.exit()
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
# What was OFFERED and left unpicked is not the scope's business -- the
# engineer chose. What matters is what he DID pick that the declaration says
# is somebody else's, because those specs are about to feed Ticket QC.
picked_out = [d for d in designs if out_of_scope(d)]
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
scope_annotation = the_scope.annotation(picked_out, unit='design')
# ------------------------------------------------------------- gather + go
output.print_md('# Extract design summaries')
output.print_md('Model **{0}**'.format(doc.Title))
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
output.print_md(
'**Every value below is machine-read and unverified.** A regex either '
'matched a rule or it did not -- there is no judgement in between. What '
"matched is filled in; every line of the summary that didn't is listed "
'verbatim in the spec\'s `unresolved` section instead of being silently '
'dropped -- and, from this press on, on this page as well. Read those, '
'then check the spec in `{0}` against the drawing before Ticket QC or '
'Panel Lift Check are trusted to have used the right numbers.'
.format(designspec.sub_dir(doc, 'specs')))
# ---- the declared scope. Not folded, and above the results rather than
# below them, because a spec is written to disk by the time this page exists
# -- the reader is being told what he already has, and Ticket QC will pick it
# up whether or not he scrolls.
output.print_md('> ' + scope_line)
if scope_annotation:
output.print_md('> **' + scope_annotation + '**')
for _note in the_scope.declaration_lines():
output.print_md('_{0}_'.format(_note))
def _per_plant_layer_exists():
"""True when this machine already has at least one per-plant folder.
Answers a different question from `knowledge.plant_id(doc)`: that asks
which plant THIS job names; this asks whether the per-plant migration
has already happened on THIS MACHINE at all, which is what decides
whether the legacy machine-wide path is still a safe place to drop a
starter.
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
-- writing the machine-wide starter there is exactly what this button has
always done, and it is still right: a fresh install needs something to
edit. But once a machine has even one real per-plant folder, the
machine-wide file at `<root>/knowledge.yaml` is what
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
(`plantpath.py`), so writing a blank template there would give a job
that simply forgot to set `job.plant` a silent, empty plant layer
instead of a loud "no plant declared." That is the exact trap this fix
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
migrated machine the safe move is to write nothing and say so.
"""
root = designspec.plant_root()
try:
names = os.listdir(root)
except Exception:
return False
for name in names:
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
return True
return False
# `write_plant_starter()` with no plant argument always targets the
# machine-wide `<root>/knowledge.yaml` -- the exact file
# `plant_knowledge_path(None)` returns as the fallback for any job that
# omits `job.plant`. Calling it unconditionally, as this button used to,
# recreated a blank template there on every press, which any no-plant job
# would then silently read as its entire plant layer. `knowledge.plant_id`
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
# before the ordinary merge runs (see its docstring), so it is read the
# same way here rather than re-derived.
plant = knowledge.plant_id(doc)
if plant:
# A named plant gets its own folder. `write_plant_starter` already
# refuses to overwrite a file that exists (knowledge.py:6601 --
# `if os.path.isfile(path) and not overwrite: return path, False`), so
# calling it on every press is safe: it only ever fills a gap, never
# clobbers a real plant layer.
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
output.print_md('Plant standards: `{0}`{1}'.format(
ppath, ' (just created - edit it)' if pcreated else ''))
elif _per_plant_layer_exists():
# No plant named, but this machine has already been migrated to
# per-plant folders -- writing at the legacy path would recreate the
# exact decoy this fix removes. Report the gap instead of filling it
# with a blank file that looks like an answer.
output.print_md(
'Plant standards: **no `job.plant` set**, and this machine already '
'has per-plant folders under `{0}` -- nothing written at the '
'legacy machine-wide path, because that path is what a no-plant '
'job reads as its plant layer. Set `job.plant` in this job\'s '
'knowledge file.'.format(designspec.plant_root()))
else:
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
# so the machine-wide file is still the only plant layer this install
# could possibly mean, exactly as it was before per-plant folders
# existed. Safe to seed, and `write_plant_starter` still refuses to
# overwrite if one is somehow already there.
ppath, pcreated = knowledge.write_plant_starter()
output.print_md('Plant standards: `{0}`{1}'.format(
ppath, ' (just created - edit it)' if pcreated else ''))
kpath, created = knowledge.write_starter(doc)
if created:
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
written, skipped = [], []
for design in designs:
try:
evidence = designspec.gather_evidence(doc, design, know=know)
except Exception as ex:
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
continue
if not evidence.get('summary_views'):
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
'sheets carry no views'.format(
DESIGN_FOLDER or 'any folder', design)))
continue
designspec.write_json(designspec.evidence_path(doc, design), evidence)
notes = sum([len(v.get('text_notes') or [])
for v in evidence.get('summary_views') or []])
try:
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
if spec is None:
skipped.append((design, 'no summary view to read'))
continue
path = designspec.save_spec(doc, design, spec)
# The spec itself is kept, not just its counts: what is unresolved is
# listed by name below, and the copy the engineer asks for is written
# from the file this returned.
written.append({'design': design, 'notes': notes, 'path': path,
'spec': spec})
except Exception as ex:
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
continue
# ------------------------------------------------------- where the copy goes
#
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
# 路径。用户也可以打开看到底是什么东西unresolved。"
#
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
# else, so a spec saved only into a folder the engineer picked would look
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
# read.
#
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
# on cancel, which is right there: nothing has been calculated yet, so a
# cancel costs nothing. Here the extraction has already run and the files are
# already on disk, so exiting would throw away work that only another press
# could get back. Cancel therefore means "no second copy" and nothing else --
# the results still print, pointing at the canonical file.
copies = {}
copy_problems = []
in_place = []
cancelled_save = False
if written:
designs_written = [w['design'] for w in written]
# Seeded with wherever the last extraction was saved; the job's data
# folder when there is no such memory. Both are real places on this
# machine -- a dialog opening on a path that does not exist is how a file
# lands somewhere nobody meant. NOT the `specs` folder itself: the
# default name is the name the canonical file already has, so opening
# there would greet a single-design run with "PNL1.yaml already exists,
# replace it?" -- a question about the file it just wrote.
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
or designspec.root_dir(doc))
chosen = forms.save_file(
file_ext='yaml', init_dir=start_dir or '',
default_name=specsave.default_name(designs_written),
title='Save the design extraction as')
if not chosen:
cancelled_save = True
else:
by_design = dict([(w['design'], w['path']) for w in written])
for design, target in specsave.targets(chosen, designs_written):
# Saving onto the file itself. The dialog opens on the folder the
# specs already live in, so this is an ordinary thing to do by
# accident -- and rewriting a file with its own contents is at
# best pointless. Say so instead of claiming a copy that is the
# same path twice.
if specsave.same_file(by_design[design], target):
in_place.append(design)
continue
try:
# Copied as text rather than re-rendered from the spec, so the
# copy is character-for-character the file Ticket QC reads. A
# second rendering could drift from the first and the two
# would look like the same extraction.
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
body = fh.read()
with io.open(target, 'w', encoding='utf-8') as fh:
fh.write(body)
copies[design] = target
except Exception as ex:
copy_problems.append('{0}: {1}'.format(design, ex))
specsave.remember_dir(designspec.root_dir(doc),
os.path.dirname(chosen))
if written:
output.print_md('## Written')
output.print_table(
[[w['design'], str(w['notes']),
str(len(specsave.unresolved_entries(w['spec']))),
os.path.basename(w['path'])] for w in written],
columns=['design', 'text notes scraped', 'unresolved notes',
'spec file'])
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
# is a dead link in this panel -- backslashes and no scheme, so it renders
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
# stays the real path, because that is what gets copied into Explorer,
# and only the href gets the URL form. `specsave.link` is that pattern.
for w in written:
line = '- **{0}** &nbsp; {1}'.format(w['design'],
specsave.link(w['path']))
if w['design'] in copies:
line += ' &nbsp;&mdash;&nbsp; copy: {0}'.format(
specsave.link(copies[w['design']]))
output.print_md(line)
if cancelled_save:
output.print_md(
'> No second copy was saved -- the save dialog was cancelled. '
'Nothing was lost: every spec above is on disk at the path shown, '
'which is where Ticket QC reads it from.')
if in_place:
output.print_md(
'> {0}: you picked the file the spec is already saved as, so '
'nothing further was written. The path above is it.'
.format(', '.join(in_place)))
if len(written) > 1 and copies:
output.print_md(
'> More than one design was extracted, so the FOLDER you chose '
'was used and each spec kept its own design number as its file '
'name -- that name is how everything downstream finds it.')
for problem in copy_problems:
output.print_md('> The copy could not be written &mdash; {0}'
.format(problem))
output.print_md(
'Then check the fields the reader did fill against the drawing. '
'Ticket QC picks specs up automatically, from the paths above.')
# ------------------------------------------- what is unresolved, by name
#
# "用户也可以打开看到底是什么东西unresolved" -- so the notes themselves are
# on the page, verbatim, not a number in a column. A count tells a reader
# there is something to look at and nothing about whether to look now.
#
# Folded by the standing display rule (`vibe.foldreport`, set 2026-08-30):
# everything below the answer starts closed, and a section carrying a
# problem opens ITSELF. Unresolved notes are exactly that case, so a
# design with any opens on its own; a design with none stays a one-line
# header. The contract that buys is: if nothing here is open, nothing on
# this page needs you.
sections = []
for w in written:
entries = specsave.unresolved_entries(w['spec'])
blanks = specsave.blank_fields(w['spec'])
problems = designspec.validate_spec(w['spec'])
section = fold.Section(
key='unresolved-' + w['design'],
# A plain dash. `foldreport._section_html` now restores the
# reports' entities in a title the way it always did for a table
# cell or a markdown block (fixed 2026-08-30, after this note was
# written), so `&mdash;` would render here too -- but a plain dash
# is right in the panel AND in the .md, so it stays.
title='{0} - what the reader could not place'.format(w['design']),
summary=specsave.summary_line(entries, blanks),
problem=specsave.problem_sentence(entries, problems))
reason = specsave.shared_reason(entries)
if entries:
# The mechanical reader gives every unplaced note the same reason.
# Printed once it is an explanation; printed on all fourteen lines
# it is what stops the list being read.
if reason:
section.md('_{0}_'.format(reason))
for i, entry in enumerate(entries):
text, why = entry
line = '**{0}.** {1}'.format(i + 1, text)
if why and not reason:
line += ' &mdash; {0}'.format(why)
section.md(line)
else:
section.md('Every text note on this design summary matched a '
'rule. Nothing was dropped and nothing was left over.')
if blanks:
section.md(
'**Still blank** &mdash; the summary filled none of these, '
'and a check that reads one of them does not run: {0}'
.format(', '.join(['`' + b + '`' for b in blanks])))
if problems:
section.md('**Validation:** ' + '; '.join(problems))
# PLAIN PATH INSIDE THE FOLD, NOT A MARKDOWN LINK. `foldreport.md_html`
# converts bold, code and italic and nothing else -- a markdown link
# written here reaches the panel as the literal text `[path](file:///
# ...)`, which is worse than no link at all. Measured on stubs,
# 2026-08-30. The CLICKABLE link is in the list above, printed through
# pyRevit's own markdown, which does render one.
section.md('The spec: `{0}` (clickable in the list above)'
.format(w['path']))
if w['design'] in copies:
section.md('Your copy: `{0}`'.format(copies[w['design']]))
sections.append(section)
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(sections)
output.print_md('---')
if opened:
output.print_md(
'**{0} of the {1} design(s) below opened themselves** &mdash; '
'they have notes the reader could not place. The rest are '
'folded; click a header to open one.'
.format(len(opened), len(sections)))
else:
output.print_md(
'**Nothing below needs you.** Every note on every summary matched '
'a rule. The sections are folded; click a header to open one.')
output.print_html(fold.html(sections))
if skipped:
output.print_md('## Skipped')
for design, why in skipped:
output.print_md('- **{0}** - {1}'.format(design, why))
# ---------------------------------------------- review specs already on disk
if existing:
output.print_md('## Specs already on disk')
rows = []
for design in sorted(existing):
spec = existing[design]
problems = designspec.validate_spec(spec)
row_cells = [design,
spec.get('source', {}).get('extractor') or '?',
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
'ok' if not problems else '; '.join(problems[:2]),
str(len(spec.get('unresolved') or []))]
# A spec on disk feeds Ticket QC whether this press wrote it or an
# earlier one did, so the label belongs on the whole folder, not
# only on what was extracted just now.
if the_scope.is_declared:
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
rows.append(row_cells)
columns = ['design', 'extractor', 'reviewed by', 'validation',
'unresolved notes']
if the_scope.is_declared:
columns.append('scope')
output.print_table(rows, columns=columns)
if the_scope.is_declared:
output.print_md(
'`scope` marks a spec whose design number falls outside {0}. '
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
'these specs if it is pointed at those pieces, and the label is '
'so that it is a decision rather than an accident.'
.format(the_scope.label()))
output.print_md(
'`reviewed by` stays NOT reviewed until a person sets '
'`source.reviewed_by` in the spec file by hand, which is the only '
'thing that ever marks a spec as checked.')
output.print_md(
'`extractor` says which reader wrote the file. This button always '
'writes `regex` (the mechanical reader). Nothing branches on the '
'value; it is shown so a spec written by one reader is not mistaken '
'for a spec written by another.')
@@ -0,0 +1,12 @@
title: Job Recon
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
# the tool changed while it was out: only its ribbon entry was ever parked,
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,174 @@
# -*- coding: utf-8 -*-
#
# Reconcile the issued ticket set against the whole job.
#
# Every other check on this panel judges one ticket at a time, and no amount of
# per-ticket checking can find a ticket that is not there. This button asks the
# question about the SET: every piece printed on something, everything printed
# about a piece that exists, and the counts reconciling mark by mark.
#
# It reads. It never writes, and it opens no transaction.
#
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
# across this tab: the answer is the headline and is never folded; everything
# else follows it, collapsed; a section that carries a problem opens itself.
# The reader's contract is that if nothing below the headline is open, nothing
# needs him.
#
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
# for, and a count per finding -- not the findings themselves. On a job with
# four hundred uncovered pieces the lists are thousands of lines and the
# verdict is invisible inside them. So:
#
# * the headline is the totals and the finding counts, printed through
# pyRevit's own markdown and tables;
# * every non-empty finding is a section below it, OPEN, because there is no
# finding in this reconciliation that is merely interesting;
# * coverage by design, the page-set inventory, the categories swept, the
# checks that found nothing and the timings are folded;
# * and the two sections that say what this run could NOT see -- out of
# scope, and blind spots -- are separate, always, and carry their counts
# on their headers with the fold shut. A blind spot that is a REFUSAL
# (nothing reconciled) opens itself and is repeated in the headline,
# because a refusal reported quietly reads as a clean job.
#
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
# problem in it renders open as plain HTML and needs no script at all; and the
# whole report, nothing folded and nothing capped, is written to
# `last-report.txt` beside the model's other job data on every press, with the
# page saying where. A fold that will not open therefore costs a file open,
# never a lost sentence. All of that lives in `vibe.foldreport`.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Job Recon'
import os
from pyrevit import revit, script
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
from vibe import foldreport as fold
doc = revit.doc
output = script.get_output()
output.close_others()
REQUIRED = ('member.control_number', 'sheet.control_numbers')
# The cap on each list ON THE PAGE. The file written beside the model has no
# cap at all -- a page is read, a file is searched.
PAGE_LIMIT = 60
HOW_TO_MAP = """
This reconciliation needs roles the older checks never used. Add them to the
plant knowledge file (the layer that describes the office TEMPLATE), naming
whatever your title block and your pieces actually call these things:
parameters:
member:
control_number: <the piece's control number>
ticket_created: <optional -- the model's own 'a ticket exists' flag>
ticket_detailed: <optional>
sheet:
total_required: <the quantity the title block states>
sheet_count: <how many pages the ticket says it has>
# either list the control-number slots explicitly ...
control_numbers: [<slot 1>, <slot 2>, ...]
nomenclature:
# ... or, for numbered slots, match them with one expression:
control_slot_re: '^<PREFIX>_\\d+$'
Until then this button refuses rather than reporting a job it cannot see.
"""
def write_full(recon, full_path):
"""The whole report, unfolded, beside the model. Returns True if written.
Written BEFORE anything is printed, so the file exists even if the
printing throws. `as_text` first and encode second: under IronPython 2.7
the report is a byte string and a mark carrying a non-ASCII character
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
"""
payload = compat.as_text(
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
return True
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if problems:
for line in problems:
output.print_md('**' + line + '**')
return
names = knowledge.param_names(know, 'member.control_number')
slots = (knowledge.param_names(know, 'sheet.control_numbers')
or knowledge.get(know, 'nomenclature.control_slot_re', None))
if not names or not slots:
output.print_md('## Job Recon cannot run')
output.print_md('Nothing was reconciled, and that is **not** a clean '
'result -- it is a refusal.')
print(HOW_TO_MAP)
return
recon = jobreconscan.run(doc, know=know)
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
'last-report.txt')
written = False
try:
written = write_full(recon, full_path)
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))
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
full_path=full_path if written else None)
# ---- 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 not tail:
return
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
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(tail)))
else:
output.print_md('**Nothing below needs you.** Every section is folded; '
'click a header to open one.')
if written:
output.print_md('The whole report, nothing folded and nothing capped, '
'is in `{0}`.'.format(full_path))
output.print_html(fold.html(tail))
main()
@@ -0,0 +1,167 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Project Config"
ShowInTaskbar="False"
WindowStartupLocation="CenterScreen"
SizeToContent="Height"
Width="880" MaxHeight="940" MinHeight="380"
ResizeMode="CanResize"
Background="#f6f7f9">
<!--
THE WINDOW IS CHROME ONLY. Every field is built in `script.py` from
`vibe.configform`, because the field list is data: it depends on which
build of `configmodel` this Revit session is holding, on which stages
the job has, and on how many checklist items are muted. A XAML
DataTemplate would have had to know all three.
What lives here is what does not change: the four module boxes, the
reason line every job-layer write needs, and the four buttons. Every
x:Name below is looked up by name in `script.py`, and every Click
handler names a method on the window class. `tests/test_configform.py`
parses this file and that one together and fails if the two have
drifted, which is the only check available for a window that cannot be
opened without Revit.
EVERY MODULE IS AN Expander, NOT A GroupBox, AND STARTS COLLAPSED. The
owner's ruling, 2026-09-01, verbatim in `script.py`'s own comment (kept
out of THIS file because the no-Chinese-in-the-XAML test below scans
every byte of it, comments included, and not only the strings a build
would render): every section folds, and folded is the default; open
the one you came to change, adjust it, done. `IsExpanded="False"` here
is the fallback for the very first time this window opens in a
session; `script.py` overrides it per module from
`vibe.configform.remembered_expanded`, which is the ONLY thing that
may reopen one on its own, since a module the owner left open a minute
ago staying open is a convenience, and a module opening because it
carries a value is not this window's call to make silently, which is
why the header text says so instead (see `configform.is_modified` /
`modified_count`, and `script.py`'s use of them building each
Header).
No double hyphen anywhere in this comment: XML forbids it inside a
comment, and the test above catches it as a parse error rather than as
a window that will not load.
-->
<Window.Resources>
<!-- No FontWeight or Foreground here: a GroupBox passes both down to
its CONTENT, so setting them to style the header would make every
label and every note in the box bold as well. The rows set their
own weight and colour in script.py. -->
<Style TargetType="{x:Type GroupBox}">
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Padding" Value="6"/>
<Setter Property="BorderBrush" Value="#c9d2dd"/>
</Style>
<!-- The four modules are Expanders now, not GroupBoxes; see the
note above this Window's own opening comment. Same border,
same spacing, so folding cost nothing visually except the
fold itself. -->
<Style TargetType="{x:Type Expander}">
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Padding" Value="6"/>
<Setter Property="BorderBrush" Value="#c9d2dd"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Background" Value="White"/>
</Style>
<Style TargetType="{x:Type Button}">
<Setter Property="Padding" Value="10,3,10,3"/>
<Setter Property="MinWidth" Value="76"/>
<Setter Property="Margin" Value="0,0,6,0"/>
</Style>
<Style TargetType="{x:Type TextBox}">
<Setter Property="Padding" Value="3,2,3,2"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
</Style>
</Window.Resources>
<DockPanel Margin="14">
<!-- The sentence that used to be cut off by a 36-pixel Label. It
wraps here, because there is room here. -->
<TextBlock x:Name="header"
DockPanel.Dock="Top"
TextWrapping="Wrap"
FontWeight="Bold"
Foreground="#2c3e50"
Margin="0,0,0,4"
Text="Project Config"/>
<TextBlock x:Name="subheader"
DockPanel.Dock="Top"
TextWrapping="Wrap"
Foreground="#5a6b7d"
Margin="0,0,0,10"
Text=""/>
<Border DockPanel.Dock="Bottom"
Background="#eef1f5"
BorderBrush="#c9d2dd"
BorderThickness="1"
Padding="8"
Margin="0,10,0,0">
<StackPanel>
<TextBlock x:Name="status"
TextWrapping="Wrap"
Foreground="#2c3e50"
Margin="0,0,0,6"
Text="Nothing changed yet."/>
<DockPanel Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left"
VerticalAlignment="Center"
Width="150"
Text="Why (written into the file)"/>
<TextBox x:Name="reason_box" Height="24"/>
</DockPanel>
<StackPanel Orientation="Horizontal">
<Button x:Name="save_button"
Content="Save to the job layer"
MinWidth="150"
Click="save_click"/>
<Button x:Name="cancel_button"
Content="Close"
Click="cancel_click"/>
<Button x:Name="openfile_button"
Content="Open the job file"
MinWidth="130"
Click="openfile_click"/>
<Button x:Name="fullview_button"
Content="Full view"
Click="fullview_click"/>
</StackPanel>
</StackPanel>
</Border>
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel>
<Expander x:Name="group_file_path" Header="File paths"
IsExpanded="False">
<StackPanel x:Name="panel_file_path"/>
</Expander>
<Expander x:Name="group_lifting" Header="Lifting and handling"
IsExpanded="False">
<StackPanel x:Name="panel_lifting"/>
</Expander>
<Expander x:Name="group_ticketqc" Header="Ticket QC"
IsExpanded="False">
<StackPanel x:Name="panel_ticketqc"/>
</Expander>
<Expander x:Name="group_job_knowledge" Header="Job knowledge"
IsExpanded="False">
<StackPanel x:Name="panel_job_knowledge"/>
</Expander>
<Border Background="#fff8e6"
BorderBrush="#e0c98a"
BorderThickness="1"
Padding="8">
<TextBlock x:Name="rest_line"
TextWrapping="Wrap"
Foreground="#6b5a20"
Text="Everything else: edit the config file directly."/>
</Border>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,29 @@
title: Project Config
# 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. Second-hottest button on this ribbon --
# 14 commits to configmodel/configpage/configform/configdump/knowledge 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# "Project Config" rather than plain "Config": the button one level up is
# already called Config, and a Config row inside a Config button reads like
# a mistake even though the two name tables never collide.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,8 @@
title: Self Check
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,322 @@
# -*- coding: utf-8 -*-
#
# Report whether this machine, this Revit and this model can run the QC tools.
#
# Written for the moment the extension lands on a computer it was not developed
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
# so a failure names itself instead of arriving as a traceback from four frames
# deep in a checking routine.
#
# Reads nothing but metadata and does not touch the model.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Self Check'
import os
import sys
from pyrevit import revit, script, HOST_APP
output = script.get_output()
output.close_others()
doc = revit.doc
rows = []
def report(area, item, state, detail):
rows.append([area, item, state, detail])
def probe(area, item, fn, missing='FAIL'):
"""Run one probe. An exception is a finding, never a crash."""
try:
state, detail = fn()
report(area, item, state or missing, detail)
except Exception as ex:
report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex))
# ------------------------------------------------------------------- host
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
def _revit():
app = HOST_APP.app
version = app.VersionNumber
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
if version == DEVELOPED_ON['revit']:
return 'OK', detail
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
DEVELOPED_ON['revit'])
def _pyrevit():
from pyrevit import versionmgr
version = versionmgr.get_pyrevit_version().get_formatted()
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
return state, version
def _engine():
detail = sys.version.split('\n')[0]
major = sys.version_info[0]
if major == 2:
return 'OK', detail
# The code is written for both, but only Python 2 has been exercised.
return 'WARN', detail + ' (only Python 2 has been run in anger)'
def _routes():
from pyrevit.userconfig import user_config
if not user_config.routes_server:
return 'WARN', ('routes server off - out-of-process tools will not '
'connect. The buttons themselves do not need it.')
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
user_config.routes_port)
probe('host', 'Revit', _revit)
probe('host', 'pyRevit', _pyrevit)
probe('host', 'python engine', _engine)
probe('host', 'routes bridge', _routes, missing='WARN')
# ---------------------------------------------------------------- library
def _import_all():
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
ticketqc, ticketchecks)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks'
def _units():
from vibe import ft, mm
round_trip = mm(ft(1000.0))
if abs(round_trip - 1000.0) > 1e-6:
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
return 'OK', '1000 mm round-trips exactly'
def _checklist():
from vibe import ticketpage
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
return 'OK', '{0} row(s) from {1}'.format(
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
probe('library', 'modules import', _import_all)
probe('library', 'unit conversion', _units)
probe('library', 'published checklist', _checklist, missing='WARN')
# ------------------------------------------------------------------- model
def _saved():
if not doc.PathName:
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
return 'OK', doc.PathName
def _data_dir():
from vibe import designspec
return 'OK', designspec.root_dir(doc)
def _knowledge():
"""Every knowledge layer, in the order load() merges them.
Which layers exist decides what the tool actually believes, so the answer
is the list, not a yes/no: naming conventions that should be plant-wide
sitting only in a project file is a real and invisible mistake.
"""
from vibe import knowledge
layers = knowledge.sources(doc)
detail = ' | '.join(
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
for label, path, ok in layers])
# A project config that has gone missing is worse than one never set: the
# run looks entirely normal and uses a configuration nobody chose.
gone = [path for label, path, ok in layers
if label == 'project config' and not ok]
if gone:
return 'FAIL', ('the project config this model runs on is not there: '
'{0} || {1}'.format(gone[0], detail))
if not any(ok for _label, _p, ok in layers):
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
return 'OK', detail
def _knowledge_parses():
"""Read every layer file that IS there, exactly the way load() reads it.
The layer list above says which files exist. It does not say whether any
of them can be READ, and those are different questions with the same
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
mapping raises `Duplicate key` and it returns nothing -- and a layer that
fails to load looks exactly like a layer that agrees with the defaults.
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
layer, every parameter role came back None, the sweep enumerated 0
assemblies, and every report rendered normally.
So this re-reads each file through the same reader and prints ok or the
parser's own message. `knowledge.config_problems()` reports the same
failure from the merge itself; this is the early warning, on the layer
list, before a button is pressed. `python tools/knowledgelint.py` is the
same check on the host, where the edit is made.
"""
from pyrevit.coreutils import yaml as pyyaml
from vibe import knowledge
notes = []
bad = []
for label, path, ok in knowledge.sources(doc):
if not ok:
notes.append('{0}: absent'.format(label))
continue
try:
pyyaml.load_as_dict(path)
notes.append('{0}: ok'.format(label))
except Exception as ex:
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
label, path, type(ex).__name__, ex))
if bad:
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
'was used, and the run fell back to the layers under '
'it. Fix the file, then run '
'tools/knowledgelint.py to confirm.')
if not notes:
return 'WARN', 'no knowledge layer to read yet'
return 'OK', ' | '.join(notes)
def _specs():
from vibe import designspec
specs = designspec.list_specs(doc)
if not specs:
return 'WARN', 'none yet - run Extract Design'
unreviewed = [d for d, s in specs.items()
if not (s.get('source') or {}).get('reviewed_by')]
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
if unreviewed:
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
return 'OK', detail
def _scope():
"""What the job declares this engineer is answerable for.
Never a FAIL. An undeclared scope is a legitimate state -- it means every
button judges every piece -- and it is reported as such rather than as a
misconfiguration, because 'judge everything' is the honest default and
the only wrong answer here would be a scope the tool chose for itself.
"""
from vibe import knowledge
from vibe import scope as scopelib
the_scope = scopelib.of(knowledge.load(doc))
if not the_scope.is_declared:
return 'WARN', ('no scope declared -- every button judges every '
'piece on the job, including work whose plant '
'knowledge has never been validated. Set '
'scope.mark_prefixes in the job knowledge file to '
'be told what is being taken on trust.')
detail = the_scope.label()
if the_scope.declared_by:
detail += ' (declared by {0})'.format(the_scope.declared_by)
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
probe('model', 'saved', _saved)
probe('model', 'declared scope', _scope, missing='WARN')
probe('model', 'data folder', _data_dir)
probe('model', 'knowledge layers', _knowledge, missing='WARN')
probe('model', 'knowledge layers parse', _knowledge_parses)
probe('model', 'design specs', _specs, missing='WARN')
# --------------------------------------------------- project conventions
# These are the assumptions most likely to be wrong on a different job: they
# are naming and parameter conventions, not Revit API.
def _mapping():
"""Which parameter roles this office has told us the names for.
Nothing in the code knows what a panel's length parameter is called, so
an unmapped role is not a detail -- it is a check that will not run. It
reports before the convention probes below, because they depend on it.
"""
from vibe import knowledge, designspec
know = knowledge.load(doc)
missing, not_applicable = knowledge.unmapped_roles(know)
total = len(knowledge.PARAMETER_ROLES)
if missing:
# The template is the plant's, so that is where the names belong; a
# project file may still override one.
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
len(missing), total, ', '.join(missing),
designspec.plant_knowledge_path(),
'; {0} declared not applicable: {1}'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
if not_applicable else '')
mapped = ['{0}={1}'.format(r.split('.')[-1],
knowledge.param_names(know, r)[0])
for r in knowledge.PARAMETER_ROLES
if knowledge.param_names(know, r)]
extra = ''
if not_applicable:
extra = '; {0} declared not applicable at this plant ({1})'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
def _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
return 'FAIL', ('no assembly carries a design number - either the '
'panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so')
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
return 'OK', '{0} designs, {1} pieces: {2}'.format(
len(grouped), sum([len(v) for v in grouped.values()]),
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
def _summaries():
from vibe import ticketqc
summaries, sheets = ticketqc.collect_context(doc)
if not summaries:
return 'FAIL', ('no drafting view is named after a design number - '
'Extract Design has nothing to read')
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
len(sheets))
probe('conventions', 'parameter role mapping', _mapping)
probe('conventions', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
output.print_md('# Self check')
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
fails = len([r for r in rows if r[2] == 'FAIL'])
warns = len([r for r in rows if r[2] == 'WARN'])
if fails:
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
output.print_md('A **FAIL** means a QC button will not work here. Start '
'with the first one - they cascade.')
elif warns:
output.print_md('## Ready, with {0} warning(s)'.format(warns))
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
else:
output.print_md('## Everything checks out')
@@ -0,0 +1,10 @@
title: Takeoff
# 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.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
#
# The takeoff, for the whole job: what it contains, per piece and in total.
#
# The counting lives in `vibe.takeoffcore` (classification and both totals),
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
# (the two renderings), all three Revit-free and unit-tested on host CPython.
# `vibe.takeoffgather` is the only part that touches a document. This file is
# the button and nothing more.
#
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
# reports what a model contains; it has no business changing it.
#
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
# -----------------------------------------------------------
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
# takeoff of the model as it was, and the report says when the index was built
# so a reader who has been editing can see the stamp predates his edits. A
# button that silently spends ten seconds rebuilding is a button that sometimes
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
# and it has the progress bar for it.
#
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
# -----------------------------------------------------
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
# ordering is done once for the job, and a per-piece takeoff is already in
# the by-piece CSV this writes.
# 2. It multiplies by control number, and prints both figures. Per-mark and
# per-job answer different questions and a reader will want to check one
# against the other, so neither is hidden behind the other.
# 3. The multiplier comes from the control numbers on framing elements, via
# the job reconciliation's own gatherer -- not from assembly names, which
# were measured through the bridge and found to be 755 unique names for 755
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
# printed as a refusal at the top of the multiplier section, never as a job
# where every mark happens to be one piece.
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
# that looks like a takeoff of a job with nothing in it is the worst
# possible output, so it says the index has not been built and names the
# button that builds it.
# 5. The reports go beside the model, never into the repository. They name
# marks, part numbers and quantities for one client's job.
#
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
# --------------------------------------------------
# The owner's standing rule for the whole tab, and it lives in
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
# first and never folded; everything else follows FOLDED; a section with a
# problem in it opens itself and says on its header why. The contract that
# buys is "if nothing below the headline is open, nothing needs me", and it
# is worth more than any one section being visible.
#
# Here the headline is the ORDER -- hardware by part number, reinforcing by
# bar mark, both figures on every line, with what multiplied them written
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
# tallies fold. The refusal does not: an unmapped control-number role means
# the model was never asked how many pieces a mark is, every per-job figure
# is an undercount, and that renders OPEN as plain HTML.
#
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
# ---------------------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
# `<details>` does not exist there and fails SILENTLY. So the fold is an
# inline `onclick` plus `style.display`, and it has not been exercised inside
# Revit by the agent that wrote it. Two things make that survivable:
# a section with a problem renders open with no script at all, and the whole
# report, nothing folded, is the `.md` written beside the model every press
# -- which this page names. A dead toggle costs a file open, never a
# sentence.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Takeoff'
from pyrevit import revit, script, forms
from vibe import foldreport as fold
from vibe import knowledge
from vibe import takeoffgather as gather
from vibe import takeoffreport as report
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Takeoff'
def not_judged(headline, message):
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
a dialog that vanishes. It must never read as 'judged and found
nothing'."""
output.print_md('# {0}'.format(TITLE))
output.print_md('## {0}'.format(headline))
output.print_md(message)
script.exit()
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if problems:
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
takeoff, counts, extras = gather.run(doc, know=know)
coverage = takeoff.get('coverage') or {}
if not coverage.get('indexed_marks'):
not_judged(
'The reinforcing index has not been built for this model',
'A takeoff is built from that index and there is nothing in it '
'yet. This is not a job with no hardware in it -- it is a '
'question nobody has asked the model.\n\n'
'Run **Reinforcing Suggestion** once; it builds the index. Then '
'press this again.')
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
# printed -- the file then exists even if the printing throws, and the
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
# every section in full, folded or not, so it can never say less than
# this page does.
paths, write_error = {}, None
try:
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
except Exception as exc:
write_error = 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, and the one line that says whether any of
# it needs him. That line IS the contract; without it a page of shut
# headers looks the same whether or not something is wrong.
tail = sections[1:]
if tail:
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
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(tail)))
else:
output.print_md(
'**Nothing below needs you.** Every section is folded; click '
'a header to open one.')
if paths.get('.md'):
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
.format(paths['.md']))
output.print_html(fold.html(tail))
output.print_md('---')
if write_error is not None:
# The page is still complete, but only while the toggle works -- so
# this says which, rather than the old flat report's promise that
# what is on the page is all of it.
output.print_md(
'**The report could not be written to disk:** {0}. Every section '
'is still on this page, but the folded ones need the click to '
'open; there is no file copy of them this press.'.format(
write_error))
else:
output.print_md('Written beside the model:')
for suffix in sorted(paths):
output.print_md('- `{0}`'.format(paths[suffix]))
output.print_md('---')
output.print_md(
'_Read only. Nothing was written to the model. Index built '
'{0}._'.format(coverage.get('built_at') or 'never'))
main()
@@ -0,0 +1,30 @@
# A `.pulldown` is a large button with a downward arrow: one icon on the
# panel, and every item below opens as a row when it is clicked
# (`components.PullDownButtonGroup`, drawn by
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
# keep their own name table, so the "Project Config" row inside this button
# does not collide with the button itself.
#
# The pulldown's own label. Unlike a stack, a pulldown container does read a
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
# from it, and `_produce_ui_pulldown` passes that straight to
# `create_pulldown_button`.
title: Config
# Row order, top to bottom. Rows are text beside a 16px icon and the
# dropdown is as wide as it needs to be, so these titles are free to say
# what they mean -- unlike the four large buttons on Tools, which are
# rationed to ~9 characters a line.
#
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
# 2026-09-02, at the owner's request, they are parked again: pulled out of
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
# the two lines -- the owner will decide later how to handle them.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ProjectConfig
- ExtractDesign
- SelfCheck
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Setup").
#
# One pulldown, on purpose. These are the commands you run to get a job
# ready or to look at it sideways -- not the four you press all day. They
# were taking two stack slots and a whole panel between them; behind one
# arrow they take none.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- Config
@@ -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 &mdash; NOT RUN')
output.print_md('**NOT RUN** &mdash; 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 &mdash; 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
@@ -0,0 +1,19 @@
# Panel order on the tab, left to right.
#
# Without this key pyRevit falls back to whatever order the filesystem hands
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
# looks at a `title:` here, so do not add one expecting it to render.
#
# Three panels, and the split between them is what the owner asked for on
# 2026-08-30: the four finished tools stand together as large icons, the
# set-up commands collapse behind one arrow, the unbuilt design work
# collapses behind another.
#
# A panel not listed below is not created at all
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
layout:
- Tools
- Setup
- Design