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

History-free snapshot of the private development repository at 77a82eb.
Code, docs and the public catalogue only: no plant or job knowledge
layers, no client, job or producer names (tools/scrub.py clean against
the widened term list; a second word-level grep clean). The intro deck is
left out because its slide images cannot be checked for job drawings.
This commit is contained in:
2026-09-09 08:35:15 -05:00
commit 25592fa6c2
406 changed files with 223435 additions and 0 deletions
@@ -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