Public demo snapshot of an in-house pyRevit extension for precast concrete shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel handling solver (PCI Chapter 8). - No client, job, plant or product data: knowledge layers ship as structural templates only. - Ticket QC's model-read escalation targets a LAN model server endpoint (configurable), not a cloud provider. - Built from the private development repo via tools/make_dist.py.
This commit is contained in:
+8
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
+583
@@ -0,0 +1,583 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Turn a design summary into a reviewable spec file.
|
||||
#
|
||||
# THE PASS, mechanical, in-process: scrapes the summary drafting view and its
|
||||
# sheet, then reads it with ticketqc's regex/geometry parser -- no AI call,
|
||||
# no subprocess, nothing that leaves this process. What the parser could not
|
||||
# place lands verbatim in the spec's 'unresolved' list rather than being
|
||||
# dropped, which is what makes a narrow rule set honest instead of lossy.
|
||||
# Read-only as far as Revit is concerned (ExportImage writes no model data).
|
||||
#
|
||||
# Every value this button writes is machine-read and UNVERIFIED. Check it
|
||||
# against the drawing before anything downstream (Ticket QC, Panel Lift Check)
|
||||
# is trusted to have used the right numbers.
|
||||
#
|
||||
# THE DECLARED SCOPE, AND WHY THIS BUTTON DOES NOT ENFORCE IT
|
||||
# -----------------------------------------------------------
|
||||
# The specs written here are what Ticket QC's design-intent comparison
|
||||
# enforces, so a design extracted here enters the scoped path. Until
|
||||
# 2026-08-31 this button offered EVERY design in the model with no mention of
|
||||
# the scope at all -- a side door: an out-of-scope design could be extracted,
|
||||
# reach Ticket QC, and be compared against, without ever being labelled.
|
||||
#
|
||||
# It is not a door that should be shut. The engineer may have a reason to
|
||||
# extract somebody else's design -- to read it, to compare against it, to
|
||||
# check a shared edge -- and a picker that hid designs would be exactly the
|
||||
# silent exclusion `vibe.scope` exists to refuse. So nothing is filtered and
|
||||
# nothing is blocked. Instead:
|
||||
#
|
||||
# * the picker row says OUTSIDE THE DECLARED SCOPE, at the moment of
|
||||
# choosing, before anything is written;
|
||||
# * the report carries the scope line every other report carries, and,
|
||||
# when one was actually extracted, the annotation saying the values in
|
||||
# it rest on knowledge nobody validated for that kind of piece;
|
||||
# * the fold opens ITSELF when that happened, by the standing display rule.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Design Extract'
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from Autodesk.Revit.DB import (BrowserOrganization, FilteredElementCollector,
|
||||
ViewSheet)
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import designspec, ticketqc, knowledge, specsave
|
||||
from vibe import foldreport as fold
|
||||
from vibe import scope as scopelib
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
if not doc.PathName:
|
||||
forms.alert('Save the model first - the spec files live beside it.',
|
||||
exitscript=True)
|
||||
|
||||
# --------------------------------------------------------------- pick work
|
||||
know = knowledge.load(doc)
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=know)
|
||||
existing = designspec.list_specs(doc)
|
||||
|
||||
DESIGN_FOLDER = knowledge.get(know, 'nomenclature.design_folder', '') or ''
|
||||
IGNORE = knowledge.get(know, 'nomenclature.ignore_sheet_prefixes', []) or []
|
||||
|
||||
# The declared scope, off the job knowledge, read the same way Job Recon,
|
||||
# Takeoff and Ticket QC read it. Used to LABEL, never to filter -- see the
|
||||
# note at the top of this file.
|
||||
the_scope = scopelib.of(know)
|
||||
|
||||
|
||||
def out_of_scope(design):
|
||||
"""True when this design number is declared to be somebody else's.
|
||||
|
||||
Through `as_mark`, so a design key that does not read as a mark at all
|
||||
is 'could not tell' and is never labelled as another engineer's.
|
||||
"""
|
||||
return not the_scope.includes(scopelib.as_mark(design))
|
||||
|
||||
|
||||
def sheets_by_folder():
|
||||
"""{'FOLDER / SUB': [ViewSheet, ...]} read from the project browser's own
|
||||
organization, so this picker is grouped the way the browser tree is.
|
||||
|
||||
A flat list of every drafting view is unusable on a real job -- there are
|
||||
hundreds, and their names are not the design numbers anyway.
|
||||
"""
|
||||
try:
|
||||
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
|
||||
except Exception:
|
||||
org = None
|
||||
|
||||
out = {}
|
||||
for sheet in (FilteredElementCollector(doc).OfClass(ViewSheet)
|
||||
.WhereElementIsNotElementType().ToElements()):
|
||||
if sheet.IsTemplate:
|
||||
continue
|
||||
number = (sheet.SheetNumber or '').strip()
|
||||
if any(number.upper().startswith(str(p).upper()) for p in IGNORE):
|
||||
continue
|
||||
folder = ''
|
||||
if org is not None:
|
||||
try:
|
||||
folder = ' / '.join(
|
||||
[item.Name for item in org.GetFolderItems(sheet.Id)])
|
||||
except Exception:
|
||||
folder = ''
|
||||
out.setdefault(folder or '(no folder)', []).append(sheet)
|
||||
return out
|
||||
|
||||
|
||||
def packets(sheets):
|
||||
"""{design key: [ViewSheet, ...]} in page order.
|
||||
|
||||
One row per design packet, not per sheet: PNL1.0 / PNL1.A / PNL1.B are
|
||||
pages of design PNL1, and extracting any of them means extracting the
|
||||
packet.
|
||||
"""
|
||||
out = {}
|
||||
for sheet in sheets:
|
||||
key = designspec.design_key(sheet.SheetNumber, know)
|
||||
if key:
|
||||
out.setdefault(key, []).append(sheet)
|
||||
for key in out:
|
||||
out[key].sort(key=lambda s: (s.SheetNumber or ''))
|
||||
return out
|
||||
|
||||
|
||||
DESIGNS = {} # row label -> design key, so nothing has to be split
|
||||
|
||||
|
||||
def row(design, sheets=None):
|
||||
bits = []
|
||||
if sheets:
|
||||
cover = sheets[0]
|
||||
bits.append(cover.Name)
|
||||
if len(sheets) > 1:
|
||||
bits.append('{0} sheets ({1})'.format(
|
||||
len(sheets), ', '.join([s.SheetNumber for s in sheets[:6]])))
|
||||
else:
|
||||
bits.append('sheet {0}'.format(cover.SheetNumber))
|
||||
bits.append('{0} pieces'.format(len(grouped[design])) if design in grouped
|
||||
else 'NO pieces')
|
||||
if design in existing:
|
||||
bits.append('spec exists - will be overwritten')
|
||||
# At the point of choosing, and in capitals: this is the one moment the
|
||||
# engineer can decide he did not mean to extract somebody else's design.
|
||||
# It never removes the row.
|
||||
if out_of_scope(design):
|
||||
bits.append('OUTSIDE THE DECLARED SCOPE')
|
||||
label = '{0} - {1}'.format(design, ', '.join(bits))
|
||||
DESIGNS[label] = design
|
||||
return label
|
||||
|
||||
|
||||
groups = OrderedDict()
|
||||
|
||||
by_folder = sheets_by_folder()
|
||||
for folder in sorted(by_folder):
|
||||
found = packets(by_folder[folder])
|
||||
if found:
|
||||
groups['Sheets / ' + folder] = [row(key, found[key])
|
||||
for key in sorted(found)]
|
||||
|
||||
# Designs the pieces claim that no sheet in the design folder accounts for.
|
||||
# These would silently go unchecked, so they get their own group rather than
|
||||
# being left off the list.
|
||||
accounted = set()
|
||||
for folder in by_folder:
|
||||
if not DESIGN_FOLDER or DESIGN_FOLDER.upper() in folder.upper():
|
||||
accounted.update(packets(by_folder[folder]).keys())
|
||||
orphans = [d for d in sorted(grouped) if d not in accounted]
|
||||
if orphans:
|
||||
groups['Pieces with no design sheet'] = [row(d) for d in orphans]
|
||||
|
||||
if existing:
|
||||
leftover = [row(d) for d in sorted(existing) if d not in DESIGNS.values()]
|
||||
if leftover:
|
||||
groups['Specs already on disk'] = leftover
|
||||
|
||||
if not groups:
|
||||
forms.alert('No design numbers found in this model.', exitscript=True)
|
||||
|
||||
kwargs = {
|
||||
'title': 'Extract which design summaries?',
|
||||
'group_selector_title': 'Browser folder',
|
||||
'multiselect': True,
|
||||
'button_name': 'Extract',
|
||||
'width': 900,
|
||||
'height': 650,
|
||||
}
|
||||
preferred = 'Sheets / ' + DESIGN_FOLDER
|
||||
if DESIGN_FOLDER and preferred in groups:
|
||||
kwargs['default_group'] = preferred
|
||||
|
||||
picked = forms.SelectFromList.show(groups, **kwargs)
|
||||
if not picked:
|
||||
script.exit()
|
||||
designs = sorted(set([DESIGNS.get(p, p.split(' ')[0]) for p in picked]))
|
||||
|
||||
# What was OFFERED and left unpicked is not the scope's business -- the
|
||||
# engineer chose. What matters is what he DID pick that the declaration says
|
||||
# is somebody else's, because those specs are about to feed Ticket QC.
|
||||
picked_out = [d for d in designs if out_of_scope(d)]
|
||||
scope_line = the_scope.line(picked_out, total=len(designs), unit='design')
|
||||
scope_annotation = the_scope.annotation(picked_out, unit='design')
|
||||
|
||||
# ------------------------------------------------------------- gather + go
|
||||
output.print_md('# Extract design summaries')
|
||||
output.print_md('Model **{0}**'.format(doc.Title))
|
||||
output.print_md('Data folder: `{0}`'.format(designspec.root_dir(doc)))
|
||||
output.print_md(
|
||||
'**Every value below is machine-read and unverified.** A regex either '
|
||||
'matched a rule or it did not -- there is no judgement in between. What '
|
||||
"matched is filled in; every line of the summary that didn't is listed "
|
||||
'verbatim in the spec\'s `unresolved` section instead of being silently '
|
||||
'dropped -- and, from this press on, on this page as well. Read those, '
|
||||
'then check the spec in `{0}` against the drawing before Ticket QC or '
|
||||
'Panel Lift Check are trusted to have used the right numbers.'
|
||||
.format(designspec.sub_dir(doc, 'specs')))
|
||||
|
||||
# ---- the declared scope. Not folded, and above the results rather than
|
||||
# below them, because a spec is written to disk by the time this page exists
|
||||
# -- the reader is being told what he already has, and Ticket QC will pick it
|
||||
# up whether or not he scrolls.
|
||||
output.print_md('> ' + scope_line)
|
||||
if scope_annotation:
|
||||
output.print_md('> **' + scope_annotation + '**')
|
||||
for _note in the_scope.declaration_lines():
|
||||
output.print_md('_{0}_'.format(_note))
|
||||
|
||||
def _per_plant_layer_exists():
|
||||
"""True when this machine already has at least one per-plant folder.
|
||||
|
||||
Answers a different question from `knowledge.plant_id(doc)`: that asks
|
||||
which plant THIS job names; this asks whether the per-plant migration
|
||||
has already happened on THIS MACHINE at all, which is what decides
|
||||
whether the legacy machine-wide path is still a safe place to drop a
|
||||
starter.
|
||||
|
||||
A machine nobody has ever set up has no `<root>/<anything>/knowledge.yaml`
|
||||
-- writing the machine-wide starter there is exactly what this button has
|
||||
always done, and it is still right: a fresh install needs something to
|
||||
edit. But once a machine has even one real per-plant folder, the
|
||||
machine-wide file at `<root>/knowledge.yaml` is what
|
||||
`plant_knowledge_path(None)` hands back as the NO-PLANT FALLBACK
|
||||
(`plantpath.py`), so writing a blank template there would give a job
|
||||
that simply forgot to set `job.plant` a silent, empty plant layer
|
||||
instead of a loud "no plant declared." That is the exact trap this fix
|
||||
exists to close (QNA/seam-review-2026-08-31.md section 2), so on a
|
||||
migrated machine the safe move is to write nothing and say so.
|
||||
"""
|
||||
root = designspec.plant_root()
|
||||
try:
|
||||
names = os.listdir(root)
|
||||
except Exception:
|
||||
return False
|
||||
for name in names:
|
||||
if os.path.isfile(os.path.join(root, name, 'knowledge.yaml')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# `write_plant_starter()` with no plant argument always targets the
|
||||
# machine-wide `<root>/knowledge.yaml` -- the exact file
|
||||
# `plant_knowledge_path(None)` returns as the fallback for any job that
|
||||
# omits `job.plant`. Calling it unconditionally, as this button used to,
|
||||
# recreated a blank template there on every press, which any no-plant job
|
||||
# would then silently read as its entire plant layer. `knowledge.plant_id`
|
||||
# is the pre-pass `knowledge.load()` itself uses to choose the plant layer
|
||||
# before the ordinary merge runs (see its docstring), so it is read the
|
||||
# same way here rather than re-derived.
|
||||
plant = knowledge.plant_id(doc)
|
||||
|
||||
if plant:
|
||||
# A named plant gets its own folder. `write_plant_starter` already
|
||||
# refuses to overwrite a file that exists (knowledge.py:6601 --
|
||||
# `if os.path.isfile(path) and not overwrite: return path, False`), so
|
||||
# calling it on every press is safe: it only ever fills a gap, never
|
||||
# clobbers a real plant layer.
|
||||
ppath, pcreated = knowledge.write_plant_starter(plant=plant)
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
elif _per_plant_layer_exists():
|
||||
# No plant named, but this machine has already been migrated to
|
||||
# per-plant folders -- writing at the legacy path would recreate the
|
||||
# exact decoy this fix removes. Report the gap instead of filling it
|
||||
# with a blank file that looks like an answer.
|
||||
output.print_md(
|
||||
'Plant standards: **no `job.plant` set**, and this machine already '
|
||||
'has per-plant folders under `{0}` -- nothing written at the '
|
||||
'legacy machine-wide path, because that path is what a no-plant '
|
||||
'job reads as its plant layer. Set `job.plant` in this job\'s '
|
||||
'knowledge file.'.format(designspec.plant_root()))
|
||||
else:
|
||||
# A genuinely fresh machine: no per-plant folder exists anywhere yet,
|
||||
# so the machine-wide file is still the only plant layer this install
|
||||
# could possibly mean, exactly as it was before per-plant folders
|
||||
# existed. Safe to seed, and `write_plant_starter` still refuses to
|
||||
# overwrite if one is somehow already there.
|
||||
ppath, pcreated = knowledge.write_plant_starter()
|
||||
output.print_md('Plant standards: `{0}`{1}'.format(
|
||||
ppath, ' (just created - edit it)' if pcreated else ''))
|
||||
|
||||
kpath, created = knowledge.write_starter(doc)
|
||||
if created:
|
||||
output.print_md('Wrote a starter knowledge file: `{0}`'.format(kpath))
|
||||
|
||||
written, skipped = [], []
|
||||
for design in designs:
|
||||
try:
|
||||
evidence = designspec.gather_evidence(doc, design, know=know)
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
if not evidence.get('summary_views'):
|
||||
skipped.append((design, 'no sheet in "{0}" numbers to {1}, or its '
|
||||
'sheets carry no views'.format(
|
||||
DESIGN_FOLDER or 'any folder', design)))
|
||||
continue
|
||||
designspec.write_json(designspec.evidence_path(doc, design), evidence)
|
||||
notes = sum([len(v.get('text_notes') or [])
|
||||
for v in evidence.get('summary_views') or []])
|
||||
|
||||
try:
|
||||
spec = designspec.build_spec(doc, design, know=know, evidence=evidence)
|
||||
if spec is None:
|
||||
skipped.append((design, 'no summary view to read'))
|
||||
continue
|
||||
path = designspec.save_spec(doc, design, spec)
|
||||
# The spec itself is kept, not just its counts: what is unresolved is
|
||||
# listed by name below, and the copy the engineer asks for is written
|
||||
# from the file this returned.
|
||||
written.append({'design': design, 'notes': notes, 'path': path,
|
||||
'spec': spec})
|
||||
except Exception as ex:
|
||||
skipped.append((design, '{0}: {1}'.format(type(ex).__name__, ex)))
|
||||
continue
|
||||
|
||||
# ------------------------------------------------------- where the copy goes
|
||||
#
|
||||
# Asked AFTER the extraction has run and BEFORE any of it is shown. Owner,
|
||||
# 2026-08-30: "跑完extractdesign,出结果以前,让用户选择存designextraction文件的
|
||||
# 路径。用户也可以打开看到底是什么东西unresolved。"
|
||||
#
|
||||
# A COPY, NOT A MOVE. The spec is already written where the rest of the tool
|
||||
# looks for it: `designspec.list_specs` reads `_ticketqc/specs/` and nothing
|
||||
# else, so a spec saved only into a folder the engineer picked would look
|
||||
# extracted and be invisible to Ticket QC and Panel Lift Check. What this
|
||||
# dialog chooses is a SECOND copy -- to keep, to send, or just to open and
|
||||
# read.
|
||||
#
|
||||
# CANCELLING KEEPS THE WORK. PanelLiftCheck asks the same question and exits
|
||||
# on cancel, which is right there: nothing has been calculated yet, so a
|
||||
# cancel costs nothing. Here the extraction has already run and the files are
|
||||
# already on disk, so exiting would throw away work that only another press
|
||||
# could get back. Cancel therefore means "no second copy" and nothing else --
|
||||
# the results still print, pointing at the canonical file.
|
||||
copies = {}
|
||||
copy_problems = []
|
||||
in_place = []
|
||||
cancelled_save = False
|
||||
|
||||
if written:
|
||||
designs_written = [w['design'] for w in written]
|
||||
# Seeded with wherever the last extraction was saved; the job's data
|
||||
# folder when there is no such memory. Both are real places on this
|
||||
# machine -- a dialog opening on a path that does not exist is how a file
|
||||
# lands somewhere nobody meant. NOT the `specs` folder itself: the
|
||||
# default name is the name the canonical file already has, so opening
|
||||
# there would greet a single-design run with "PNL1.yaml already exists,
|
||||
# replace it?" -- a question about the file it just wrote.
|
||||
start_dir = (specsave.recall_dir(designspec.root_dir(doc))
|
||||
or designspec.root_dir(doc))
|
||||
chosen = forms.save_file(
|
||||
file_ext='yaml', init_dir=start_dir or '',
|
||||
default_name=specsave.default_name(designs_written),
|
||||
title='Save the design extraction as')
|
||||
if not chosen:
|
||||
cancelled_save = True
|
||||
else:
|
||||
by_design = dict([(w['design'], w['path']) for w in written])
|
||||
for design, target in specsave.targets(chosen, designs_written):
|
||||
# Saving onto the file itself. The dialog opens on the folder the
|
||||
# specs already live in, so this is an ordinary thing to do by
|
||||
# accident -- and rewriting a file with its own contents is at
|
||||
# best pointless. Say so instead of claiming a copy that is the
|
||||
# same path twice.
|
||||
if specsave.same_file(by_design[design], target):
|
||||
in_place.append(design)
|
||||
continue
|
||||
try:
|
||||
# Copied as text rather than re-rendered from the spec, so the
|
||||
# copy is character-for-character the file Ticket QC reads. A
|
||||
# second rendering could drift from the first and the two
|
||||
# would look like the same extraction.
|
||||
with io.open(by_design[design], 'r', encoding='utf-8') as fh:
|
||||
body = fh.read()
|
||||
with io.open(target, 'w', encoding='utf-8') as fh:
|
||||
fh.write(body)
|
||||
copies[design] = target
|
||||
except Exception as ex:
|
||||
copy_problems.append('{0}: {1}'.format(design, ex))
|
||||
specsave.remember_dir(designspec.root_dir(doc),
|
||||
os.path.dirname(chosen))
|
||||
|
||||
if written:
|
||||
output.print_md('## Written')
|
||||
output.print_table(
|
||||
[[w['design'], str(w['notes']),
|
||||
str(len(specsave.unresolved_entries(w['spec']))),
|
||||
os.path.basename(w['path'])] for w in written],
|
||||
columns=['design', 'text notes scraped', 'unresolved notes',
|
||||
'spec file'])
|
||||
|
||||
# THE LINKS ARE `file:///` HREFS. A bare Windows path in a markdown link
|
||||
# is a dead link in this panel -- backslashes and no scheme, so it renders
|
||||
# and navigates nowhere. Same fix as PanelLiftCheck: the visible text
|
||||
# stays the real path, because that is what gets copied into Explorer,
|
||||
# and only the href gets the URL form. `specsave.link` is that pattern.
|
||||
for w in written:
|
||||
line = '- **{0}** {1}'.format(w['design'],
|
||||
specsave.link(w['path']))
|
||||
if w['design'] in copies:
|
||||
line += ' — 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 — {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 `—` 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 += ' — {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** — 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** — '
|
||||
'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.')
|
||||
+12
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+174
@@ -0,0 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Reconcile the issued ticket set against the whole job.
|
||||
#
|
||||
# Every other check on this panel judges one ticket at a time, and no amount of
|
||||
# per-ticket checking can find a ticket that is not there. This button asks the
|
||||
# question about the SET: every piece printed on something, everything printed
|
||||
# about a piece that exists, and the counts reconciling mark by mark.
|
||||
#
|
||||
# It reads. It never writes, and it opens no transaction.
|
||||
#
|
||||
# THE SHAPE OF THE PAGE, AND WHOSE IT IS. Owner, 2026-08-30, now standing
|
||||
# across this tab: the answer is the headline and is never folded; everything
|
||||
# else follows it, collapsed; a section that carries a problem opens itself.
|
||||
# The reader's contract is that if nothing below the headline is open, nothing
|
||||
# needs him.
|
||||
#
|
||||
# Here the answer is the VERDICT IN NUMBERS -- pieces and tickets accounted
|
||||
# for, and a count per finding -- not the findings themselves. On a job with
|
||||
# four hundred uncovered pieces the lists are thousands of lines and the
|
||||
# verdict is invisible inside them. So:
|
||||
#
|
||||
# * the headline is the totals and the finding counts, printed through
|
||||
# pyRevit's own markdown and tables;
|
||||
# * every non-empty finding is a section below it, OPEN, because there is no
|
||||
# finding in this reconciliation that is merely interesting;
|
||||
# * coverage by design, the page-set inventory, the categories swept, the
|
||||
# checks that found nothing and the timings are folded;
|
||||
# * and the two sections that say what this run could NOT see -- out of
|
||||
# scope, and blind spots -- are separate, always, and carry their counts
|
||||
# on their headers with the fold shut. A blind spot that is a REFUSAL
|
||||
# (nothing reconciled) opens itself and is repeated in the headline,
|
||||
# because a refusal reported quietly reads as a clean job.
|
||||
#
|
||||
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED. This output panel is a
|
||||
# `System.Windows.Forms.WebBrowser` -- the IE engine, in IE7 document mode.
|
||||
# `<details>` does not exist there and fails SILENTLY. The fold is an inline
|
||||
# `onclick` plus `style.display`, which is IE5-era DOM; a section with a
|
||||
# problem in it renders open as plain HTML and needs no script at all; and the
|
||||
# whole report, nothing folded and nothing capped, is written to
|
||||
# `last-report.txt` beside the model's other job data on every press, with the
|
||||
# page saying where. A fold that will not open therefore costs a file open,
|
||||
# never a lost sentence. All of that lives in `vibe.foldreport`.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
|
||||
# pyRevit reads it only `if not self.meta`. It is kept in step so that
|
||||
# trimming a bundle file cannot bring an old name back. One line, no
|
||||
# `\n`: this button is a row in a `.pulldown`, and a row has no second
|
||||
# line to put one on.
|
||||
__title__ = 'Job Recon'
|
||||
|
||||
import os
|
||||
|
||||
from pyrevit import revit, script
|
||||
|
||||
from vibe import compat, designspec, knowledge, jobrecon, jobreconscan
|
||||
from vibe import foldreport as fold
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
REQUIRED = ('member.control_number', 'sheet.control_numbers')
|
||||
|
||||
# The cap on each list ON THE PAGE. The file written beside the model has no
|
||||
# cap at all -- a page is read, a file is searched.
|
||||
PAGE_LIMIT = 60
|
||||
|
||||
HOW_TO_MAP = """
|
||||
This reconciliation needs roles the older checks never used. Add them to the
|
||||
plant knowledge file (the layer that describes the office TEMPLATE), naming
|
||||
whatever your title block and your pieces actually call these things:
|
||||
|
||||
parameters:
|
||||
member:
|
||||
control_number: <the piece's control number>
|
||||
ticket_created: <optional -- the model's own 'a ticket exists' flag>
|
||||
ticket_detailed: <optional>
|
||||
sheet:
|
||||
total_required: <the quantity the title block states>
|
||||
sheet_count: <how many pages the ticket says it has>
|
||||
# either list the control-number slots explicitly ...
|
||||
control_numbers: [<slot 1>, <slot 2>, ...]
|
||||
|
||||
nomenclature:
|
||||
# ... or, for numbered slots, match them with one expression:
|
||||
control_slot_re: '^<PREFIX>_\\d+$'
|
||||
|
||||
Until then this button refuses rather than reporting a job it cannot see.
|
||||
"""
|
||||
|
||||
|
||||
def write_full(recon, full_path):
|
||||
"""The whole report, unfolded, beside the model. Returns True if written.
|
||||
|
||||
Written BEFORE anything is printed, so the file exists even if the
|
||||
printing throws. `as_text` first and encode second: under IronPython 2.7
|
||||
the report is a byte string and a mark carrying a non-ASCII character
|
||||
would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
|
||||
"""
|
||||
payload = compat.as_text(
|
||||
'\n'.join(jobrecon.format_report(recon, full_path=full_path)))
|
||||
handle = open(full_path, 'wb')
|
||||
try:
|
||||
handle.write(payload.encode('utf-8'))
|
||||
finally:
|
||||
handle.close()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
for line in problems:
|
||||
output.print_md('**' + line + '**')
|
||||
return
|
||||
|
||||
names = knowledge.param_names(know, 'member.control_number')
|
||||
slots = (knowledge.param_names(know, 'sheet.control_numbers')
|
||||
or knowledge.get(know, 'nomenclature.control_slot_re', None))
|
||||
if not names or not slots:
|
||||
output.print_md('## Job Recon cannot run')
|
||||
output.print_md('Nothing was reconciled, and that is **not** a clean '
|
||||
'result -- it is a refusal.')
|
||||
print(HOW_TO_MAP)
|
||||
return
|
||||
|
||||
recon = jobreconscan.run(doc, know=know)
|
||||
|
||||
full_path = os.path.join(designspec.sub_dir(doc, 'jobrecon'),
|
||||
'last-report.txt')
|
||||
written = False
|
||||
try:
|
||||
written = write_full(recon, full_path)
|
||||
except Exception as exc:
|
||||
output.print_md('> The full report could not be written to disk ({0}). '
|
||||
'Everything is still on this page; use the fold '
|
||||
'headers.'.format(exc))
|
||||
|
||||
sections = jobrecon.render(recon, limit=PAGE_LIMIT,
|
||||
full_path=full_path if written else None)
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded.
|
||||
tail = sections[1:]
|
||||
if not tail:
|
||||
return
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md('**{0} of the {1} section(s) below opened themselves '
|
||||
'— they have something in them that needs you.** '
|
||||
'The rest are folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md('**Nothing below needs you.** Every section is folded; '
|
||||
'click a header to open one.')
|
||||
if written:
|
||||
output.print_md('The whole report, nothing folded and nothing capped, '
|
||||
'is in `{0}`.'.format(full_path))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
|
||||
main()
|
||||
+167
@@ -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>
|
||||
+29
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
+1453
File diff suppressed because it is too large
Load Diff
+8
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
+322
@@ -0,0 +1,322 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Report whether this machine, this Revit and this model can run the QC tools.
|
||||
#
|
||||
# Written for the moment the extension lands on a computer it was not developed
|
||||
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
|
||||
# so a failure names itself instead of arriving as a traceback from four frames
|
||||
# deep in a checking routine.
|
||||
#
|
||||
# Reads nothing but metadata and does not touch the model.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Self Check'
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from pyrevit import revit, script, HOST_APP
|
||||
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
doc = revit.doc
|
||||
rows = []
|
||||
|
||||
|
||||
def report(area, item, state, detail):
|
||||
rows.append([area, item, state, detail])
|
||||
|
||||
|
||||
def probe(area, item, fn, missing='FAIL'):
|
||||
"""Run one probe. An exception is a finding, never a crash."""
|
||||
try:
|
||||
state, detail = fn()
|
||||
report(area, item, state or missing, detail)
|
||||
except Exception as ex:
|
||||
report(area, item, missing, '{0}: {1}'.format(type(ex).__name__, ex))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- host
|
||||
|
||||
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
|
||||
|
||||
|
||||
def _revit():
|
||||
app = HOST_APP.app
|
||||
version = app.VersionNumber
|
||||
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
|
||||
if version == DEVELOPED_ON['revit']:
|
||||
return 'OK', detail
|
||||
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
|
||||
DEVELOPED_ON['revit'])
|
||||
|
||||
|
||||
def _pyrevit():
|
||||
from pyrevit import versionmgr
|
||||
version = versionmgr.get_pyrevit_version().get_formatted()
|
||||
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
|
||||
return state, version
|
||||
|
||||
|
||||
def _engine():
|
||||
detail = sys.version.split('\n')[0]
|
||||
major = sys.version_info[0]
|
||||
if major == 2:
|
||||
return 'OK', detail
|
||||
# The code is written for both, but only Python 2 has been exercised.
|
||||
return 'WARN', detail + ' (only Python 2 has been run in anger)'
|
||||
|
||||
|
||||
def _routes():
|
||||
from pyrevit.userconfig import user_config
|
||||
if not user_config.routes_server:
|
||||
return 'WARN', ('routes server off - out-of-process tools will not '
|
||||
'connect. The buttons themselves do not need it.')
|
||||
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
|
||||
user_config.routes_port)
|
||||
|
||||
|
||||
probe('host', 'Revit', _revit)
|
||||
probe('host', 'pyRevit', _pyrevit)
|
||||
probe('host', 'python engine', _engine)
|
||||
probe('host', 'routes bridge', _routes, missing='WARN')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- library
|
||||
|
||||
def _import_all():
|
||||
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
|
||||
ticketqc, ticketchecks)
|
||||
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
|
||||
'ticketchecks'
|
||||
|
||||
|
||||
def _units():
|
||||
from vibe import ft, mm
|
||||
round_trip = mm(ft(1000.0))
|
||||
if abs(round_trip - 1000.0) > 1e-6:
|
||||
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
|
||||
return 'OK', '1000 mm round-trips exactly'
|
||||
|
||||
|
||||
def _checklist():
|
||||
from vibe import ticketpage
|
||||
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
|
||||
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
|
||||
return 'OK', '{0} row(s) from {1}'.format(
|
||||
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
|
||||
|
||||
|
||||
probe('library', 'modules import', _import_all)
|
||||
probe('library', 'unit conversion', _units)
|
||||
probe('library', 'published checklist', _checklist, missing='WARN')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- model
|
||||
|
||||
def _saved():
|
||||
if not doc.PathName:
|
||||
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
|
||||
return 'OK', doc.PathName
|
||||
|
||||
|
||||
def _data_dir():
|
||||
from vibe import designspec
|
||||
return 'OK', designspec.root_dir(doc)
|
||||
|
||||
|
||||
def _knowledge():
|
||||
"""Every knowledge layer, in the order load() merges them.
|
||||
|
||||
Which layers exist decides what the tool actually believes, so the answer
|
||||
is the list, not a yes/no: naming conventions that should be plant-wide
|
||||
sitting only in a project file is a real and invisible mistake.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
layers = knowledge.sources(doc)
|
||||
detail = ' | '.join(
|
||||
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
|
||||
for label, path, ok in layers])
|
||||
|
||||
# A project config that has gone missing is worse than one never set: the
|
||||
# run looks entirely normal and uses a configuration nobody chose.
|
||||
gone = [path for label, path, ok in layers
|
||||
if label == 'project config' and not ok]
|
||||
if gone:
|
||||
return 'FAIL', ('the project config this model runs on is not there: '
|
||||
'{0} || {1}'.format(gone[0], detail))
|
||||
if not any(ok for _label, _p, ok in layers):
|
||||
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _knowledge_parses():
|
||||
"""Read every layer file that IS there, exactly the way load() reads it.
|
||||
|
||||
The layer list above says which files exist. It does not say whether any
|
||||
of them can be READ, and those are different questions with the same
|
||||
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
|
||||
mapping raises `Duplicate key` and it returns nothing -- and a layer that
|
||||
fails to load looks exactly like a layer that agrees with the defaults.
|
||||
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
|
||||
layer, every parameter role came back None, the sweep enumerated 0
|
||||
assemblies, and every report rendered normally.
|
||||
|
||||
So this re-reads each file through the same reader and prints ok or the
|
||||
parser's own message. `knowledge.config_problems()` reports the same
|
||||
failure from the merge itself; this is the early warning, on the layer
|
||||
list, before a button is pressed. `python tools/knowledgelint.py` is the
|
||||
same check on the host, where the edit is made.
|
||||
"""
|
||||
from pyrevit.coreutils import yaml as pyyaml
|
||||
from vibe import knowledge
|
||||
|
||||
notes = []
|
||||
bad = []
|
||||
for label, path, ok in knowledge.sources(doc):
|
||||
if not ok:
|
||||
notes.append('{0}: absent'.format(label))
|
||||
continue
|
||||
try:
|
||||
pyyaml.load_as_dict(path)
|
||||
notes.append('{0}: ok'.format(label))
|
||||
except Exception as ex:
|
||||
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
|
||||
label, path, type(ex).__name__, ex))
|
||||
if bad:
|
||||
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
|
||||
'was used, and the run fell back to the layers under '
|
||||
'it. Fix the file, then run '
|
||||
'tools/knowledgelint.py to confirm.')
|
||||
if not notes:
|
||||
return 'WARN', 'no knowledge layer to read yet'
|
||||
return 'OK', ' | '.join(notes)
|
||||
|
||||
|
||||
def _specs():
|
||||
from vibe import designspec
|
||||
specs = designspec.list_specs(doc)
|
||||
if not specs:
|
||||
return 'WARN', 'none yet - run Extract Design'
|
||||
unreviewed = [d for d, s in specs.items()
|
||||
if not (s.get('source') or {}).get('reviewed_by')]
|
||||
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
|
||||
if unreviewed:
|
||||
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
|
||||
return 'OK', detail
|
||||
|
||||
|
||||
def _scope():
|
||||
"""What the job declares this engineer is answerable for.
|
||||
|
||||
Never a FAIL. An undeclared scope is a legitimate state -- it means every
|
||||
button judges every piece -- and it is reported as such rather than as a
|
||||
misconfiguration, because 'judge everything' is the honest default and
|
||||
the only wrong answer here would be a scope the tool chose for itself.
|
||||
"""
|
||||
from vibe import knowledge
|
||||
from vibe import scope as scopelib
|
||||
the_scope = scopelib.of(knowledge.load(doc))
|
||||
if not the_scope.is_declared:
|
||||
return 'WARN', ('no scope declared -- every button judges every '
|
||||
'piece on the job, including work whose plant '
|
||||
'knowledge has never been validated. Set '
|
||||
'scope.mark_prefixes in the job knowledge file to '
|
||||
'be told what is being taken on trust.')
|
||||
detail = the_scope.label()
|
||||
if the_scope.declared_by:
|
||||
detail += ' (declared by {0})'.format(the_scope.declared_by)
|
||||
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
|
||||
|
||||
|
||||
probe('model', 'saved', _saved)
|
||||
probe('model', 'declared scope', _scope, missing='WARN')
|
||||
probe('model', 'data folder', _data_dir)
|
||||
probe('model', 'knowledge layers', _knowledge, missing='WARN')
|
||||
probe('model', 'knowledge layers parse', _knowledge_parses)
|
||||
probe('model', 'design specs', _specs, missing='WARN')
|
||||
|
||||
|
||||
# --------------------------------------------------- project conventions
|
||||
# These are the assumptions most likely to be wrong on a different job: they
|
||||
# are naming and parameter conventions, not Revit API.
|
||||
|
||||
def _mapping():
|
||||
"""Which parameter roles this office has told us the names for.
|
||||
|
||||
Nothing in the code knows what a panel's length parameter is called, so
|
||||
an unmapped role is not a detail -- it is a check that will not run. It
|
||||
reports before the convention probes below, because they depend on it.
|
||||
"""
|
||||
from vibe import knowledge, designspec
|
||||
know = knowledge.load(doc)
|
||||
missing, not_applicable = knowledge.unmapped_roles(know)
|
||||
total = len(knowledge.PARAMETER_ROLES)
|
||||
if missing:
|
||||
# The template is the plant's, so that is where the names belong; a
|
||||
# project file may still override one.
|
||||
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
|
||||
len(missing), total, ', '.join(missing),
|
||||
designspec.plant_knowledge_path(),
|
||||
'; {0} declared not applicable: {1}'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
if not_applicable else '')
|
||||
mapped = ['{0}={1}'.format(r.split('.')[-1],
|
||||
knowledge.param_names(know, r)[0])
|
||||
for r in knowledge.PARAMETER_ROLES
|
||||
if knowledge.param_names(know, r)]
|
||||
extra = ''
|
||||
if not_applicable:
|
||||
extra = '; {0} declared not applicable at this plant ({1})'.format(
|
||||
len(not_applicable), ', '.join(sorted(not_applicable)))
|
||||
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
|
||||
|
||||
|
||||
def _designs():
|
||||
from vibe import ticketqc
|
||||
from vibe import knowledge
|
||||
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
|
||||
if not grouped:
|
||||
return 'FAIL', ('no assembly carries a design number - either the '
|
||||
'panel.design_number role is mapped to the wrong '
|
||||
'parameter, or this model does not group pieces so')
|
||||
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
|
||||
return 'OK', '{0} designs, {1} pieces: {2}'.format(
|
||||
len(grouped), sum([len(v) for v in grouped.values()]),
|
||||
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
|
||||
|
||||
|
||||
def _summaries():
|
||||
from vibe import ticketqc
|
||||
summaries, sheets = ticketqc.collect_context(doc)
|
||||
if not summaries:
|
||||
return 'FAIL', ('no drafting view is named after a design number - '
|
||||
'Extract Design has nothing to read')
|
||||
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
|
||||
len(sheets))
|
||||
|
||||
|
||||
probe('conventions', 'parameter role mapping', _mapping)
|
||||
probe('conventions', 'design number groups assemblies', _designs)
|
||||
probe('conventions', 'design summary views', _summaries)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ verdict
|
||||
|
||||
output.print_md('# Self check')
|
||||
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
|
||||
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
|
||||
|
||||
fails = len([r for r in rows if r[2] == 'FAIL'])
|
||||
warns = len([r for r in rows if r[2] == 'WARN'])
|
||||
|
||||
if fails:
|
||||
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
|
||||
output.print_md('A **FAIL** means a QC button will not work here. Start '
|
||||
'with the first one - they cascade.')
|
||||
elif warns:
|
||||
output.print_md('## Ready, with {0} warning(s)'.format(warns))
|
||||
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
|
||||
else:
|
||||
output.print_md('## Everything checks out')
|
||||
+10
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+184
@@ -0,0 +1,184 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# The takeoff, for the whole job: what it contains, per piece and in total.
|
||||
#
|
||||
# The counting lives in `vibe.takeoffcore` (classification and both totals),
|
||||
# `vibe.takeoffcount` (the control-number multiplier) and `vibe.takeoffreport`
|
||||
# (the two renderings), all three Revit-free and unit-tested on host CPython.
|
||||
# `vibe.takeoffgather` is the only part that touches a document. This file is
|
||||
# the button and nothing more.
|
||||
#
|
||||
# READ ONLY. No Transaction is opened here or in anything this calls. A takeoff
|
||||
# reports what a model contains; it has no business changing it.
|
||||
#
|
||||
# WHAT A PRESS COSTS, AND WHY IT DOES NOT REFRESH THE INDEX
|
||||
# -----------------------------------------------------------
|
||||
# It reads the cached reinforcing index (~1.4 s, no geometry, no view) and
|
||||
# makes one cheap pass over the assembly instances for the multiplier (~0.3 s).
|
||||
# It deliberately does NOT rebuild the index. A takeoff off a stale index is a
|
||||
# takeoff of the model as it was, and the report says when the index was built
|
||||
# so a reader who has been editing can see the stamp predates his edits. A
|
||||
# button that silently spends ten seconds rebuilding is a button that sometimes
|
||||
# freezes Revit and never says why; refreshing is Reinforcing Suggestion's job
|
||||
# and it has the progress bar for it.
|
||||
#
|
||||
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
|
||||
# -----------------------------------------------------
|
||||
# 1. Whole job per press, not one piece. A takeoff is a job-level document --
|
||||
# ordering is done once for the job, and a per-piece takeoff is already in
|
||||
# the by-piece CSV this writes.
|
||||
# 2. It multiplies by control number, and prints both figures. Per-mark and
|
||||
# per-job answer different questions and a reader will want to check one
|
||||
# against the other, so neither is hidden behind the other.
|
||||
# 3. The multiplier comes from the control numbers on framing elements, via
|
||||
# the job reconciliation's own gatherer -- not from assembly names, which
|
||||
# were measured through the bridge and found to be 755 unique names for 755
|
||||
# assemblies, i.e. no multiplier at all. An unmapped control-number role is
|
||||
# printed as a refusal at the top of the multiplier section, never as a job
|
||||
# where every mark happens to be one piece.
|
||||
# 4. An empty index is a refusal, not an empty takeoff. A takeoff of nothing
|
||||
# that looks like a takeoff of a job with nothing in it is the worst
|
||||
# possible output, so it says the index has not been built and names the
|
||||
# button that builds it.
|
||||
# 5. The reports go beside the model, never into the repository. They name
|
||||
# marks, part numbers and quantities for one client's job.
|
||||
#
|
||||
# WHAT THIS PAGE LOOKS LIKE, AND WHOSE RULE THAT IS
|
||||
# --------------------------------------------------
|
||||
# The owner's standing rule for the whole tab, and it lives in
|
||||
# `vibe.foldreport` rather than here: the answer is the headline, biggest and
|
||||
# first and never folded; everything else follows FOLDED; a section with a
|
||||
# problem in it opens itself and says on its header why. The contract that
|
||||
# buys is "if nothing below the headline is open, nothing needs me", and it
|
||||
# is worth more than any one section being visible.
|
||||
#
|
||||
# Here the headline is the ORDER -- hardware by part number, reinforcing by
|
||||
# bar mark, both figures on every line, with what multiplied them written
|
||||
# above. Coverage, provenance, the piece-by-piece breakdown and the basis
|
||||
# tallies fold. The refusal does not: an unmapped control-number role means
|
||||
# the model was never asked how many pieces a mark is, every per-job figure
|
||||
# is an undercount, and that renders OPEN as plain HTML.
|
||||
#
|
||||
# WHY THE FOLD IS IE5-ERA DOM AND IS DESIGNED TO FAIL SAFE
|
||||
# ---------------------------------------------------------
|
||||
# This output panel is a `System.Windows.Forms.WebBrowser` -- the IE engine,
|
||||
# in IE7 document mode, since pyRevit sets no FEATURE_BROWSER_EMULATION.
|
||||
# `<details>` does not exist there and fails SILENTLY. So the fold is an
|
||||
# inline `onclick` plus `style.display`, and it has not been exercised inside
|
||||
# Revit by the agent that wrote it. Two things make that survivable:
|
||||
# a section with a problem renders open with no script at all, and the whole
|
||||
# report, nothing folded, is the `.md` written beside the model every press
|
||||
# -- which this page names. A dead toggle costs a file open, never a
|
||||
# sentence.
|
||||
|
||||
# pylint: disable=import-error,invalid-name,broad-except
|
||||
__title__ = 'Takeoff'
|
||||
|
||||
from pyrevit import revit, script, forms
|
||||
|
||||
from vibe import foldreport as fold
|
||||
from vibe import knowledge
|
||||
from vibe import takeoffgather as gather
|
||||
from vibe import takeoffreport as report
|
||||
|
||||
doc = revit.doc
|
||||
output = script.get_output()
|
||||
output.close_others()
|
||||
|
||||
TITLE = 'Takeoff'
|
||||
|
||||
|
||||
def not_judged(headline, message):
|
||||
"""'Could not judge' is a RESULT and it gets printed, not swallowed into
|
||||
a dialog that vanishes. It must never read as 'judged and found
|
||||
nothing'."""
|
||||
output.print_md('# {0}'.format(TITLE))
|
||||
output.print_md('## {0}'.format(headline))
|
||||
output.print_md(message)
|
||||
script.exit()
|
||||
|
||||
|
||||
def main():
|
||||
know = knowledge.load(doc)
|
||||
problems = knowledge.config_problems(doc, know)
|
||||
if problems:
|
||||
forms.alert('\n'.join(problems), title=TITLE, exitscript=True)
|
||||
|
||||
takeoff, counts, extras = gather.run(doc, know=know)
|
||||
|
||||
coverage = takeoff.get('coverage') or {}
|
||||
if not coverage.get('indexed_marks'):
|
||||
not_judged(
|
||||
'The reinforcing index has not been built for this model',
|
||||
'A takeoff is built from that index and there is nothing in it '
|
||||
'yet. This is not a job with no hardware in it -- it is a '
|
||||
'question nobody has asked the model.\n\n'
|
||||
'Run **Reinforcing Suggestion** once; it builds the index. Then '
|
||||
'press this again.')
|
||||
|
||||
sections = report.sections(takeoff, counts, title=TITLE, extras=extras)
|
||||
|
||||
# FOLDED IS NOT GONE, so the unfolded copy is written BEFORE anything is
|
||||
# printed -- the file then exists even if the printing throws, and the
|
||||
# page can name it. `takeoff.md` is that copy: `report.markdown` renders
|
||||
# every section in full, folded or not, so it can never say less than
|
||||
# this page does.
|
||||
paths, write_error = {}, None
|
||||
try:
|
||||
paths = gather.write_reports(doc, takeoff, counts, extras=extras)
|
||||
except Exception as exc:
|
||||
write_error = exc
|
||||
|
||||
# ---- the answer, through pyRevit, big and first.
|
||||
for block in sections[0].blocks:
|
||||
if block[0] == fold.TABLE:
|
||||
output.print_table(block[2], columns=block[1])
|
||||
else:
|
||||
output.print_md(block[1])
|
||||
|
||||
# ---- everything else, folded, and the one line that says whether any of
|
||||
# it needs him. That line IS the contract; without it a page of shut
|
||||
# headers looks the same whether or not something is wrong.
|
||||
tail = sections[1:]
|
||||
if tail:
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(tail)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} section(s) below opened themselves — '
|
||||
'they have something in them that needs you.** The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(tail)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every section is folded; click '
|
||||
'a header to open one.')
|
||||
if paths.get('.md'):
|
||||
output.print_md('The whole takeoff, nothing folded, is in `{0}`.'
|
||||
.format(paths['.md']))
|
||||
output.print_html(fold.html(tail))
|
||||
|
||||
output.print_md('---')
|
||||
if write_error is not None:
|
||||
# The page is still complete, but only while the toggle works -- so
|
||||
# this says which, rather than the old flat report's promise that
|
||||
# what is on the page is all of it.
|
||||
output.print_md(
|
||||
'**The report could not be written to disk:** {0}. Every section '
|
||||
'is still on this page, but the folded ones need the click to '
|
||||
'open; there is no file copy of them this press.'.format(
|
||||
write_error))
|
||||
else:
|
||||
output.print_md('Written beside the model:')
|
||||
for suffix in sorted(paths):
|
||||
output.print_md('- `{0}`'.format(paths[suffix]))
|
||||
|
||||
output.print_md('---')
|
||||
output.print_md(
|
||||
'_Read only. Nothing was written to the model. Index built '
|
||||
'{0}._'.format(coverage.get('built_at') or 'never'))
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
# A `.pulldown` is a large button with a downward arrow: one icon on the
|
||||
# panel, and every item below opens as a row when it is clicked
|
||||
# (`components.PullDownButtonGroup`, drawn by
|
||||
# `uimaker._produce_ui_pulldown`). Panels, pulldowns and pushbuttons each
|
||||
# keep their own name table, so the "Project Config" row inside this button
|
||||
# does not collide with the button itself.
|
||||
#
|
||||
# The pulldown's own label. Unlike a stack, a pulldown container does read a
|
||||
# `title:` -- `GenericUIComponent._read_bundle_metadata` sets `_ui_title`
|
||||
# from it, and `_produce_ui_pulldown` passes that straight to
|
||||
# `create_pulldown_button`.
|
||||
title: Config
|
||||
|
||||
# Row order, top to bottom. Rows are text beside a 16px icon and the
|
||||
# dropdown is as wide as it needs to be, so these titles are free to say
|
||||
# what they mean -- unlike the four large buttons on Tools, which are
|
||||
# rationed to ~9 characters a line.
|
||||
#
|
||||
# Job Recon and Takeoff came back here on 2026-08-30 ("recon 跟 takeoff 加回
|
||||
# 来吧, 放到 Config 里面去") after a day parked outside the tab. As of
|
||||
# 2026-09-02, at the owner's request, they are parked again: pulled out of
|
||||
# `layout:` below so neither renders, with `JobRecon.pushbutton/` and
|
||||
# `Takeoff.pushbutton/` left untouched on disk. Unparking is just re-adding
|
||||
# the two lines -- the owner will decide later how to handle them.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- ProjectConfig
|
||||
- ExtractDesign
|
||||
- SelfCheck
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,10 @@
|
||||
# The panel's name on the ribbon is the FOLDER name ("Setup").
|
||||
#
|
||||
# One pulldown, on purpose. These are the commands you run to get a job
|
||||
# ready or to look at it sideways -- not the four you press all day. They
|
||||
# were taking two stack slots and a whole panel between them; behind one
|
||||
# arrow they take none.
|
||||
#
|
||||
# Anything not listed here is not created. Add a button, add it here.
|
||||
layout:
|
||||
- Config
|
||||
Reference in New Issue
Block a user