Files
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

584 lines
26 KiB
Python

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