WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m18s

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,12 @@
title: Beam 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 -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside ConnectionAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Beam Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own
# instruction to put a button here for it. Same posture as GravityLoadChase
# and LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not what a "beam" is in this model's terms,
# not which code governs, not read-only or not. None of that is guessed
# here; this button exists so there is somewhere to build it once it is.
# 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__ = 'Beam Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Beam Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Beam Analysis', exitscript=True)
@@ -0,0 +1,12 @@
title: Connection 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 -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside BeamAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Connection Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside BeamAnalysis, on the owner's own instruction to
# put a button here for it. Same posture as GravityLoadChase and
# LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not which connections, not which code
# governs, not read-only or not. None of that is guessed here; this button
# exists so there is somewhere to build it once it is.
# 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__ = 'Connection Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Connection Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Connection Analysis', exitscript=True)
@@ -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,351 @@
# -*- 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
import traceback
from pyrevit import revit, script, forms
from vibe import designspec, knowledge, latextract, latmodel, latrisa
from vibe import runlog
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."""
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='refused',
report_text=message)
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))
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='not-run',
report_text=message)
script.exit()
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# -------------------------------------------------------------- context
know = knowledge.load(doc)
for problem in knowledge.config_problems(doc, know):
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused', report_text=problem)
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]))
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Nothing is marked - no files written.')
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.')
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='refused',
report_text='Model has never been saved - nothing written.')
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.')
# The extraction ran and its files are on disk -- the record is 'ok' with
# the report text kept short, since record() caps at 20000 characters.
runlog.record('Lateral Extract', doc_title=doc.Title, outcome='ok',
report_path=written[0] if written else None,
report_text=('{0} marked member(s), {1} skipped, '
'{2} node(s), {3} member(s) written; files: {4}'
.format(n_marked, len(model['skipped']),
conn['node_count'],
conn['member_count'],
'; '.join(written))))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Lateral Extract', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -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,27 @@
title: Panel Lift Check
# 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.
#
# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons
# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row
# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a
# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the
# longest row, so the title is free to say what it means. No (WIP): moving
# panels did not change what this tool does, and it is not half-built.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,36 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: DesignHub
# REORGANISED 2026-09-05, on the owner's own instruction: "把 Panel Lift
# 移到 Design Hub 里面去,再把 Beam Analysis 跟 Connection Analysis 也加
# 进去" -- Panel Lift Check moved onto this pulldown from being one of
# Tools.panel's four large icons (see that panel's own bundle comment), and
# two new placeholder rows, BeamAnalysis and ConnectionAnalysis, joined
# GravityLoadChase and LateralExtract as more (WIP) rows.
#
# PanelLiftCheck is NOT half-built -- it is the one row here that runs, end
# to end, on a real piece, exactly like PanelAnalysis. It moved here because
# the owner wants it grouped with the other analysis tools rather than
# because it stopped working; nothing about its own behaviour changed.
#
# The other four rows are half-built and all four 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 BeamAnalysis, ConnectionAnalysis,
# GravityLoadChase and LateralExtract 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.
#
# Order: the two tools that run today, then the four that do not, in the
# order the owner named them.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- PanelLiftCheck
- PanelAnalysis
- BeamAnalysis
- ConnectionAnalysis
- GravityLoadChase
- LateralExtract
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,16 @@
# 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, lateral extraction, beam
# analysis and connection analysis are buttons-and-docs only until he
# provides worked examples, so they live one click down, behind DesignHub,
# on a panel of their own. Panel Analysis and Panel Lift Check share that
# same door for a different reason (2026-09-05 for Panel Lift Check) --
# both actually run, but the owner wants them grouped with the tools that
# do not rather than back out on Tools.panel; see DesignHub's own bundle
# comment.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- DesignHub
@@ -0,0 +1,13 @@
title: GOD_MOD
# 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.
#
# The name is the owner's, verbatim and with the underscore, from
# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod
# because a pyRevit component name is a C# class name in the generated
# assembly; the title is what anybody actually reads.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
#
# Starts a detached terminal running the `claude` CLI with its working
# directory set to the repository root, so the session comes up on CLAUDE.md
# and tools/rvt.py -- which is what wires it to THIS running Revit.
#
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
# long agent session cannot freeze or crash Revit, and this button touches the
# model in no way at all: it opens no Transaction, reads no element, and does
# not even import `vibe.helpers`' document handle.
#
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
# any other auto-approve flag. The agent is being pointed at a live Revit
# session and the owner's real model; every tool call it makes is worth a
# human seeing first. If a future edit "just needs" the prompts gone, that is
# a conversation with the owner, not a flag.
#
# Interactive, so this deliberately does NOT use the redirect-both-streams
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
# run without deadlocking on a pipe, and redirecting stdout here would leave
# the CLI with no terminal to draw on. What is reused is the other half of the
# idiom: a generated .cmd does the cd and holds the window open, so nothing
# has to nest quotes inside a wt.exe command line.
#
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
# unreferenced, for the owner to keep or bin.
#
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
# word, underscore and all. Same button, same folder contents throughout; the
# folder is `GodMod.pushbutton` because a component name becomes a C# class
# name in the generated assembly.
#
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
# bundle file cannot resurrect a name nobody uses.
__title__ = 'GOD_MOD'
import io
import os
import tempfile
from System.Diagnostics import Process, ProcessStartInfo
from pyrevit import forms, script
TITLE = 'Claude'
MARKER = 'CLAUDE.md' # the project instructions the session reads
def repo_root():
"""The checkout to start the session in, and whether it looks like one.
This script lives at
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
so the repo is four levels above the button. Deriving it means the
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
case where pyRevit does not hand us __file__.
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
session -- it is only missing the project instructions -- but the report
says so rather than letting the agent start somewhere surprising in
silence.
"""
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
try:
root = os.path.dirname(os.path.abspath(__file__))
except NameError:
return home, os.path.isfile(os.path.join(home, MARKER))
for _ in range(4):
root = os.path.dirname(root)
if os.path.isfile(os.path.join(root, MARKER)):
return root, True
if os.path.isfile(os.path.join(home, MARKER)):
return home, True
return root, False
def find_cli():
"""Absolute path to the Claude Code CLI, or None.
`vibe.aiextract.find_claude` already knows the four places a per-user
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
drift. Imported here rather than at module scope so a broken library
still leaves this button able to say what went wrong.
"""
from vibe.aiextract import find_claude
return find_claude()
def write_runner(claude_exe, workdir):
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
It goes in %TEMP%, never in the repo and never beside the model: it holds
an absolute path to somebody's install, which is exactly what the scrub
gate exists to keep out of the tree. Named per Revit process, because
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
file under each other is a class of bug not worth owning.
"""
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
if not os.path.isdir(folder):
os.makedirs(folder)
runner = os.path.join(folder,
'god-mod-claude-{0}.cmd'.format(os.getpid()))
# newline='' or the CRLFs below get translated again and every line ends
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
# text editor is a bad place to be debugging a launch from.
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'title Claude Code - Revit\r\n')
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
fh.write(u'echo Revit dev folder: %CD%\r\n')
fh.write(u'echo.\r\n')
fh.write(u'"{0}"\r\n'.format(claude_exe))
fh.write(u'echo.\r\n')
fh.write(u'echo [claude exited - this window stays open]\r\n')
fh.write(u'pause >nul\r\n')
return runner
def launch(runner, workdir):
"""Start the runner detached. Windows Terminal when it is there."""
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.WorkingDirectory = workdir
psi.UseShellExecute = True
return Process.Start(psi)
WORKDIR, HAS_MARKER = repo_root()
try:
CLAUDE = find_cli()
except Exception as ex:
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
if not CLAUDE:
forms.alert(
'Claude Code CLI not found.\n\n'
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
'full path of claude.cmd / claude.exe.\n\n'
'Looked in the usual per-user install folders and on PATH.',
title=TITLE, exitscript=True)
try:
RUNNER = write_runner(CLAUDE, WORKDIR)
launch(RUNNER, WORKDIR)
except Exception as ex:
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
output = script.get_output()
output.print_md('### Claude Code is starting')
output.print_md('Working folder: `{0}`'.format(WORKDIR))
output.print_md('CLI: `{0}`'.format(CLAUDE))
if not HAS_MARKER:
output.print_md(
'**No `{0}` in that folder** - the session will start without the '
'project instructions, and without them it does not know about the '
'bridge to this Revit.'.format(MARKER))
output.print_md(
'It runs with **default permissions**, so it asks before it acts. '
'It can read and drive **this** Revit session through `tools/rvt.py` - '
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
'button that renumbers the selected doors"_.')
@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Lab").
#
# One button, and it is not a work in progress -- GOD_MOD works. It sits
# alone at the right-hand end because it is the escape hatch rather than one
# of the four tools, and an escape hatch you can find without looking is
# worth a panel divider.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- GodMod
@@ -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,714 @@
# -*- coding: utf-8 -*-
#
# Turn a design summary into a reviewable spec file, in two chained passes.
#
# PASS 1, 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, except for exporting the summary
# view to a PNG for pass 2 (also read-only -- ExportImage writes no model
# data).
#
# PASS 2, AI, out of process (owner, 2026-09-01, Discord): chained
# automatically after pass 1 succeeds, `vibe.aiextract.launch` starts a
# DETACHED process (`tools/aiextract_run.py`, DSV4F with vision) that fills
# whatever pass 1 left null, empty or unresolved, plus the strand matrix
# pass 1 never attempts. It can only ADD to the spec pass 1 wrote -- see
# that module's docstring for the never-overwrite rule -- and this button
# does not wait for it: the spec file is updated in place whenever that
# process finishes, or left exactly as pass 1 wrote it if the AI pass fails.
#
# 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
import traceback
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 aiextract, ticketvision
from vibe import foldreport as fold
from vibe import runlog
from vibe import scope as scopelib
doc = revit.doc
output = script.get_output()
output.close_others()
if not doc.PathName:
runlog.record('Design Extract', doc_title=doc.Title,
outcome='refused',
report_text='Save the model first - the spec files live '
'beside it.')
forms.alert('Save the model first - the spec files live beside it.',
exitscript=True)
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# --------------------------------------------------------------- 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:
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
report_text='No design numbers found in this model.')
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:
runlog.record('Design Extract', doc_title=doc.Title, outcome='refused',
report_text='Design picker closed -- no design chosen.')
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, plant=plant)
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
# ----------------------------------------------- the AI pass, chained on
#
# Owner, 2026-09-01 (Discord), approving the two-stage-extraction
# proposal: the mechanical reader above always runs first and never
# guesses; whatever it left null, empty or unresolved is this second
# pass's job, on DSV4F WITH VISION, never opus ("可以,不过别用opus,用
# dsv4f带vision吧"). The strand matrix is one such field, not a
# standing exception any more -- `build_spec` now reads it from
# geometry (the drawn strand symbols against the dimension chain) and
# takes it only when every identity check on that reading passes; the
# AI pass picks it up here exactly when the geometry read refused, the
# same as any other blank left by the mechanical reader. It runs
# detached (`vibe.aiextract.launch`), the same
# way the AI reader always has, so this button returns immediately --
# the spec file above is updated IN PLACE whenever that process
# finishes, or is left exactly as written above (plus one `unresolved`
# line saying so) if the AI pass fails or times out. Nothing here waits
# for it and nothing here re-reads the spec after kicking it off.
#
# Rendering the summary view to an image is Revit API work, done here
# rather than inside `aiextract` (which otherwise has no reason to touch
# a live View) -- OWNER MUST VERIFY: exercised only against a live Revit
# session, not tonight.
ai_image, ai_error = None, None
try:
ai_views, _ai_sheets = designspec.find_summary_views(doc, design,
know=know)
if ai_views:
ai_image = ticketvision.render_view_png(
doc, ai_views[0].Id, designspec.sub_dir(doc, 'log'))
except Exception as ex:
ai_error = ('could not render the summary view to an image ({0}: '
'{1}) -- the AI pass will run text-only, and the strand '
'matrix will very likely stay blank'
.format(type(ex).__name__, ex))
try:
aiextract.launch(doc, design, know=know,
image_paths=[ai_image] if ai_image else [])
written[-1]['ai_launched'] = True
except Exception as ex:
written[-1]['ai_launched'] = False
started = ('the AI pass itself could not be started ({0}: {1})'
.format(type(ex).__name__, ex))
ai_error = (ai_error + '; ' + started) if ai_error else started
written[-1]['ai_image'] = ai_image
written[-1]['ai_error'] = ai_error
# ------------------------------------------------------- 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.')
# --------------------------------------------------------- the AI pass
output.print_md('---')
output.print_md(
'## AI pass (DSV4F, vision) -- chained automatically\n\n'
'For each design above, a second, detached pass just launched: it '
'reads whatever the mechanical reader left null, empty or '
'unresolved, plus the strand matrix the mechanical reader never '
'even attempts. It runs on DSV4F with vision, never opus (owner\'s '
'ruling, 2026-09-01) -- and it can only ADD to the spec: a field '
'the mechanical reader above already filled is never touched. It '
'writes back to the SAME spec file above, in place, whenever it '
'finishes; if it fails or times out the file is left exactly as '
'the mechanical reader wrote it, with one line in `unresolved` '
'saying so.')
for w in written:
bits = ['launched' if w.get('ai_launched') else 'NOT launched']
if w.get('ai_image'):
bits.append('drawing image rendered')
else:
bits.append('no drawing image -- text-only pass')
output.print_md('- **{0}** &mdash; {1}'.format(w['design'],
', '.join(bits)))
if w.get('ai_error'):
output.print_md(' > {0}'.format(w['ai_error']))
output.print_md(
'**Every value in these spec files remains machine-read and '
'unverified** -- the AI pass does not change that, it only changes '
'who read which line. Re-open the spec after the AI pass\'s window '
'closes and check it against the drawing the same as above, '
'especially the strand matrix and anything now stamped '
'`dsv4f-vision` in `source.extractor`.')
# ------------------------------------------- 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: `regex` is the '
'mechanical reader this button runs, `dsv4f-vision` is the second, '
'AI pass this button now chains after it (`tools/aiextract_run.py`, '
'DSV4F with vision -- owner\'s ruling, 2026-09-01), and `opus` / '
'`claude-code` are what an earlier AI reader stamped before that '
'ruling -- older files, read the same way. Nothing branches on the '
'value; it is shown so a spec written by one reader is not mistaken '
'for a spec written by another.')
# The extraction ran and everything is on the page. The record is 'ok'
# with a short summary; record() never raises, so a logging failure cannot
# turn a finished run into an error.
runlog.record('Design Extract', doc_title=doc.Title, outcome='ok',
report_text=('{0} design(s) written, {1} skipped; saved '
'copy: {2}'
.format(len(written), len(skipped),
chosen if not cancelled_save else 'none')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Design Extract', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,8 @@
title: Hot Reload
# No tooltip and no author on purpose -- the owner wants nothing on hover,
# and test_button_tooltips.py enforces it. Document the button in comments
# in script.py instead.
#
# One line: this is a row in a `.pulldown` (text beside a 16px icon), so a
# `\n` has nowhere to go. The dropdown grows to fit, so the title may read
# in full.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
#
# Hot Reload -- stop the WillTools routes bridge cleanly, THEN reload pyRevit.
#
# Why the stop must come first: the routes HTTP dispatch is wired to a module-
# global external event handler created once, on the host main thread, when
# `pyrevit/routes/server/server.py` is first imported (server.py:35-36), and it
# is NOT recreated across a reload -- pyRevit re-imports extension scripts, not
# its own lib. A request in flight when the engine is torn down runs its handler
# against a dead engine -> unhandled exception on a non-main thread -> Revit dies
# uncatchably (measured 2026-08-14; see startup.py and CLAUDE.md "Never reload").
# Stopping the HTTP server first closes that crash window. On reload, startup.py
# re-registers the routes and a fresh server comes back up automatically.
#
# If the server will NOT stop cleanly, this ABORTS rather than reloads -- a
# reload with a live bridge is the exact crash, so a bad stop must never fall
# through to it.
#
# IronPython 2.7: no f-strings. No module docstring / tooltip / author
# (test_button_tooltips.py).
# pylint: disable=import-error,invalid-name,broad-except
from pyrevit import EXEC_PARAMS
from pyrevit import routes
from pyrevit import forms
from pyrevit import script
from pyrevit.loader import sessionmgr
from pyrevit.loader import sessioninfo
logger = script.get_logger()
go = True
if EXEC_PARAMS.executed_from_ui:
go = forms.alert(
'Hot Reload stops the WillTools bridge, then reloads pyRevit.\n\n'
'Run this only when no bridge request is in flight -- no sweep, no '
'agent mid-exec. A request raised the instant before this click cannot '
'be cancelled and can still take Revit down.\n\nReload now?',
ok=False, yes=True, no=True)
if go:
stopped_clean = True
try:
routes.deactivate_server()
except Exception as ex:
stopped_clean = False
logger.error('deactivate_server raised: {0}'.format(ex))
# Verify the socket is actually gone before tearing the engine down. A
# deactivate that "succeeded" but left the server up is still unsafe.
try:
if routes.get_active_server() is not None:
stopped_clean = False
except Exception as ex:
stopped_clean = False
logger.error('get_active_server raised: {0}'.format(ex))
if not stopped_clean:
forms.alert(
'Could not stop the WillTools bridge cleanly.\n\n'
'Do NOT reload with a live bridge -- restart Revit instead of '
'reloading. (A reload while the routes handler is live is what '
'crashes Revit.)', ok=True)
else:
# Vouch to tools/rvt.py that THIS reload is a clean stop-first one, so
# the reloaded bridge is not refused as a raw-reload landmine. startup.py
# consumes this marker on the very next load and stamps that session
# 'hotreload'. Keyed by pid: a reload is in-process, so the same Revit
# picks it up and no other session can. Best-effort -- if it cannot be
# written the only cost is rvt.py refusing the reloaded bridge (the safe
# default), never a failed reload.
try:
import os as _os
from System.Diagnostics import Process as _Process
_here = _os.path.abspath(__file__).replace('\\', '/')
_mark = _here.find('/WillTools.extension/')
if _mark != -1:
_root = _here[:_mark]
else:
_root = _os.path.join(_os.path.expanduser('~'), 'pyrevit-dev')
_pid = _Process.GetCurrentProcess().Id
_flagpath = _os.path.join(
_root, '.bridge-hotreload-{0}.flag'.format(_pid))
_fh = open(_flagpath, 'w')
_fh.write('hotreload\n')
_fh.close()
except Exception as ex:
logger.warning('could not write hot-reload marker: {0}'.format(ex))
logger.info('WillTools bridge stopped. Reloading pyRevit...')
sessionmgr.reload_pyrevit()
# Mirrors the shipped Reload button: record the new session id.
script.get_results().newsession = sessioninfo.get_session_uuid()
@@ -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,207 @@
# -*- 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
import traceback
from pyrevit import revit, script
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
from vibe import foldreport as fold
from vibe import runlog
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 + '**')
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
report_text=('\n'.join(problems) +
'\n\nJob Recon cannot run until the '
'configuration is fixed.'))
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)
runlog.record('Job Recon', doc_title=doc.Title, outcome='refused',
report_text=('Job Recon cannot run: the control-number '
'roles are not mapped.\n' + 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:
# The headline alone was the whole page -- recorded the same as a
# full page, with the file copy's path when one was written.
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
report_path=full_path if written else None,
report_text='Job Recon ran -- nothing below the headline '
'to report.')
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))
# The page is complete and the file copy exists. The verdict in numbers
# is the headline, which `sections[0]` carried as markdown -- this short
# summary says what reconciled, and points at the file for the rest.
runlog.record('Job Recon', doc_title=doc.Title, outcome='ok',
report_path=full_path if written else None,
report_text='Job Recon ran; full report at {0}'
.format(full_path if written else 'this page'))
try:
main()
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Job Recon', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -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,405 @@
# -*- 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
import traceback
from pyrevit import revit, script, HOST_APP
from vibe import runlog
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:
# Name the frame that blew, not just the exception: a bare
# "SystemError: Object reference not set to an instance of an object"
# (IronPython's face for a .NET NullReferenceException) says nothing
# about WHERE, and 2026-09-05 one of these cost a round trip to the
# owner's machine to find out. Innermost frame of our own code first.
where = ''
try:
import traceback
frames = traceback.extract_tb(sys.exc_info()[2])
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
if ours:
fn, ln, func, _ = ours[-1]
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
except Exception:
pass
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
# ------------------------------------------------------------------- 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 - the Claude Code bridge 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, aiextract)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks, aiextract'
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')
# ----------------------------------------------------------------- tooling
def _claude():
from vibe import aiextract
found = aiextract.find_claude()
if not found:
return 'WARN', ('not found - Extract Design cannot run. Install the '
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
return 'OK', found
probe('tooling', 'Claude Code CLI', _claude, 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),
knowledge.plant_file_in_use(know),
'; {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 _plant_gaps():
"""Plant-owned data the buttons need, checked at setup time.
The per-button refusals gate on `handling.lifter_groups`,
`handling.stages` and friends, so a gap is normally found by the user
as a per-piece NOT RUN. This probe runs the same gate off the merged
tree and says which plant file to edit, so the developer finds it here
instead -- see knowledge.plant_layer_gaps for the wording.
"""
from vibe import knowledge
know = knowledge.load(doc)
gaps = knowledge.plant_layer_gaps(know)
if not gaps:
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
knowledge.plant_file_in_use(know))
texts = [g['text'] for g in gaps]
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
def _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
# Two states that used to share one FAIL (issue #20). A model with
# NO assemblies at all is a job that has not reached the reinforcing
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
# Assemblies present but none carrying a design number is the
# convention failure this probe exists for.
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
if n_asm == 0:
return 'WARN', ('no assemblies in this model yet - the job has not '
'reached the reinforcing stage; the design-number '
'grouping is checked once assemblies exist')
return 'FAIL', ('{0} assemblies, none carries a design number - either '
'the panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so'
.format(n_asm))
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', 'plant layer completeness', _plant_gaps)
probe('conventions', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
#
# The whole report body is wrapped so an unexpected exception still leaves a
# run-log record (outcome='error') before pyRevit shows its own error dialog.
try:
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')
outcome = 'ok' if not fails else 'fail'
runlog.record(
'Self Check', doc_title=doc.Title,
outcome=outcome,
report_text='\n'.join(
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
except Exception as exc:
runlog.record('Self Check',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(exc),
traceback_text=traceback.format_exc())
raise
@@ -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,210 @@
# -*- 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'
import traceback
from pyrevit import revit, script, forms
from vibe import foldreport as fold
from vibe import knowledge
from vibe import runlog
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)
runlog.record('Takeoff', doc_title=doc.Title, outcome='not-run',
report_text='{0}: {1}'.format(headline, message))
script.exit()
def main():
know = knowledge.load(doc)
problems = knowledge.config_problems(doc, know)
if problems:
runlog.record('Takeoff', doc_title=doc.Title, outcome='refused',
report_text='\n'.join(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'))
# The takeoff is on the page and (usually) in the files beside the model
# -- the record is 'ok' with the paths that were written.
runlog.record('Takeoff', doc_title=doc.Title, outcome='ok',
report_path=paths.get('.md'),
report_text=('Takeoff ran; index built {0}. Reports: {1}'
.format(coverage.get('built_at') or 'never',
', '.join(sorted(paths)) or 'not '
'written')))
try:
main()
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Takeoff', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,31 @@
# 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
- HotReload
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,11 @@
title: Reinforce Suggestion
# 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.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. A pulldown row renders as text beside a
# 16px icon and the dropdown grows to fit the longest row, so the title is
# free to say what it means, unlike a large button rationed to ~9 characters
# a line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,459 @@
# -*- 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 Suggestion'
# 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
import traceback
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 runlog
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."""
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused', report_text=message)
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.
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------------ 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:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target 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:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target 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))
# The report is on the page and the full text is on disk -- record the run
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
# the page led with; keep the record short, since report_text is capped.
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
report_path=full_path,
report_text=('Target {0}; suggestion {1}'
.format(target_name,
'found' if best is not None
else 'not judged')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
title: Reinf QC
# 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 (it shares
# `lib/vibe/ticketqc_run.py` and `ticketchecks.py` with Ticket QC) is
# visible without a Revit restart. See TicketQC.pushbutton/bundle.yaml for
# the full explanation; this one carries the same key for the same reason.
#
# 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.
#
# Added 2026-09-05, on the owner's own instruction: a cut-down TicketQC for
# a panel whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started. Shares its engine with
# TicketQC -- both call into `lib/vibe/ticketqc_run.py`, which takes a
# `mode` of 'ticket' or 'reinforcing' -- and reads the same evidence one
# stage earlier: no ticket sheet is required, and any check whose evidence
# is a ticket artefact (a hatch, a callout, a BOM table, a sheet name...)
# does not run here. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` for
# exactly which of the 41 checks that is, and why, one by one.
#
# 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 -- kept
# short anyway so it reads at a glance beside "Ticket QC".
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
#
# Check a panel's MODELLED reinforcing against its design spec -- for a
# piece whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started yet.
#
# A cut-down Ticket QC: same engine, same evidence-first discipline, but
# only the checks whose evidence is the panel's modelled content run here
# (connectors/embeds, lifters, strands, bars, WWF, solid zones as modelled,
# member interference, weight, cast extras, release ratio, the handling
# result, lift positions/points). Anything that needs a ticket artefact --
# a sheet, a placed view, a printed call-out, a hatch pattern, a BOM table,
# a bed-plan note -- does not run here; it is listed, with its reason, in
# the report's own "Not checked at this stage" section rather than left
# silent. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` /
# `REINFORCING_EXCLUDED` for the check-by-check classification, and
# `lib/vibe/ticketqc_run.py` for the shared engine this and Ticket QC both
# call.
#
# Read-only. Nothing here opens a transaction.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinf QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='reinforcing')
@@ -0,0 +1,27 @@
title: Ticket QC
# 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.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. One line, no `\n`: a pulldown row renders
# as text beside a 16px icon and the dropdown grows to fit the longest row,
# so the title is free to say what it means -- unlike a large button, which
# is as wide as its widest line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,32 @@
# -*- 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.
#
# THE WHOLE BUTTON MOVED into `lib/vibe/ticketqc_run.py` on 2026-09-05, so
# the new Reinforcing QC button could reuse it instead of these 765 lines
# being copy-pasted -- see that module's own header for exactly what is
# shared and what differs. This script is `mode='ticket'`, which is BYTE
# FOR BYTE what used to live here: nothing about Ticket QC's own behaviour
# changed in the move.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Ticket QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='ticket')
@@ -0,0 +1,27 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: Modeling
# THE MODELLING-STAGE TOOLS, gathered under one arrow (2026-09-05, on the
# owner's own instruction: "加一个 Modeling pulldown,把 Reinforcing 和
# TicketQC 放进去"). ReinforceSuggest and TicketQC moved here from being two
# of the four large buttons this panel used to show side by side; the fourth
# tool that lived here, Panel Lift Check, moved the other way -- onto
# Design.panel/DesignHub.pulldown, see that bundle's own comment. Snapshot
# stays put as the one remaining large icon.
#
# ReinforcingQC is new: a cut-down TicketQC for a panel whose reinforcing is
# finished but whose ticketing has not started yet -- see
# lib/vibe/ticketqc_run.py and lib/vibe/ticketchecks.REINFORCING_CHECKS for
# what it does and does not judge at that stage. It sits beside TicketQC
# because it shares the engine and reads the same evidence, one stage
# earlier.
#
# Order is the natural order of the work: suggest the reinforcing, check the
# reinforcing once it is modelled, check the ticket once it is drawn.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ReinforceSuggest
- ReinforcingQC
- TicketQC
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -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,889 @@
# -*- 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 runlog
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):
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='refused', report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
if doc is None:
# `doc.Title` would itself fail on a None document -- record what there is
# to record, then hand the refusal to the dialog.
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
report_text='No model is open, so there is nothing to '
'snapshot.')
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
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------ 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:
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='No job knowledge file; user declined to '
'snapshot anyway.')
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.')
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='User cancelled the snapshot dialog.')
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]})
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='Phase A failed - Revit has not been closed.',
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.')
# A normal completion for the STAY / cycle choices: Revit stays open,
# the snapshot index is on disk. The CLOSE path below records its own
# completion (Revit is about to exit, so this is the last write).
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Revit stays open. Index written: {0}'
.format(report['path'] if report else '?')))
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.')
# The CLOSE path's completion. If the close command was accepted Revit is
# about to exit, so this record is the button's last write; if not, the
# run still produced its snapshot and says so.
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Close posted: {0}. Index written: {1}'
.format(posted,
report['path'] if report else '?')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
# 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.
#
# REORGANISED 2026-09-05, on the owner's own instruction: "把 ribbon 重新
# organize 一下,加一个 Modeling pulldown,把 Reinforcing 和 TicketQC 放进去".
# This panel used to hold four large icons side by side (reinforce, LIFT,
# ticketqc, snapshot -- see the 2026-08-30 note this comment replaces). Two
# of those four moved into a new `Modeling.pulldown` (ReinforceSuggest and
# TicketQC, joined there by the new ReinforcingQC), and the third,
# PanelLiftCheck, moved the other way onto Design.panel/DesignHub.pulldown --
# see that bundle's own comment for why. SnapshotClose is the one large icon
# still on this panel: it never belonged to the modelling/ticketing group,
# it closes and archives a session, so it keeps its own button rather than
# joining a pulldown about a different kind of work.
#
# What is left is one pulldown and one large button, side by side. 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:
- Modeling
- SnapshotClose
@@ -0,0 +1,20 @@
# 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.
#
# Four 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, and GOD_MOD keeps its own corner.
#
# A panel not listed below is not created at all
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
layout:
- Tools
- Setup
- Design
- Lab