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

History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
This commit is contained in:
2026-09-09 08:32:15 -05:00
commit 8e6ee31596
406 changed files with 223435 additions and 0 deletions
@@ -0,0 +1,12 @@
title: Beam Analysis (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside ConnectionAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Beam Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside ConnectionAnalysis, on the owner's own
# instruction to put a button here for it. Same posture as GravityLoadChase
# and LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not what a "beam" is in this model's terms,
# not which code governs, not read-only or not. None of that is guessed
# here; this button exists so there is somewhere to build it once it is.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Beam Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Beam Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Beam Analysis', exitscript=True)
@@ -0,0 +1,12 @@
title: Connection Analysis (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration -- see GravityLoadChase. Added 2026-09-05 on the
# owner's own instruction, alongside BeamAnalysis; nothing behind this
# button is built yet, see script.py.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
#
# Connection Analysis -- PLACEHOLDER. Nothing is implemented.
#
# Added 2026-09-05 alongside BeamAnalysis, on the owner's own instruction to
# put a button here for it. Same posture as GravityLoadChase and
# LateralExtract: a row that opens is still a row somebody clicks, so it
# says plainly that nothing is built rather than staying silent about it.
#
# No scope has been settled -- not which connections, not which code
# governs, not read-only or not. None of that is guessed here; this button
# exists so there is somewhere to build it once it is.
# pylint: disable=import-error,invalid-name,broad-except
# `__title__` mirrors bundle.yaml's `title:` and is otherwise dead --
# pyRevit reads it only `if not self.meta`. It is kept in step so that
# trimming a bundle file cannot bring an old name back. One line, no
# `\n`: this button is a row in a `.pulldown`, and a row has no second
# line to put one on.
__title__ = 'Connection Analysis (WIP)'
from pyrevit import forms
forms.alert(
'Connection Analysis is not built yet.\n\n'
'This row exists so there is somewhere on the ribbon for it. Nothing '
'has been read from the model and nothing has been calculated.',
title='Connection Analysis', exitscript=True)
@@ -0,0 +1,14 @@
title: Gravity (WIP)
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# (WIP) is not decoration. This tool is buttons-and-docs only until the owner
# provides worked examples; the suffix is what stops it being mistaken for a
# finished tool by anyone who opens the DesignHub dropdown. It sits on the
# title rather than the second line now because a pulldown row has no second
# line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

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

After

Width:  |  Height:  |  Size: 3.9 KiB

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

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,27 @@
title: Panel Lift Check
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to `liftexport` -- the Revit half of the
# panellift boundary -- is visible without a Revit restart. It trades press
# latency (engine construction instead of engine reuse) for restart-freedom.
# The CPython half already needs no restart; this is for the half that does.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 from Tools.panel, where it was one of four large icons
# (see Tools.panel/bundle.yaml). One line, no `\n`: this button is now a row
# in DesignHub's `.pulldown`, and a pulldown row renders as text beside a
# 16px icon -- a `\n` has nowhere to go and the dropdown grows to fit the
# longest row, so the title is free to say what it means. No (WIP): moving
# panels did not change what this tool does, and it is not half-built.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

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

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,16 @@
# The panel's name on the ribbon is the FOLDER name ("Design").
#
# THIS PANEL IS THE QUARANTINE, and the pulldown is its door. The owner's
# standing instruction is that unfinished tools must never sit on the same
# panel as the polished ones; gravity-load chasing, lateral extraction, beam
# analysis and connection analysis are buttons-and-docs only until he
# provides worked examples, so they live one click down, behind DesignHub,
# on a panel of their own. Panel Analysis and Panel Lift Check share that
# same door for a different reason (2026-09-05 for Panel Lift Check) --
# both actually run, but the owner wants them grouped with the tools that
# do not rather than back out on Tools.panel; see DesignHub's own bundle
# comment.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- DesignHub
@@ -0,0 +1,13 @@
title: GOD_MOD
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Two lines, short ones. A large ribbon button is as wide as its widest line
# of text, so a long title makes one button dwarf its neighbours -- which is
# exactly what the owner objected to. Keep every line under ~9 characters.
#
# The name is the owner's, verbatim and with the underscore, from
# 2026-08-30: "我不喜欢 claude button. 还是叫 GOD_MOD." The folder is GodMod
# because a pyRevit component name is a C# class name in the generated
# assembly; the title is what anybody actually reads.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
#
# Starts a detached terminal running the `claude` CLI with its working
# directory set to the repository root, so the session comes up on CLAUDE.md
# and tools/rvt.py -- which is what wires it to THIS running Revit.
#
# The terminal is a separate process. Nothing runs on Revit's UI thread, so a
# long agent session cannot freeze or crash Revit, and this button touches the
# model in no way at all: it opens no Transaction, reads no element, and does
# not even import `vibe.helpers`' document handle.
#
# PLAIN `claude`, DEFAULT PERMISSIONS. Standing ruling: this launch must never
# pass --dangerously-skip-permissions, --permission-mode bypassPermissions, or
# any other auto-approve flag. The agent is being pointed at a live Revit
# session and the owner's real model; every tool call it makes is worth a
# human seeing first. If a future edit "just needs" the prompts gone, that is
# a conversation with the owner, not a flag.
#
# Interactive, so this deliberately does NOT use the redirect-both-streams
# runner from vibe.liftexport.run -- that pattern exists to capture a headless
# run without deadlocking on a pipe, and redirecting stdout here would leave
# the CLI with no terminal to draw on. What is reused is the other half of the
# idiom: a generated .cmd does the cd and holds the window open, so nothing
# has to nest quotes inside a wt.exe command line.
#
# WHAT THIS REPLACED: the button used to run tools/dsh-here.cmd, which starts
# `dsh web` (the DeepSeek Harness browser UI) in the same folder. That was the
# whole of its behaviour -- a different agent CLI in the same slot -- so it is
# gone rather than preserved. tools/dsh-here.cmd is left on disk, now
# unreferenced, for the owner to keep or bin.
#
# This button was called GOD_MOD, then `Claude` for the 2026-08-30 ribbon
# rebuild, and is GOD_MOD again after the 2026-08-30 one -- the owner's own
# word, underscore and all. Same button, same folder contents throughout; the
# folder is `GodMod.pushbutton` because a component name becomes a C# class
# name in the generated assembly.
#
# `__title__` is dead weight while `bundle.yaml` exists -- pyRevit reads it
# only `if not self.meta` -- and it is kept in step anyway, so that trimming a
# bundle file cannot resurrect a name nobody uses.
__title__ = 'GOD_MOD'
import io
import os
import tempfile
from System.Diagnostics import Process, ProcessStartInfo
from pyrevit import forms, script
TITLE = 'Claude'
MARKER = 'CLAUDE.md' # the project instructions the session reads
def repo_root():
"""The checkout to start the session in, and whether it looks like one.
This script lives at
<repo>/WillTools.extension/<tab>/<panel>/<button>/script.py
so the repo is four levels above the button. Deriving it means the
checkout can sit anywhere; ~/pyrevit-dev stays as the fallback for the
case where pyRevit does not hand us __file__.
Returns (folder, found_marker). A folder without CLAUDE.md still gets the
session -- it is only missing the project instructions -- but the report
says so rather than letting the agent start somewhere surprising in
silence.
"""
home = os.path.join(os.path.expanduser('~'), 'pyrevit-dev')
try:
root = os.path.dirname(os.path.abspath(__file__))
except NameError:
return home, os.path.isfile(os.path.join(home, MARKER))
for _ in range(4):
root = os.path.dirname(root)
if os.path.isfile(os.path.join(root, MARKER)):
return root, True
if os.path.isfile(os.path.join(home, MARKER)):
return home, True
return root, False
def find_cli():
"""Absolute path to the Claude Code CLI, or None.
`vibe.aiextract.find_claude` already knows the four places a per-user
install lands and honours CLAUDE_CODE_EXE; two copies of that list would
drift. Imported here rather than at module scope so a broken library
still leaves this button able to say what went wrong.
"""
from vibe.aiextract import find_claude
return find_claude()
def write_runner(claude_exe, workdir):
"""A one-shot .cmd that cds, runs claude, and keeps the window up.
It goes in %TEMP%, never in the repo and never beside the model: it holds
an absolute path to somebody's install, which is exactly what the scrub
gate exists to keep out of the tree. Named per Revit process, because
cmd.exe re-reads a batch file as it runs it and two Revits rewriting one
file under each other is a class of bug not worth owning.
"""
folder = os.path.join(tempfile.gettempdir(), 'WillTools')
if not os.path.isdir(folder):
os.makedirs(folder)
runner = os.path.join(folder,
'god-mod-claude-{0}.cmd'.format(os.getpid()))
# newline='' or the CRLFs below get translated again and every line ends
# \r\r\n. cmd.exe survives that, but a batch file nobody can read in a
# text editor is a bad place to be debugging a launch from.
with io.open(runner, 'w', encoding='utf-8', newline='') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'title Claude Code - Revit\r\n')
fh.write(u'cd /d "{0}"\r\n'.format(workdir))
fh.write(u'echo Revit dev folder: %CD%\r\n')
fh.write(u'echo.\r\n')
fh.write(u'"{0}"\r\n'.format(claude_exe))
fh.write(u'echo.\r\n')
fh.write(u'echo [claude exited - this window stays open]\r\n')
fh.write(u'pause >nul\r\n')
return runner
def launch(runner, workdir):
"""Start the runner detached. Windows Terminal when it is there."""
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = '-d "{0}" cmd.exe /c "{1}"'.format(workdir, runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.WorkingDirectory = workdir
psi.UseShellExecute = True
return Process.Start(psi)
WORKDIR, HAS_MARKER = repo_root()
try:
CLAUDE = find_cli()
except Exception as ex:
forms.alert('Could not look up the Claude Code CLI:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
if not CLAUDE:
forms.alert(
'Claude Code CLI not found.\n\n'
'Install it, or set the CLAUDE_CODE_EXE environment variable to the '
'full path of claude.cmd / claude.exe.\n\n'
'Looked in the usual per-user install folders and on PATH.',
title=TITLE, exitscript=True)
try:
RUNNER = write_runner(CLAUDE, WORKDIR)
launch(RUNNER, WORKDIR)
except Exception as ex:
forms.alert('Could not start Claude Code:\n\n{0}: {1}'.format(
type(ex).__name__, ex), title=TITLE, exitscript=True)
output = script.get_output()
output.print_md('### Claude Code is starting')
output.print_md('Working folder: `{0}`'.format(WORKDIR))
output.print_md('CLI: `{0}`'.format(CLAUDE))
if not HAS_MARKER:
output.print_md(
'**No `{0}` in that folder** - the session will start without the '
'project instructions, and without them it does not know about the '
'bridge to this Revit.'.format(MARKER))
output.print_md(
'It runs with **default permissions**, so it asks before it acts. '
'It can read and drive **this** Revit session through `tools/rvt.py` - '
'ask it things like _"how many walls are on level 2?"_ or _"build me a '
'button that renumbers the selected doors"_.')
@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Lab").
#
# One button, and it is not a work in progress -- GOD_MOD works. It sits
# alone at the right-hand end because it is the escape hatch rather than one
# of the four tools, and an escape hatch you can find without looking is
# worth a panel divider.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- GodMod
@@ -0,0 +1,8 @@
title: Design Extract
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

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

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
#
# Hot Reload -- stop the WillTools routes bridge cleanly, THEN reload pyRevit.
#
# Why the stop must come first: the routes HTTP dispatch is wired to a module-
# global external event handler created once, on the host main thread, when
# `pyrevit/routes/server/server.py` is first imported (server.py:35-36), and it
# is NOT recreated across a reload -- pyRevit re-imports extension scripts, not
# its own lib. A request in flight when the engine is torn down runs its handler
# against a dead engine -> unhandled exception on a non-main thread -> Revit dies
# uncatchably (measured 2026-08-14; see startup.py and CLAUDE.md "Never reload").
# Stopping the HTTP server first closes that crash window. On reload, startup.py
# re-registers the routes and a fresh server comes back up automatically.
#
# If the server will NOT stop cleanly, this ABORTS rather than reloads -- a
# reload with a live bridge is the exact crash, so a bad stop must never fall
# through to it.
#
# IronPython 2.7: no f-strings. No module docstring / tooltip / author
# (test_button_tooltips.py).
# pylint: disable=import-error,invalid-name,broad-except
from pyrevit import EXEC_PARAMS
from pyrevit import routes
from pyrevit import forms
from pyrevit import script
from pyrevit.loader import sessionmgr
from pyrevit.loader import sessioninfo
logger = script.get_logger()
go = True
if EXEC_PARAMS.executed_from_ui:
go = forms.alert(
'Hot Reload stops the WillTools bridge, then reloads pyRevit.\n\n'
'Run this only when no bridge request is in flight -- no sweep, no '
'agent mid-exec. A request raised the instant before this click cannot '
'be cancelled and can still take Revit down.\n\nReload now?',
ok=False, yes=True, no=True)
if go:
stopped_clean = True
try:
routes.deactivate_server()
except Exception as ex:
stopped_clean = False
logger.error('deactivate_server raised: {0}'.format(ex))
# Verify the socket is actually gone before tearing the engine down. A
# deactivate that "succeeded" but left the server up is still unsafe.
try:
if routes.get_active_server() is not None:
stopped_clean = False
except Exception as ex:
stopped_clean = False
logger.error('get_active_server raised: {0}'.format(ex))
if not stopped_clean:
forms.alert(
'Could not stop the WillTools bridge cleanly.\n\n'
'Do NOT reload with a live bridge -- restart Revit instead of '
'reloading. (A reload while the routes handler is live is what '
'crashes Revit.)', ok=True)
else:
# Vouch to tools/rvt.py that THIS reload is a clean stop-first one, so
# the reloaded bridge is not refused as a raw-reload landmine. startup.py
# consumes this marker on the very next load and stamps that session
# 'hotreload'. Keyed by pid: a reload is in-process, so the same Revit
# picks it up and no other session can. Best-effort -- if it cannot be
# written the only cost is rvt.py refusing the reloaded bridge (the safe
# default), never a failed reload.
try:
import os as _os
from System.Diagnostics import Process as _Process
_here = _os.path.abspath(__file__).replace('\\', '/')
_mark = _here.find('/WillTools.extension/')
if _mark != -1:
_root = _here[:_mark]
else:
_root = _os.path.join(_os.path.expanduser('~'), 'pyrevit-dev')
_pid = _Process.GetCurrentProcess().Id
_flagpath = _os.path.join(
_root, '.bridge-hotreload-{0}.flag'.format(_pid))
_fh = open(_flagpath, 'w')
_fh.write('hotreload\n')
_fh.close()
except Exception as ex:
logger.warning('could not write hot-reload marker: {0}'.format(ex))
logger.info('WillTools bridge stopped. Reloading pyRevit...')
sessionmgr.reload_pyrevit()
# Mirrors the shipped Reload button: record the new session id.
script.get_results().newsession = sessioninfo.get_session_uuid()
@@ -0,0 +1,12 @@
title: Job Recon
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 after a day in `parked/`. Nothing about
# the tool changed while it was out: only its ribbon entry was ever parked,
# and `vibe.jobrecon` / `vibe.jobreconscan` and their tests never moved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

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

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,8 @@
title: Self Check
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
#
# Report whether this machine, this Revit and this model can run the QC tools.
#
# Written for the moment the extension lands on a computer it was not developed
# on. Everything the tools assume gets probed and reported as OK / WARN / FAIL,
# so a failure names itself instead of arriving as a traceback from four frames
# deep in a checking routine.
#
# Reads nothing but metadata and does not touch the model.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Self Check'
import os
import sys
import traceback
from pyrevit import revit, script, HOST_APP
from vibe import runlog
output = script.get_output()
output.close_others()
doc = revit.doc
rows = []
def report(area, item, state, detail):
rows.append([area, item, state, detail])
def probe(area, item, fn, missing='FAIL'):
"""Run one probe. An exception is a finding, never a crash."""
try:
state, detail = fn()
report(area, item, state or missing, detail)
except Exception as ex:
# Name the frame that blew, not just the exception: a bare
# "SystemError: Object reference not set to an instance of an object"
# (IronPython's face for a .NET NullReferenceException) says nothing
# about WHERE, and 2026-09-05 one of these cost a round trip to the
# owner's machine to find out. Innermost frame of our own code first.
where = ''
try:
import traceback
frames = traceback.extract_tb(sys.exc_info()[2])
ours = [f for f in frames if 'WillTools' in (f[0] or '')] or frames
if ours:
fn, ln, func, _ = ours[-1]
where = ' @ {0}:{1} in {2}()'.format(fn.replace('\\', '/').split('/')[-1], ln, func)
except Exception:
pass
report(area, item, missing, '{0}: {1}{2}'.format(type(ex).__name__, ex, where))
# ------------------------------------------------------------------- host
DEVELOPED_ON = {'revit': '2025', 'pyrevit': '6.5.3', 'python': '2.7.12'}
def _revit():
app = HOST_APP.app
version = app.VersionNumber
detail = '{0} build {1}'.format(app.VersionName, app.VersionBuild)
if version == DEVELOPED_ON['revit']:
return 'OK', detail
return 'WARN', detail + ' (developed on {0} - see INSTALL.md)'.format(
DEVELOPED_ON['revit'])
def _pyrevit():
from pyrevit import versionmgr
version = versionmgr.get_pyrevit_version().get_formatted()
state = 'OK' if version.startswith(DEVELOPED_ON['pyrevit'][:1]) else 'WARN'
return state, version
def _engine():
detail = sys.version.split('\n')[0]
major = sys.version_info[0]
if major == 2:
return 'OK', detail
# The code is written for both, but only Python 2 has been exercised.
return 'WARN', detail + ' (only Python 2 has been run in anger)'
def _routes():
from pyrevit.userconfig import user_config
if not user_config.routes_server:
return 'WARN', ('routes server off - the Claude Code bridge will not '
'connect. The buttons themselves do not need it.')
return 'OK', 'enabled on {0}:{1}'.format(user_config.routes_host,
user_config.routes_port)
probe('host', 'Revit', _revit)
probe('host', 'pyRevit', _pyrevit)
probe('host', 'python engine', _engine)
probe('host', 'routes bridge', _routes, missing='WARN')
# ---------------------------------------------------------------- library
def _import_all():
from vibe import (helpers, compat, designspec, knowledge, # noqa: F401
ticketqc, ticketchecks, aiextract)
return 'OK', 'helpers, compat, designspec, knowledge, ticketqc, ' \
'ticketchecks, aiextract'
def _units():
from vibe import ft, mm
round_trip = mm(ft(1000.0))
if abs(round_trip - 1000.0) > 1e-6:
return 'FAIL', 'mm -> ft -> mm gave {0}'.format(round_trip)
return 'OK', '1000 mm round-trips exactly'
def _checklist():
from vibe import ticketpage
if ticketpage.CHECKLIST_UNAVAILABLE_REASON:
return 'WARN', ticketpage.CHECKLIST_UNAVAILABLE_REASON
return 'OK', '{0} row(s) from {1}'.format(
len(ticketpage.CHECKLIST_ITEMS), ticketpage.master_path())
probe('library', 'modules import', _import_all)
probe('library', 'unit conversion', _units)
probe('library', 'published checklist', _checklist, missing='WARN')
# ----------------------------------------------------------------- tooling
def _claude():
from vibe import aiextract
found = aiextract.find_claude()
if not found:
return 'WARN', ('not found - Extract Design cannot run. Install the '
'CLI or set CLAUDE_CODE_EXE. Ticket QC is unaffected.')
return 'OK', found
probe('tooling', 'Claude Code CLI', _claude, missing='WARN')
# ------------------------------------------------------------------- model
def _saved():
if not doc.PathName:
return 'FAIL', 'model has never been saved - QC data has nowhere to live'
return 'OK', doc.PathName
def _data_dir():
from vibe import designspec
return 'OK', designspec.root_dir(doc)
def _knowledge():
"""Every knowledge layer, in the order load() merges them.
Which layers exist decides what the tool actually believes, so the answer
is the list, not a yes/no: naming conventions that should be plant-wide
sitting only in a project file is a real and invisible mistake.
"""
from vibe import knowledge
layers = knowledge.sources(doc)
detail = ' | '.join(
['{0}: {1} [{2}]'.format(label, path, 'found' if ok else 'ABSENT')
for label, path, ok in layers])
# A project config that has gone missing is worse than one never set: the
# run looks entirely normal and uses a configuration nobody chose.
gone = [path for label, path, ok in layers
if label == 'project config' and not ok]
if gone:
return 'FAIL', ('the project config this model runs on is not there: '
'{0} || {1}'.format(gone[0], detail))
if not any(ok for _label, _p, ok in layers):
return 'WARN', 'no knowledge file yet - Extract Design drops starters'
return 'OK', detail
def _knowledge_parses():
"""Read every layer file that IS there, exactly the way load() reads it.
The layer list above says which files exist. It does not say whether any
of them can be READ, and those are different questions with the same
appearance: pyRevit's YAML reader is strict -- a key stated twice in one
mapping raises `Duplicate key` and it returns nothing -- and a layer that
fails to load looks exactly like a layer that agrees with the defaults.
Measured 2026-09-01: one nested duplicate discarded a whole 255KB plant
layer, every parameter role came back None, the sweep enumerated 0
assemblies, and every report rendered normally.
So this re-reads each file through the same reader and prints ok or the
parser's own message. `knowledge.config_problems()` reports the same
failure from the merge itself; this is the early warning, on the layer
list, before a button is pressed. `python tools/knowledgelint.py` is the
same check on the host, where the edit is made.
"""
from pyrevit.coreutils import yaml as pyyaml
from vibe import knowledge
notes = []
bad = []
for label, path, ok in knowledge.sources(doc):
if not ok:
notes.append('{0}: absent'.format(label))
continue
try:
pyyaml.load_as_dict(path)
notes.append('{0}: ok'.format(label))
except Exception as ex:
bad.append('{0} ({1}) WILL NOT PARSE -- {2}: {3}'.format(
label, path, type(ex).__name__, ex))
if bad:
return 'FAIL', (' || '.join(bad) + ' || nothing in that file '
'was used, and the run fell back to the layers under '
'it. Fix the file, then run '
'tools/knowledgelint.py to confirm.')
if not notes:
return 'WARN', 'no knowledge layer to read yet'
return 'OK', ' | '.join(notes)
def _specs():
from vibe import designspec
specs = designspec.list_specs(doc)
if not specs:
return 'WARN', 'none yet - run Extract Design'
unreviewed = [d for d, s in specs.items()
if not (s.get('source') or {}).get('reviewed_by')]
detail = '{0} on disk: {1}'.format(len(specs), ', '.join(sorted(specs)))
if unreviewed:
return 'WARN', detail + ' ({0} not reviewed)'.format(len(unreviewed))
return 'OK', detail
def _scope():
"""What the job declares this engineer is answerable for.
Never a FAIL. An undeclared scope is a legitimate state -- it means every
button judges every piece -- and it is reported as such rather than as a
misconfiguration, because 'judge everything' is the honest default and
the only wrong answer here would be a scope the tool chose for itself.
"""
from vibe import knowledge
from vibe import scope as scopelib
the_scope = scopelib.of(knowledge.load(doc))
if not the_scope.is_declared:
return 'WARN', ('no scope declared -- every button judges every '
'piece on the job, including work whose plant '
'knowledge has never been validated. Set '
'scope.mark_prefixes in the job knowledge file to '
'be told what is being taken on trust.')
detail = the_scope.label()
if the_scope.declared_by:
detail += ' (declared by {0})'.format(the_scope.declared_by)
return 'OK', detail + ' -- everything else is reported NOT JUDGED'
probe('model', 'saved', _saved)
probe('model', 'declared scope', _scope, missing='WARN')
probe('model', 'data folder', _data_dir)
probe('model', 'knowledge layers', _knowledge, missing='WARN')
probe('model', 'knowledge layers parse', _knowledge_parses)
probe('model', 'design specs', _specs, missing='WARN')
# --------------------------------------------------- project conventions
# These are the assumptions most likely to be wrong on a different job: they
# are naming and parameter conventions, not Revit API.
def _mapping():
"""Which parameter roles this office has told us the names for.
Nothing in the code knows what a panel's length parameter is called, so
an unmapped role is not a detail -- it is a check that will not run. It
reports before the convention probes below, because they depend on it.
"""
from vibe import knowledge, designspec
know = knowledge.load(doc)
missing, not_applicable = knowledge.unmapped_roles(know)
total = len(knowledge.PARAMETER_ROLES)
if missing:
# The template is the plant's, so that is where the names belong; a
# project file may still override one.
return 'FAIL', '{0} of {1} roles unmapped: {2} -- edit "parameters:" in {3}{4}'.format(
len(missing), total, ', '.join(missing),
knowledge.plant_file_in_use(know),
'; {0} declared not applicable: {1}'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
if not_applicable else '')
mapped = ['{0}={1}'.format(r.split('.')[-1],
knowledge.param_names(know, r)[0])
for r in knowledge.PARAMETER_ROLES
if knowledge.param_names(know, r)]
extra = ''
if not_applicable:
extra = '; {0} declared not applicable at this plant ({1})'.format(
len(not_applicable), ', '.join(sorted(not_applicable)))
return 'OK', '{0} roles mapped{1}'.format(len(mapped), extra)
def _plant_gaps():
"""Plant-owned data the buttons need, checked at setup time.
The per-button refusals gate on `handling.lifter_groups`,
`handling.stages` and friends, so a gap is normally found by the user
as a per-piece NOT RUN. This probe runs the same gate off the merged
tree and says which plant file to edit, so the developer finds it here
instead -- see knowledge.plant_layer_gaps for the wording.
"""
from vibe import knowledge
know = knowledge.load(doc)
gaps = knowledge.plant_layer_gaps(know)
if not gaps:
return 'OK', 'plant layer names everything the buttons need: {0}'.format(
knowledge.plant_file_in_use(know))
texts = [g['text'] for g in gaps]
state = 'FAIL' if any(g['severity'] == 'FAIL' for g in gaps) else 'WARN'
return state, '{0} gap(s): {1}'.format(len(gaps), ' || '.join(texts))
def _designs():
from vibe import ticketqc
from vibe import knowledge
grouped = ticketqc.assemblies_by_design(doc, know=knowledge.load(doc))
if not grouped:
# Two states that used to share one FAIL (issue #20). A model with
# NO assemblies at all is a job that has not reached the reinforcing
# stage -- owner, 2026-09-05: "还没进入reinforce阶段,所以就是没有
# assembly。这个情况下这玩意应该是warn不是fail" -- so that is a WARN.
# Assemblies present but none carrying a design number is the
# convention failure this probe exists for.
from Autodesk.Revit.DB import FilteredElementCollector, AssemblyInstance
n_asm = FilteredElementCollector(doc).OfClass(AssemblyInstance).GetElementCount()
if n_asm == 0:
return 'WARN', ('no assemblies in this model yet - the job has not '
'reached the reinforcing stage; the design-number '
'grouping is checked once assemblies exist')
return 'FAIL', ('{0} assemblies, none carries a design number - either '
'the panel.design_number role is mapped to the wrong '
'parameter, or this model does not group pieces so'
.format(n_asm))
top = sorted(grouped.items(), key=lambda kv: -len(kv[1]))[:6]
return 'OK', '{0} designs, {1} pieces: {2}'.format(
len(grouped), sum([len(v) for v in grouped.values()]),
', '.join(['{0} x{1}'.format(d, len(v)) for d, v in top]))
def _summaries():
from vibe import ticketqc
summaries, sheets = ticketqc.collect_context(doc)
if not summaries:
return 'FAIL', ('no drafting view is named after a design number - '
'Extract Design has nothing to read')
return 'OK', '{0} summary views, {1} sheets'.format(len(summaries),
len(sheets))
probe('conventions', 'parameter role mapping', _mapping)
probe('conventions', 'plant layer completeness', _plant_gaps)
probe('conventions', 'design number groups assemblies', _designs)
probe('conventions', 'design summary views', _summaries)
# ------------------------------------------------------------------ verdict
#
# The whole report body is wrapped so an unexpected exception still leaves a
# run-log record (outcome='error') before pyRevit shows its own error dialog.
try:
output.print_md('# Self check')
output.print_md('Model **{0}**'.format(doc.Title or '(unsaved)'))
output.print_table(rows, columns=['area', 'check', 'state', 'detail'])
fails = len([r for r in rows if r[2] == 'FAIL'])
warns = len([r for r in rows if r[2] == 'WARN'])
if fails:
output.print_md('## {0} FAIL, {1} WARN'.format(fails, warns))
output.print_md('A **FAIL** means a QC button will not work here. Start '
'with the first one - they cascade.')
elif warns:
output.print_md('## Ready, with {0} warning(s)'.format(warns))
output.print_md('Nothing blocking. Read the warnings before trusting a run.')
else:
output.print_md('## Everything checks out')
outcome = 'ok' if not fails else 'fail'
runlog.record(
'Self Check', doc_title=doc.Title,
outcome=outcome,
report_text='\n'.join(
['{0} | {1} | {2} | {3}'.format(*row) for row in rows]))
except Exception as exc:
runlog.record('Self Check',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(exc),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,10 @@
title: Takeoff
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means.
#
# Back on the ribbon on 2026-08-30 -- see JobRecon, same story.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

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

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,10 @@
# The panel's name on the ribbon is the FOLDER name ("Setup").
#
# One pulldown, on purpose. These are the commands you run to get a job
# ready or to look at it sideways -- not the four you press all day. They
# were taking two stack slots and a whole panel between them; behind one
# arrow they take none.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- Config
@@ -0,0 +1,11 @@
title: Reinforce Suggestion
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. A pulldown row renders as text beside a
# 16px icon and the dropdown grows to fit the longest row, so the title is
# free to say what it means, unlike a large button rationed to ~9 characters
# a line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

@@ -0,0 +1,459 @@
# -*- coding: utf-8 -*-
#
# Reinforcing suggestion for one assembly: which already-reinforced panels,
# between them, account for every feature of this one.
#
# Owner's framing (QNA/reinforcing-suggestion-brief.md): this is a jigsaw, not
# a similarity ranking. Every detail of the target has to be covered by
# something, and the number of donors should be as small as possible. The
# search lives in `vibe.reinforcematch`, the candidate index in
# `vibe.reinforceindex` / `vibe.reinforcecore`, the join between them in
# `vibe.reinforcebind`, and the wording in `vibe.reinforcereport`. This file is
# the Revit end and nothing more: it resolves the selection, decides how
# current the index has to be, and prints.
#
# READ ONLY, AND THAT IS THE POINT
# --------------------------------
# No Transaction is opened anywhere in this file or anything it calls. Nothing
# is written to the model -- not a parameter, not a rebar, not a copy of a
# donor's cage. The tool proposes; a human decides and a human draws it. This
# is the single most important constraint on the feature, because a suggestion
# tool is exactly the thing somebody will ask to "just copy it in for me", and
# the middle band of a wall panel's vertical reinforcing is explicitly PER
# DESIGN -- an engineer's calculation for that one piece. Carrying it across
# automatically would look like an ordinary suggestion and would be the most
# dangerous thing this feature could do.
#
# WHERE THE REFRESH DECISION LIVES, AND WHY IT IS HERE
# ----------------------------------------------------
# In this file, deliberately. `reinforceindex` offers mechanism -- `read()`,
# `refresh()`, `refresh(rebuild_all=True)`, `coverage()` -- and takes no view
# on which a button press deserves. Which one to spend is a UI policy question
# (how many seconds may a click cost, and what does a modifier key mean), so it
# belongs at the UI, in one readable place, not buried in the library where two
# callers would end up disagreeing.
#
# The policy, with the measured numbers it rests on:
#
# * ordinary press -- the cheap signature pass over every assembly (~0.7 s,
# no geometry, no view), then a rebuild of ONLY the pieces whose signature
# moved. Nothing moved is the common case and then nothing is rebuilt and
# nothing is rewritten: about a second. A full rebuild on every press is
# 10 s of frozen UI to answer a question that usually has not changed.
# * cold or empty index -- a full build regardless, ~10 s, with a progress
# bar. There is no cheaper honest answer available.
# * SHIFT-CLICK -- `rebuild_all=True` plus a re-run of the outline audit.
#
# KNOWN LIMITATION, AND WHY SHIFT-CLICK EXISTS AT ALL
# ---------------------------------------------------
# MASTER is a tie-break, never a filter. A donor typed MASTER in the model has
# been REVIEWED rather than merely reinforced, and the ranking prefers it -- but
# only between donor sets the geometry cannot separate. Owner's ruling: fit
# wins. The report marks it and says so.
#
# The staleness signature is `reinforced | on_hold | master | member-count |
# checksum(member ids)`. It catches a member added, removed or swapped, a piece
# newly marked reinforced, and a piece put on or taken off hold. It does NOT
# catch a geometry edit that leaves the member set alone -- an opening moved or
# resized in place, a panel stretched. Those pieces serve a stale measurement
# until something else about them moves.
#
# So an ordinary press can be wrong in exactly that one way, and the report
# says so every time, in `reinforcereport.staleness_note`, along with the
# instruction to shift-click. The report also always prints when the index was
# built, so a reader who knows he has been editing geometry can tell whether
# the stamp predates his edits. Shift-click is cheap because a full rebuild of
# this model's 690 eligible pieces measured 6-10 s: the escape hatch is an
# inconvenience, not a last resort.
#
# JUDGEMENT CALLS MADE HERE, for the owner to overrule
# -----------------------------------------------------
# 1. One target per press. The engineer is reinforcing one piece; a batch of
# suggestions is a report nobody reads. Several assemblies preselected asks
# which one rather than fanning out.
# 2. Shift-click is the force-rebuild gesture. It is invisible unless you are
# told, so it is in the footer of every report and in the staleness note
# the report prints when the index may be out of date. It used to be in the
# hover tooltip too; the owner had every tooltip stripped (2026-08-30), so
# the report is now the only place that says it. A dialog
# on every press would be worse: the answer is the same 99 times out of 100.
# 3. The outline audit runs automatically the first time only. It costs one
# geometry read per indexed piece, and its answer changes only when the
# model does. Never-audited and audited-and-found-none are different
# answers, so the report says which one it has.
# 4. At most FOUR donors in one proposal (owner's ruling, 2026-08-30). When
# four cannot account for everything, the report prints the best four and
# names what they miss -- it does not relax to a fifth. That overflow rule
# is INFERRED from the ruling, not stated by the owner, and is the thing to
# overrule first if this behaves wrongly on a real piece.
# 5. Every measurement printed is in INCHES to two decimals, as ruled. The
# ranking is quantized to 1/8 in so the two decimals cannot be mistaken for
# 0.01 in precision -- see `reinforcebind.GRAIN_IN`.
# 6. A target that is itself already reinforced, or on hold, is answered
# anyway, with a note. Neither flag disqualifies a TARGET -- they are donor
# rules -- but silently treating an already-reinforced piece as a fresh
# request would hide a misclick.
# 7. EVERYTHING BELOW THE ANSWER IS FOLDED, and a section opens itself only
# when it has a problem in it (owner's ruling, 2026-08-30). Which sections
# count as a problem is `reinforcereport`'s decision and is stated there,
# section by section; the one to argue with first is the staleness rule,
# which opens at 24 hours old and is otherwise folded. Nothing is removed:
# a folded header names what it holds, and the whole report is written
# unfolded to `last-report.txt` beside the index on every press.
# 8. A COULD-NOT-JUDGE TARGET NOW PRINTS THE WHOLE PAGE rather than a short
# "not judged" and an exit. It used to lose the index stamp and the
# staleness note on the way out, which are exactly what a reader needs in
# order to know whether to believe a refusal.
# 9. A DONOR MUST BE THE SAME DESIGN AS THE TARGET, and that is the one rule
# on this page that is NOT a judgement call. Owner, 2026-09-01: "选择母版的
# 目标,必须得是跟自己的design number一样的panel ... 给我俩WP1.0的母版,压根
# 用不了。" So it is a hard filter in `reinforcebind.DonorFilter`, applied
# before a single comparison is made, and when no same-design donor exists
# this button REFUSES -- it names the design, counts what the pool actually
# held, and says the way out. It never falls back to the nearest other
# design, not even labelled as one. Packet suffixes do not split a design:
# `WP1.0`, `WP1.A` and `WP1` are one family, through the same
# `specformat.design_key` reduction Ticket QC already uses.
# A design number nobody could read is UNJUDGED, never a match -- an index
# whose design numbers are all empty (which is what a build with no
# knowledge layer produces) refuses and asks for a rebuild.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinforce Suggestion'
# Shift-click runs the same script with EXEC_PARAMS.config_mode set; that is
# the force-full-rebuild gesture. See the notes at the top of this file.
__shiftclick__ = True
import os
import traceback
from Autodesk.Revit.DB import AssemblyInstance, ElementId
from pyrevit import revit, script, forms
from vibe import compat
from vibe import foldreport as fold
from vibe import knowledge
from vibe import runlog
from vibe import scope as scopelib
from vibe import reinforcebind as bind
from vibe import reinforcecore as core
from vibe import reinforceindex as index
from vibe import reinforcereport as R
doc = revit.doc
uidoc = revit.uidoc
output = script.get_output()
output.close_others()
TITLE = R.TITLE
def die(message):
"""A usage error: the user has not given us something yet. Not a refusal
to judge -- those go in the report, where they survive the dialog."""
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused', report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
# `not_judged` used to live here: a short page and an exit, for a target that
# could not be measured. It is gone, and deliberately -- 'could not judge' is
# a RESULT, and printing it on a stripped-down page dropped the index stamp
# and the staleness note, which are precisely what tells a reader whether to
# believe the refusal. `reinforcereport.render` now returns the whole page for
# that case too, with the headline saying NOT JUDGED, and this file prints it
# the same way it prints an answer.
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------------ force rebuild
def _forced():
"""True when the button was shift-clicked. Never fatal if unavailable."""
try:
from pyrevit import EXEC_PARAMS
return bool(EXEC_PARAMS.config_mode)
except Exception:
return False
# ------------------------------------------------------------- the selection
#
# The same resolution Ticket QC and Panel Lift Check use: assemblies picked
# straight through, a picked member resolves to its owning assembly, nothing
# picked offers a list. The one difference is that this tool answers about
# ONE piece, so several preselected assemblies ask which.
selection = [doc.GetElement(i) for i in uidoc.Selection.GetElementIds()]
picked_assemblies = [el for el in selection if isinstance(el, AssemblyInstance)]
if not picked_assemblies:
owners = []
for el in selection:
try:
owner = doc.GetElement(el.AssemblyInstanceId)
except Exception:
owner = None
if isinstance(owner, AssemblyInstance) and owner not in owners:
owners.append(owner)
picked_assemblies = owners
if selection and not picked_assemblies:
die('What is selected is not part of an assembly, so there is no piece '
'to reinforce.\n\nSelect the assembly you want suggestions for -- or '
'any member of it -- and run this again.')
all_assemblies = index.assemblies(doc)
by_name = dict(all_assemblies)
# The knowledge file is read here rather than further down, because the pick
# list below needs the DECLARED SCOPE before it can offer anything.
try:
know = knowledge.load(doc)
except Exception as exc:
know = None
output.print_md('> The knowledge file could not be read ({0}). No scope '
'is declared, so every assembly in the model is offered '
'&mdash; and **design numbers will be blank**, which '
'since 2026-09-01 means this run will REFUSE rather than '
'answer: a donor has to carry the same design number as '
'the target, and an unread design number is never assumed '
'to match. Fix the knowledge file, or shift-click to '
'rebuild the index once it reads.'.format(exc))
the_scope = scopelib.of(know) if know else scopelib.Scope()
if not picked_assemblies:
# The list this button OFFERS is scoped: this is the TOOL choosing, and
# it chooses inside the declared scope. What the user picks HIMSELF is
# never filtered -- see the branches below and the report's annotation.
names, left_alone = the_scope.split_names(
sorted(n for n, _a in all_assemblies))
if not names:
die('There is nothing here to pick from.\n\n{0}'
.format(the_scope.line(left_alone, total=len(all_assemblies),
unit='assembly')))
chosen = forms.SelectFromList.show(
names, multiselect=False, title=TITLE + ' - which piece',
button_name='Suggest donors')
if not chosen:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target chosen.')
script.exit()
target_name = chosen
elif len(picked_assemblies) > 1:
names = sorted(str(a.Name) for a in picked_assemblies)
chosen = forms.SelectFromList.show(
names, multiselect=False,
title=TITLE + ' - one piece at a time',
button_name='Suggest donors')
if not chosen:
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='refused',
report_text='Piece picker closed -- no target chosen.')
script.exit()
target_name = chosen
else:
target_name = str(picked_assemblies[0].Name)
target_asm = by_name.get(target_name)
if target_asm is None:
die('{0} is not an assembly in this model.'.format(target_name))
def _flag(el, pname):
"""1 / 0 / None off the assembly. READ ONLY -- LookupParameter + read."""
try:
p = el.LookupParameter(pname)
return None if p is None else int(p.AsInteger())
except Exception:
return None
target_reinforced = _flag(target_asm, index.P_REINFORCED)
target_on_hold = _flag(target_asm, index.P_ON_HOLD)
# ------------------------------------------------------------------ the index
#
# See the notes at the top of this file for the policy. The cheap plan is
# computed here rather than inside `refresh` so that the commonest press --
# nothing changed -- can skip `refresh` entirely and avoid a second
# signature pass, and so the progress bar knows how many pieces it is about
# to build.
forced = _forced()
# `know` was loaded above, for the pick list. Past the scope it is used for
# one optional thing -- reading each piece's design number for the report --
# so a missing or broken one costs a label, not the answer. It is not worth
# stopping the run over, unlike in Ticket QC where every check depends on it.
cache = index.read(doc)
if cache.get('_load_error'):
output.print_md('> The index on disk could not be read ({0}); it is being '
'rebuilt from the model. A cache is derived data, so this '
'costs time and nothing else.'
.format(cache['_load_error']))
forced = True
live = index.live_signatures(doc, all_assemblies, know=know)
the_plan = core.plan(cache, live)
todo = len(the_plan['build'])
refreshed = bool(todo or the_plan['drop']) or forced
refresh_report = None
if refreshed:
total = len(the_plan['build']) + len(the_plan['fresh']) if forced else todo
bar = 'Indexing donors ({value} of {max_value})'
if forced:
bar = 'Full rebuild ({value} of {max_value}) - all donors re-measured'
with forms.ProgressBar(title=bar, cancellable=False) as pb:
state = {'n': 0}
def _tick(_name):
state['n'] += 1
pb.update_progress(state['n'], max(total, 1))
pb.update_progress(0, max(total, 1))
cache, refresh_report = index.refresh(
doc, rebuild_all=forced, know=know, on_piece=_tick)
# The outline audit: once, unless forced. It is what keeps an approximated
# edge condition from ever being printed as a measured exact match.
audit_needed = forced or (bind.load_audit(doc) is None)
with forms.ProgressBar(title='Matching {0}...'.format(target_name),
indeterminate=True):
suggestion, info = bind.suggest_for_piece(
doc, target_name, refresh=False, audit=audit_needed, know=know)
# ------------------------------------------------------------------- report
#
# THE SHAPE, AND WHOSE IT IS. Owner, 2026-08-30:
#
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
#
# So the target and the donor proposals are the page, printed big and first
# through pyRevit's own markdown and tables -- which is also what can linkify
# an assembly id. Everything else follows, smaller, FOLDED BY DEFAULT, and a
# section that has a problem in it opens itself. That turns the page into a
# contract: if nothing below the answer is open, nothing needs him.
#
# The rule lives in `vibe.foldreport`, not here, and the sections come from
# `reinforcereport.render` -- the same sections the offline CLI prints as
# plain text, so the two cannot say different things.
#
# WHY THE HTML IS WRITTEN BY HAND AND LOOKS DATED
# -----------------------------------------------
# This output panel is a `System.Windows.Forms.WebBrowser`: the IE engine,
# hosted in WinForms, running in IE7 document mode unless the host process
# carries a FEATURE_BROWSER_EMULATION entry, which pyRevit does not set.
# `<details>` / `<summary>` DO NOT EXIST there and fail silently -- an unknown
# element renders inline and the "collapsed" content is simply always visible.
# So the fold is an inline `onclick` plus `style.display`, which is IE5-era
# DOM. JavaScript does run in this panel (pyRevit injects Chart.js into it),
# but the toggle has NOT been exercised inside Revit, so it is designed for
# failure rather than trusted:
#
# * a section with a problem in it renders OPEN, as plain HTML, and does not
# need the script to run at all;
# * the whole report, nothing folded, is written to `last-report.txt` beside
# the index every press, and the page says where. A fold that will not
# open therefore costs a file open, never a lost sentence.
full_path = os.path.join(os.path.dirname(index.index_path(doc)),
'last-report.txt')
def _donor_link(_name, record):
"""pyRevit's clickable element id, for a donor's line in the headline."""
if not (record or {}).get('asm_id'):
return None
try:
return output.linkify(ElementId(record['asm_id']), 'select assembly')
except Exception:
return None
best = suggestion.best if suggestion is not None else None
rows = bind.explain(suggestion, info, best) if best is not None else []
sections = R.render(suggestion, info, rows, cache,
model=doc.Title,
refresh_report=refresh_report,
rebuilt_all=forced, refreshed=refreshed,
offline=False,
reinforced=target_reinforced, on_hold=target_on_hold,
full_path=full_path, donor_link=_donor_link)
# FOLDED IS NOT GONE. Written before anything is printed, so the file exists
# even if the printing throws.
try:
# `as_text` first, then encode: under IronPython 2.7 the report is a byte
# string, and a piece mark or design number carrying a non-ASCII character
# would make a bare `.encode('utf-8')` raise on the implicit ASCII decode.
payload = compat.as_text(fold.text(sections, show_all=True))
handle = open(full_path, 'wb')
try:
handle.write(payload.encode('utf-8'))
finally:
handle.close()
except Exception as exc:
output.print_md('> The full report could not be written to disk ({0}). '
'Everything is still on this page; use the fold headers.'
.format(exc))
# ---- the answer, through pyRevit, big and first.
for block in sections[0].blocks:
if block[0] == fold.TABLE:
output.print_table(block[2], columns=block[1])
else:
output.print_md(block[1])
# ---- everything else, folded.
tail = sections[1:]
if tail:
output.add_style(fold.STYLE)
output.inject_script(fold.SCRIPT)
opened = fold.sections_with_problems(tail)
if opened:
output.print_md('---')
output.print_md('**{0} of the {1} section(s) below opened themselves '
'&mdash; they have something in them that needs you.** '
'The rest are folded; click a header to open one.'
.format(len(opened), len(tail)))
else:
output.print_md('---')
output.print_md('**Nothing below needs you.** Every section is folded; '
'click a header to open one. The whole report is also '
'in `{0}`.'.format(full_path))
output.print_html(fold.html(tail))
# The report is on the page and the full text is on disk -- record the run
# as 'ok'. The headline (target + donors or the NOT JUDGED refusal) is what
# the page led with; keep the record short, since report_text is capped.
runlog.record('Reinforce Suggest', doc_title=doc.Title, outcome='ok',
report_path=full_path,
report_text=('Target {0}; suggestion {1}'
.format(target_name,
'found' if best is not None
else 'not judged')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Reinforce Suggest', doc_title=doc.Title,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
title: Reinf QC
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses (it shares
# `lib/vibe/ticketqc_run.py` and `ticketchecks.py` with Ticket QC) is
# visible without a Revit restart. See TicketQC.pushbutton/bundle.yaml for
# the full explanation; this one carries the same key for the same reason.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Added 2026-09-05, on the owner's own instruction: a cut-down TicketQC for
# a panel whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started. Shares its engine with
# TicketQC -- both call into `lib/vibe/ticketqc_run.py`, which takes a
# `mode` of 'ticket' or 'reinforcing' -- and reads the same evidence one
# stage earlier: no ticket sheet is required, and any check whose evidence
# is a ticket artefact (a hatch, a callout, a BOM table, a sheet name...)
# does not run here. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` for
# exactly which of the 41 checks that is, and why, one by one.
#
# One line: this button is a row in a `.pulldown`, and a pulldown row renders
# as text beside a 16px icon. A `\n` in it has nowhere to go. The dropdown
# grows to fit the longest row, so the title may say what it means -- kept
# short anyway so it reads at a glance beside "Ticket QC".
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
#
# Check a panel's MODELLED reinforcing against its design spec -- for a
# piece whose reinforcing is finished (the model already has everything it
# should have) but whose ticketing has not started yet.
#
# A cut-down Ticket QC: same engine, same evidence-first discipline, but
# only the checks whose evidence is the panel's modelled content run here
# (connectors/embeds, lifters, strands, bars, WWF, solid zones as modelled,
# member interference, weight, cast extras, release ratio, the handling
# result, lift positions/points). Anything that needs a ticket artefact --
# a sheet, a placed view, a printed call-out, a hatch pattern, a BOM table,
# a bed-plan note -- does not run here; it is listed, with its reason, in
# the report's own "Not checked at this stage" section rather than left
# silent. See `lib/vibe/ticketchecks.REINFORCING_CHECKS` /
# `REINFORCING_EXCLUDED` for the check-by-check classification, and
# `lib/vibe/ticketqc_run.py` for the shared engine this and Ticket QC both
# call.
#
# Read-only. Nothing here opens a transaction.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Reinf QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='reinforcing')
@@ -0,0 +1,27 @@
title: Ticket QC
# DEV. A fresh IronPython engine for every press: `lib/vibe` is re-imported
# on each click, so an edit to a module this button uses is visible without
# a Revit restart. It trades press latency (engine construction instead of
# engine reuse) for restart-freedom, and it is the hottest button here --
# 51 commits to its six modules in the seven days to 2026-08-30.
#
# Read docs/architecture-migration.md section 8 before trusting or removing
# this. Two things it says that are not obvious: pyRevit's DEFAULT is already
# a clean engine, and this extension gets one today only because
# extension.json has no `url:` and so is never registered as a package --
# add a url and every button silently switches to the shared engine. This key
# is the pin against that, not a new capability.
#
# REMOVE IT PER BUTTON as that button stabilises: delete the two lines below.
engine:
clean: true
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# MOVED 2026-09-05 into the new Modeling.pulldown (see
# Tools.panel/Modeling.pulldown/bundle.yaml), where this used to be one of
# four large icons on Tools.panel. One line, no `\n`: a pulldown row renders
# as text beside a 16px icon and the dropdown grows to fit the longest row,
# so the title is free to say what it means -- unlike a large button, which
# is as wide as its widest line.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
#
# Check shop tickets against their design spec.
#
# Reads the specs written by Extract Design plus the project's knowledge.yaml,
# then compares every piece of the chosen design numbers against them.
#
# Every CHECK here is pure arithmetic - the same model always gives the same
# answer, and every row of the checklist table is computed. One thing in the
# report is not: with the run-time option `vision_escalation` on, the checks
# code cannot settle (5.5) are escalated to a small model reading one rendered
# view. That result is quarantined - its own list, its own codes, its own
# section, and it can never set a checklist row. See vibe.ticketvision and
# docs/ticket-checklist.md section 8bis.
#
# Read-only. Nothing here opens a transaction, including the image export.
#
# THE WHOLE BUTTON MOVED into `lib/vibe/ticketqc_run.py` on 2026-09-05, so
# the new Reinforcing QC button could reuse it instead of these 765 lines
# being copy-pasted -- see that module's own header for exactly what is
# shared and what differs. This script is `mode='ticket'`, which is BYTE
# FOR BYTE what used to live here: nothing about Ticket QC's own behaviour
# changed in the move.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Ticket QC'
from pyrevit import revit
from vibe import ticketqc_run
ticketqc_run.run(revit.doc, revit.uidoc, mode='ticket')
@@ -0,0 +1,27 @@
# See Setup.panel/Config.pulldown/bundle.yaml for what a `.pulldown` is and
# why a container may carry a `title:`.
title: Modeling
# THE MODELLING-STAGE TOOLS, gathered under one arrow (2026-09-05, on the
# owner's own instruction: "加一个 Modeling pulldown,把 Reinforcing 和
# TicketQC 放进去"). ReinforceSuggest and TicketQC moved here from being two
# of the four large buttons this panel used to show side by side; the fourth
# tool that lived here, Panel Lift Check, moved the other way -- onto
# Design.panel/DesignHub.pulldown, see that bundle's own comment. Snapshot
# stays put as the one remaining large icon.
#
# ReinforcingQC is new: a cut-down TicketQC for a panel whose reinforcing is
# finished but whose ticketing has not started yet -- see
# lib/vibe/ticketqc_run.py and lib/vibe/ticketchecks.REINFORCING_CHECKS for
# what it does and does not judge at that stage. It sits beside TicketQC
# because it shares the engine and reads the same evidence, one stage
# earlier.
#
# Order is the natural order of the work: suggest the reinforcing, check the
# reinforcing once it is modelled, check the ticket once it is drawn.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- ReinforceSuggest
- ReinforcingQC
- TicketQC
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,11 @@
title: Snapshot
# No tooltip and no author on purpose. pyRevit renders both on hover;
# the owner wants nothing there. Guarded by
# WillTools.extension/tests/test_button_tooltips.py -- do not add them back.
#
# Two lines, short ones. A large ribbon button is as wide as its widest line
# of text, so a long title makes one button dwarf its neighbours -- which is
# exactly what the owner objected to. Keep every line under ~9 characters.
#
# One word, so one line, and it is shorter than "Reinforce" beside it -- the
# four large buttons on this panel all come out the same width.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,889 @@
# -*- coding: utf-8 -*-
# Index the model as it stands, then hand the sandbox off -- or close Revit.
#
# WHAT THIS BUTTON IS NOW, 2026-09-01
# ------------------------------------
# Owner's commission: "snapshot功能重构。功能变成保存一份本地detach模型带
# 时间戳,然后关闭detach模型的revit session,重开这个新的detach模型,做你需要
# 的所有操作,然后自动关闭。" -- and, separately, "开关模型太耗时了。我没办法
# 这么操作": closing and reopening HIS model by hand is not a thing he will do.
#
# So the whole cycle moved out of this button and into
# `tools/snapshot_cycle.py`, which runs on the host, needs nobody in front of
# it, and can be started by Claude. What is left here is the one job that
# genuinely requires the live session and cannot be done anywhere else:
# handing over the model's IDENTITY. Only the open document can cheaply report
# its cloud region and GUIDs, and without those nothing else can find the
# model to copy.
#
# WHY THIS BUTTON DOES NOT MAKE THE COPY ITSELF
# ----------------------------------------------
# The design this replaced expected an in-session background open --
# `Application.OpenDocumentFile(cloudPath, OpenOptions with
# DetachAndPreserveWorksets)` -- right here, in the live session. That route
# does not exist for this model, and the reason is not a version guard:
#
# * a cloud model cannot be opened detached at all. Any
# `DetachFromCentralOption` on a cloud `ModelPath` raises "Detach option
# is not valid for cloud model"; Autodesk's answer is that the Revit team
# does not allow it and that SaveAs is the way; and the UI agrees --
# "Detach from Central" is greyed out for anything from Autodesk Docs.
# * Revit holds one Document per model per session, so the live session
# cannot open the model it already has open.
# * the only call that could copy the document already in memory is
# `doc.SaveAs`, and SaveAs RE-POINTS the document it is called on. Run
# here it would leave HIS Revit sitting in a local copy, disconnected
# from the cloud. There is no SaveCopyAs.
#
# The full evidence, with sources, is in `tools/snapshot_cycle.py`'s header.
# The copy is therefore made by a Revit that is not his, and this button's
# phase C is a spawn rather than an open.
#
# THE OLD RITUAL, KEPT
# ---------------------
# `snapshot.after_close` is NOT retired. The close-model path below is
# unchanged and still chains `tools/snapshot_defer.py` -- for a machine, or a
# day, where the answer really is "close everything first". It is simply no
# longer the only way to get a copy, and it is no longer the default.
#
# THE ORIGINAL CONSTRAINT, AND WHAT IS LEFT OF IT
# ------------------------------------------------
# Owner, 2026-08-30: the model is 300+ MB on disk and 10+ GB open, and the
# machine cannot hold two of them. Measured 2026-09-01: it can -- the live
# session and a detached sandbox ran side by side all morning. What nobody has
# measured is three, so the pipeline sequences to keep it to two and this
# button never opens a model at all.
#
# The close-model ritual, still here as one of the options:
#
# he syncs his act, outside this button. This button NEVER
# calls SynchronizeWithCentral and never asks about it.
# phase A this button indexes the OPEN document. The model is
# already in RAM and already paid for; a snapshot read
# from the live document at sync state and one read
# from a detached copy of that same state contain the
# same numbers. Seconds, not minutes, and no copy.
# phase B Revit closes. The press IS the authorisation for the
# close -- that is what the button is called.
# phase C AFTER Revit has exited and the 10 GB is free, a
# deferred host process opens the model detached, saves
# a timestamped copy and quits. Optional, off unless
# the job layer configures it, and its outcome lands in
# a log this button reads out on the next press.
#
# THE TWO WRITES THIS FEATURE IS ALLOWED TO MAKE, AND NEITHER IS HERE
# --------------------------------------------------------------------
# No Transaction is opened in this file or in anything it calls. Nothing is
# written to the model -- not a parameter, not a workset, not a save. The only
# model-adjacent write in the whole feature is phase C's `SaveAs` of a NEW
# file, in another process, after this one has exited. The sync that used to
# live here has been removed entirely at the owner's instruction.
#
# ORDERING, AND THE FAILURE MODE IT EXISTS TO PREVENT
# ----------------------------------------------------
# Revit is closed LAST and only after phase A has reported complete. A crash
# halfway through the index must leave the session open, with the traceback on
# screen, because the alternative is a closed Revit and no way to find out
# what went wrong. `PostCommand` is therefore reached on exactly one path.
#
# The other half of that discipline is phase C. It runs when nobody is
# watching, in a process this one has already outlived, and a day it silently
# did not run looks exactly like a day it worked. So the request is journalled
# BEFORE the close and the deferred process journals its own outcome; a
# request with nothing after it is reported, in capitals, at the top of the
# next press. Silence is not an acceptable report.
#
# JUDGEMENT CALLS, for the owner to overrule
# -------------------------------------------
# 1. FULL REBUILD, every press. `refresh(rebuild_all=True)`. The ordinary
# press elsewhere trusts the cheap signature, which cannot see an opening
# moved or resized in place. This is the daily archival snapshot and it is
# the one that gets trusted months later, so it pays the measured 6-10 s
# for an answer that owes nothing to yesterday's cache.
# 2. THREE OPTIONS, not yes/no. Closing Revit is a big irreversible thing to
# hang on a mis-click, and "snapshot but stay open" is a real want (a
# lunchtime checkpoint). Cancel writes nothing at all.
# 3. A MISSING JOB LAYER ASKS RATHER THAN BLOCKS. Without it there is no
# declared scope and no parameter-role mapping, so the index would measure
# the whole model and mark every piece could-not-judge on the review
# signal -- and would overwrite a good index to do it. That is worth a
# dialog. It is not worth a refusal on a machine that legitimately has no
# job layer yet.
# 4. PHASE C IS OFF UNTIL CONFIGURED. `snapshot.detached_dir` is None by
# default and None means not configured, never "pick somewhere". The button
# says so instead of guessing a folder for a 300 MB file.
# pylint: disable=import-error,invalid-name,broad-except
__title__ = 'Snapshot'
import os
import time
import traceback
from pyrevit import revit, script, forms
from vibe import knowledge
from vibe import liftexport
from vibe import reinforcecore as core
from vibe import reinforceindex as index
from vibe import runlog
from vibe import snapshotmeta as S
doc = revit.doc
output = script.get_output()
output.close_others()
TITLE = 'Snapshot & Close'
# Where this button sits, and therefore where the repo is. Hoisted to the top
# because three separate places need it now (the deferred tool, the cycle
# pipeline and the runner) and computing it three times is how two of them end
# up one directory apart.
HERE = os.path.dirname(os.path.abspath(__file__))
EXT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
REPO_ROOT = os.path.dirname(EXT_ROOT)
DEFER_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_defer.py')
CYCLE_TOOL = os.path.join(REPO_ROOT, 'tools', 'snapshot_cycle.py')
RUNNER = os.path.join(EXT_ROOT, 'runners', 'detach_snapshot.py')
CYCLE = 'Snapshot, then run the sandbox cycle'
CLOSE = 'Snapshot, then close Revit'
STAY = 'Snapshot only, stay open'
CANCEL = 'Cancel'
def die(message):
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='refused', report_text=message)
forms.alert(message, title=TITLE, exitscript=True)
if doc is None:
# `doc.Title` would itself fail on a None document -- record what there is
# to record, then hand the refusal to the dialog.
runlog.record('Snapshot Close', doc_title=None, outcome='refused',
report_text='No model is open, so there is nothing to '
'snapshot.')
die('No model is open, so there is nothing to snapshot.')
# --------------------------------------------------------------- versions
def _revit_version():
try:
app = doc.Application
return '{0} ({1})'.format(app.VersionNumber, app.VersionBuild)
except Exception:
return None
def _pyrevit_version():
try:
from pyrevit import versionmgr
return versionmgr.get_pyrevit_version().get_formatted()
except Exception:
return None
def _revit_pid():
try:
from System.Diagnostics import Process
return Process.GetCurrentProcess().Id
except Exception:
return None
# --------------------------------------------------------------------- main
# The main body is wrapped so an unexpected exception still leaves a run-log
# record (outcome='error') before pyRevit shows its own error dialog.
try:
# ------------------------------------------------------ what already happened
#
# Read and printed FIRST, before anything is decided, because the one thing
# this design can lose is a phase C that never ran -- and the only moment
# anybody is looking is the moment they press this button again.
know = knowledge.load(doc)
job_folder = None
try:
from vibe import designspec
job_folder = designspec.root_dir(doc)
except Exception:
job_folder = None
log = S.log_path(job_folder) if job_folder else None
history = S.read(log) if log else []
output.print_md('# {0}'.format(TITLE))
for line in S.summary(history):
if line.startswith('WARNING'):
output.print_md('**{0}**'.format(line))
else:
output.print_md('- {0}'.format(line))
# ---------------------------------------------------- is this job set up
problems = knowledge.config_problems(doc, know)
if problems:
die('\n\n'.join(problems))
has_job_layer = False
for label, path, exists in knowledge.sources(doc):
if label == 'job' and exists:
has_job_layer = True
if not has_job_layer:
keep_going = forms.alert(
'This model has no job knowledge file.\n\n'
'Without it there is no declared scope and no parameter-name '
'mapping, so the index will measure every assembly in the model and '
'will record "could not judge" for the review signal on all of them '
'-- and it will overwrite whatever index is there now.\n\n'
'Snapshot anyway?',
title=TITLE, yes=True, no=True)
if not keep_going:
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='No job knowledge file; user declined to '
'snapshot anyway.')
script.exit()
# --------------------------------------------------------- phase C settings
#
# Resolved BEFORE the confirmation so the dialog can say what will actually
# happen after the close, rather than promising a detached copy that turns out
# not to be configured.
detached_dir = knowledge.get(know, 'snapshot.detached_dir', None)
after_close = knowledge.get(know, 'snapshot.after_close', False)
# `S.FOLDER`, not the literal 'folder': `tools/snapshot_cycle.py` reads the
# same key and `tools/knowledge_drift.py` compares the fallbacks as SOURCE
# TEXT, so two call sites spelling the same value differently are reported as
# disagreeing about what "unset" means.
layout = knowledge.get(know, 'snapshot.layout', S.FOLDER)
revit_year = knowledge.get(know, 'snapshot.revit_year', None)
worksets = knowledge.get(know, 'snapshot.worksets', 'preserve')
wait_minutes = knowledge.get(know, 'snapshot.wait_minutes', 20)
pyrevit_exe = knowledge.get(know, 'snapshot.pyrevit_exe', None)
revit_exe = knowledge.get(know, 'snapshot.revit_exe', None) \
or S.revit_exe_guess(revit_year)
if not revit_year:
# `pyrevit run` (both the cycle route's detach step and the close-model
# route) takes an optional `--revit=<year>`, and a job layer that states
# `snapshot.revit_exe` but not `snapshot.revit_year` -- exactly this
# job's shape -- used to leave it off entirely. On a machine with more
# than one Revit version attached that is not a neutral omission: see
# `vibe.snapshotmeta.revit_year_from_exe` for the 2026-09-01 incident it
# is named for. `revit_exe`'s own path almost always carries the year.
revit_year = S.revit_year_from_exe(revit_exe)
batch = knowledge.get(know, 'snapshot.batch', []) or []
def _pyrevit_cli():
"""The pyRevit CLI, from the job layer or from where pyRevit installs it."""
if pyrevit_exe and os.path.isfile(pyrevit_exe):
return pyrevit_exe
if pyrevit_exe:
return None # stated and wrong: never fall back
return S.pyrevit_exe_guess()
def _copy_blocker():
"""What stops ANY detached copy being made, whichever route. Or None."""
if not detached_dir:
return ('not configured -- set snapshot.detached_dir in the job '
'knowledge file to switch it on')
if not os.path.isdir(detached_dir):
return 'snapshot.detached_dir does not exist: {0}'.format(detached_dir)
if _pyrevit_cli() is None:
return 'the pyRevit CLI was not found (snapshot.pyrevit_exe)'
if liftexport.find_python(know) is None:
return 'no host Python was found to run the deferred step'
return None
def phase_c_blocker():
"""Why the OLD after-close route cannot run, or None.
`snapshot.after_close` is deliberately still consulted here and nowhere
else: it gates the close-model ritual, which is the thing it has always
meant. The cycle route below does not read it -- it is not chained to an
exit, so "after close" has nothing to say about it, and making one key
mean two things is how a setting becomes untrustworthy.
"""
stop = _copy_blocker()
if stop:
return stop
if not after_close:
return ('switched off -- snapshot.after_close is false in the job '
'knowledge file')
return None
def cycle_blocker():
"""Why the sandbox cycle cannot be started, or None.
Note what is NOT in here: `after_close`, and any check on other Revits.
The cycle runs beside this session on purpose -- that is the whole point
of it -- and it sequences its own steps so that at most two Revits are
ever resident.
"""
stop = _copy_blocker()
if stop:
return stop
if not revit_exe:
return ('no Revit.exe to open the sandbox with -- set '
'snapshot.revit_exe or snapshot.revit_year')
if not os.path.isfile(revit_exe):
return 'Revit.exe is not at {0} (snapshot.revit_exe)'.format(revit_exe)
if not batch:
return 'snapshot.batch is empty, so the sandbox would open and do nothing'
if not os.path.isfile(CYCLE_TOOL):
return 'tools/snapshot_cycle.py is not where this button expects it'
return None
blocker = phase_c_blocker()
cycle_stop = cycle_blocker()
# -------------------------------------------------------------- the decision
if cycle_stop is None:
cycle_tail = (
'\n\nCYCLE: a timestamped copy is written to\n{0}\nby a Revit of its '
'own, then opened in a sandbox session, then {1} runs inside it, then '
'that session closes. YOUR MODEL STAYS OPEN and is never touched.'
.format(detached_dir, ' + '.join(batch)))
else:
cycle_tail = '\n\nThe sandbox cycle cannot run ({0}).'.format(cycle_stop)
if blocker is None:
close_tail = ('\n\nCLOSE: after Revit has exited, a timestamped detached '
'copy is written to:\n{0}'.format(detached_dir))
else:
close_tail = ('\n\nCLOSE writes no detached copy ({0}).'.format(blocker))
choice = forms.alert(
'Sync first if you have not -- this button does not sync, and it never '
'touches the model.\n\n'
'It rebuilds the reinforcing index from the model as it stands now and '
'stamps it with where it came from.' + cycle_tail + close_tail,
title=TITLE, options=[CYCLE, CLOSE, STAY, CANCEL])
if choice not in (CYCLE, CLOSE, STAY):
output.print_md('---')
output.print_md('Cancelled. Nothing was read, written or closed.')
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='User cancelled the snapshot dialog.')
script.exit()
closing = (choice == CLOSE)
cycling = (choice == CYCLE)
# ------------------------------------------------------------------ phase A
output.print_md('---')
output.print_md('## Phase A - indexing the open model')
output.print_md('Full rebuild, so nothing in this snapshot is inherited from '
'an earlier press. Measured at 6-10 s on this model.')
# The index AS IT STOOD BEFORE this press, captured so the update statistics
# below can diff what this snapshot newly marked. READ ONLY -- `index.read`
# only loads the file, it never touches the model. Absent or damaged is fine:
# `core.snapshot_stats` reads an absent old cache as "first snapshot".
try:
old_cache = index.read(doc)
except Exception:
old_cache = None
report = None
cache = None
failure = None
try:
# An upper bound for the bar: a full rebuild measures at most every
# assembly in the model. The cheap collector pass costs ~0.3 s here and
# buys a progress bar that moves, instead of one pinned at 100%.
total = max(len(index.assemblies(doc)), 1)
with forms.ProgressBar(title='Full rebuild ({value} of {max_value})',
cancellable=False) as bar:
state = {'n': 0}
def tick(_name):
state['n'] += 1
bar.update_progress(state['n'], total)
bar.update_progress(0, total)
cache, report = index.refresh(doc, rebuild_all=True, know=know,
on_piece=tick)
except Exception:
failure = traceback.format_exc()
if failure is not None:
# THE MODEL STAYS OPEN. A close here would take the traceback with it.
output.print_md('## Phase A FAILED - Revit has not been closed')
output.print_md('```\n{0}\n```'.format(failure))
if log:
S.append(log, {'event': 'phase-a-failed',
'error': failure.splitlines()[-1]})
runlog.record('Snapshot Close', doc_title=doc.Title,
outcome='refused',
report_text='Phase A failed - Revit has not been closed.',
error=failure.splitlines()[-1])
script.exit()
# ------------------------------------------------- stamp it with where it came from
#
# `refresh` writes the index; this adds the block that says whose numbers
# those are, and rewrites. It is written on EVERY press, including the press
# where nothing changed and `refresh` therefore did not save -- an index whose
# provenance is a week older than its numbers is worse than none.
source_path = None
try:
source_path = doc.PathName or None
except Exception:
source_path = None
prov = S.provenance(
'A', S.LIVE_DOC,
source_path=source_path,
source_stamp=None, # a live document has no snapshot stamp
source_title=index.doc_key(doc),
revit=_revit_version(),
pyrevit=_pyrevit_version(),
job_folder=job_folder,
note=('read from the open document immediately after the engineer synced; '
'no detached copy was involved in these numbers'))
pieces = len(cache.get('pieces') or {})
try:
cache['provenance'] = prov
core.save(report['path'], cache)
stamped = True
except Exception:
stamped = False
output.print_md('- {0} assemblies seen, {1} pieces in the index'
.format(report['assemblies'], pieces))
output.print_md('- scope: {0}'.format(report['scope']))
output.print_md('- built in {0} s, written to `{1}`'
.format(report['seconds_total'], report['path']))
if stamped:
output.print_md('- provenance: {0}'.format(S.provenance_line(prov)))
else:
output.print_md('- **the provenance stamp could not be written** -- this '
'index cannot say where it came from')
missing = (report.get('coverage') or {}).get('missing') or []
if missing:
output.print_md('- **{0} pieces could not be measured** and are not in '
'this snapshot'.format(len(missing)))
if log:
S.append(log, {'event': S.SNAPSHOT, 'pieces': pieces,
'path': report['path'], 'scope': report['scope'],
'seconds': report['seconds_total'],
'provenance': prov})
# ------------------------------------------------- update statistics
#
# The owner's ask, 2026-09-02: "每次按 snapshot 先做更新统计:这次 mark 了什么
# panel、有什么 feature、写入库". Before the model is handed over, say what THIS
# press changed: which pieces are newly donors, how many features each carries,
# and which feature-IDs went into the shared vocabulary for the first time
# versus which were already known. READ ONLY -- this only reads the two caches
# and one cheap signature pass; nothing about the model is written.
#
# `index.refresh` already grew the registry (via `core.register_features`), so
# `cache` carries the post-press vocabulary; `old_cache` is the pre-press one
# captured before Phase A. `live_signatures` is the same cheap pass `refresh`
# runs, redone here so the diff sees the model exactly as it stands now.
output.print_md('## Update statistics -- what this snapshot marked')
try:
live = index.live_signatures(doc, know=know)
stats = core.snapshot_stats(old_cache, cache, live)
except Exception:
stats = None
output.print_md('- **the update statistics could not be computed** -- the '
'index was still written; only this summary is missing')
if stats is not None:
newly = stats['newly']
if stats['first_snapshot']:
output.print_md('- **first snapshot for this index** -- there was '
'nothing to diff against, so every reinforced piece '
'below is new to the donor pool, not a change since '
'last press.')
if not newly:
output.print_md('- no piece was newly marked reinforced since the last '
'snapshot. (This is "nothing changed", not "nothing '
'is reinforced".)')
else:
output.print_md('- **{0} piece(s) newly marked** as donors this press: '
'{1}'.format(len(newly), ', '.join(newly)))
output.print_md('- feature-IDs new to the vocabulary this press: '
'**{0}**; already known: **{1}**'
.format(len(stats['new_ids']),
len(stats['known_ids'])))
for panel in stats['panels']:
if panel['feature_count'] is None:
output.print_md(' - **{0}** -- could not be measured; no '
'features recorded'.format(panel['name']))
continue
bits = ['{0} feature(s)'.format(panel['feature_count'])]
if panel['new_ids']:
bits.append('new IDs {0}'.format(
', '.join('#{0}'.format(i) for i in panel['new_ids'])))
if panel['known_ids']:
bits.append('known IDs {0}'.format(
', '.join('#{0}'.format(i) for i in panel['known_ids'])))
if panel['unregistered']:
bits.append('{0} unregistered'.format(panel['unregistered']))
output.print_md(' - **{0}** -- {1}'.format(
panel['name'], '; '.join(bits)))
if log:
# Journal the same figures. Per-panel id lists are kept so the ledger
# can be replayed without the model; `first_snapshot` distinguishes a
# cold index from a genuine no-change press.
S.append(log, {'event': 'phase-a-update-stats',
'first_snapshot': stats['first_snapshot'],
'newly': newly,
'new_ids': stats['new_ids'],
'known_ids': stats['known_ids'],
'panels': stats['panels']})
# ---------------------------------------------- handing the model over
#
# Both routes below need the same two things: WHERE the copy goes (a plan) and
# HOW to find the model again (its identity). Built once, here, so the cycle
# and the close-model ritual cannot drift into naming files differently.
def _source_block():
"""How another session is to find this model again.
Cloud GUIDs and file path both, when both are available: a cloud model is
reopened from its GUIDs, a file-based one from its path, and which one a
job is was never something to assume. A cloud model reports no usable
local path, which is the ordinary case here, not an error.
"""
src = {'path': source_path, 'title': index.doc_key(doc)}
try:
from Autodesk.Revit.DB import ModelPathUtils # noqa: F401
mp = doc.GetCloudModelPath()
src['cloud'] = {
'region': str(mp.Region),
'project_guid': str(mp.GetProjectGUID()),
'model_guid': str(mp.GetModelGUID()),
}
except Exception as exc:
# Not a cloud model, or this Revit does not expose the GUIDs. The file
# path is then the only handle and the runner says so if it is absent.
src['cloud'] = None
src['cloud_error'] = str(exc)
return src
def _write_request(run_id, plan, source, extra=None):
"""The handoff file. Same schema for both routes -- one runner reads it."""
request = {
'run': run_id,
'base': plan['title'],
'layout': layout,
'worksets': worksets,
'detached_dir': detached_dir,
'plan': plan,
'log': log,
'job_folder': job_folder,
'requested_at': time.strftime('%Y-%m-%d %H:%M:%S'),
'requested_by_revit': _revit_version(),
'source': source,
}
request.update(extra or {})
req_dir = os.path.join(detached_dir, '_requests')
req_path = os.path.join(req_dir, run_id + '.json')
if not os.path.isdir(req_dir):
os.makedirs(req_dir)
import json
handle = open(req_path, 'wb')
try:
handle.write(json.dumps(request, indent=2).encode('utf-8'))
finally:
handle.close()
# And the identity on its own, where the host pipeline looks for it.
# A cloud model is found by its region and two GUIDs, and NOTHING on this
# machine knows those except a session with the model open -- this one.
# Writing them down here is what lets `tools/snapshot_cycle.py` be run
# later with no Revit anywhere and still find the model to copy. It is
# identity, never state: it says which model, never anything about a copy.
try:
cache = open(os.path.join(detached_dir, 'source.json'), 'wb')
try:
cache.write(json.dumps(source, indent=2,
sort_keys=True).encode('utf-8'))
finally:
cache.close()
except Exception:
pass # a lost cache costs a flag, not a snapshot
return req_path
def _spawn(argv):
"""Start a host process that outlives this one. Returns its pid.
`UseShellExecute = False` + `CreateNoWindow = True`: the pipeline runs for
minutes with nobody watching, and a console window popping up over the
engineer's Revit is the kind of thing that gets a tool switched off.
"""
from System.Diagnostics import Process, ProcessStartInfo
psi = ProcessStartInfo()
psi.FileName = argv[0]
psi.Arguments = S.quote(argv[1:])
psi.UseShellExecute = False
psi.CreateNoWindow = True
psi.WorkingDirectory = REPO_ROOT
return Process.Start(psi).Id
def _job_knowledge_path():
"""The job knowledge file, so the host pipeline reads the same layer.
Handed over as a path rather than as resolved values: the pipeline needs
`bridge.refuse_titles` and `bridge.allow_roots` as well as the snapshot
block, and a button that copies six settings across is a button that
forgets the seventh the day one is added.
"""
try:
for label, path, exists in knowledge.sources(doc):
if label == 'job' and exists:
return path
except Exception:
pass
return None
# ------------------------------------------------------- the sandbox cycle
#
# The new route, and the one the commission is about. Nothing here opens,
# saves or closes a model: it writes the handoff and starts a host process
# that does all of it in Revits that are not this one.
run_id = None
if cycling and cycle_stop is None:
run_id = 'c{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
base = index.doc_key(doc) or 'untitled'
plan = S.plan(detached_dir, base, layout=layout)
started_pid = None
argv = None
try:
req_path = _write_request(run_id, plan, _source_block(),
{'requested_by': 'SnapshotClose button, '
'cycle route'})
argv = [liftexport.find_python(know), CYCLE_TOOL,
'--fresh-detach',
'--request', req_path,
'--detached-dir', detached_dir,
'--pyrevit', _pyrevit_cli(),
'--revit-exe', revit_exe]
if revit_year:
argv.extend(['--revit', str(revit_year)])
if log:
argv.extend(['--log', log])
job_knowledge = _job_knowledge_path()
if job_knowledge:
argv.extend(['--knowledge', job_knowledge])
for command in batch:
argv.extend(['--run', command])
started_pid = _spawn(argv)
except Exception:
started_pid = None
cycle_stop = 'the pipeline could not be started: {0}'.format(
traceback.format_exc().splitlines()[-1])
output.print_md('---')
output.print_md('## The sandbox cycle - running now, beside this session')
if started_pid:
output.print_md(
'Started as `{0}` (host pid {1}). **This Revit is not affected** '
'-- it is not closed, not saved, and not read again.'
.format(run_id, started_pid))
output.print_md('It writes:')
output.print_md('`{0}`'.format(plan['path']))
if plan.get('warning'):
output.print_md('**{0}**'.format(plan['warning']))
output.print_md('then opens that copy in a Revit of its own, runs '
'`{0}`, and closes it again. Its log lands beside the '
'copy and its outcome in `{1}`.'
.format(' + '.join(batch), log))
output.print_md('The command it is running:')
output.print_md('```\n{0}\n```'.format(S.quote(argv)))
if log:
S.append(log, {'event': S.REQUESTED, 'run': run_id,
'path': plan['path'], 'request': req_path,
'route': 'cycle', 'host_pid': started_pid})
else:
output.print_md('**Not started: {0}**'.format(cycle_stop))
if log:
S.append(log, {'event': S.SKIPPED, 'run': run_id,
'route': 'cycle', 'reason': cycle_stop})
elif cycling:
output.print_md('---')
output.print_md('## The sandbox cycle - not run')
output.print_md('Not started: {0}'.format(cycle_stop))
if log:
S.append(log, {'event': S.SKIPPED, 'route': 'cycle',
'reason': cycle_stop})
# ------------------------------------------------------------------ phase C
#
# The OLD route, kept: requested here, run later, by a process that outlives
# this one. Everything it needs goes into a request file -- `pyrevit run`
# passes no arguments to the script it runs, so a file named by an environment
# variable is the only handoff there is.
#
# This is the only place `snapshot.after_close` still means anything.
if closing and blocker is None:
run_id = 'r{0}'.format(time.strftime('%Y%m%d-%H%M%S'))
base = index.doc_key(doc) or 'untitled'
plan = S.plan(detached_dir, base, layout=layout)
launched = None
try:
req_path = _write_request(run_id, plan, _source_block())
argv = [liftexport.find_python(know), DEFER_TOOL,
'--request', req_path,
'--runner', RUNNER,
'--pyrevit', _pyrevit_cli(),
'--wait-pid', str(_revit_pid()),
'--wait-minutes', str(wait_minutes),
# The close-model ritual is the one case where waiting for the
# whole machine to be free is still the right answer: nobody
# is at the keyboard and nothing else is queued behind it.
'--machine-free', 'require']
if revit_year:
argv.extend(['--revit', str(revit_year)])
_spawn(argv)
launched = argv
except Exception:
launched = None
blocker = 'the deferred process could not be started: {0}'.format(
traceback.format_exc().splitlines()[-1])
output.print_md('---')
output.print_md('## Phase C - the detached copy, after Revit exits')
if launched:
output.print_md('Requested as `{0}`. It waits for this Revit '
'(pid {1}) to exit -- and then for the machine to be '
'free of Revit altogether -- then opens the model in a '
'session of its own and writes:'
.format(run_id, _revit_pid()))
output.print_md('`{0}`'.format(plan['path']))
if plan.get('warning'):
output.print_md('**{0}**'.format(plan['warning']))
output.print_md('The command it will run:')
output.print_md('```\n{0}\n```'.format(S.quote(
S.pyrevit_run_argv(_pyrevit_cli(), RUNNER, revit_year))))
output.print_md('Its outcome is journalled to `{0}` and read out the '
'next time this button is pressed.'.format(log))
if log:
S.append(log, {'event': S.REQUESTED, 'run': run_id,
'path': plan['path'], 'request': req_path,
'route': 'after-close', 'wait_pid': _revit_pid()})
else:
output.print_md('**Not requested: {0}**'.format(blocker))
if log:
S.append(log, {'event': S.SKIPPED, 'run': run_id,
'route': 'after-close', 'reason': blocker})
elif closing:
output.print_md('---')
output.print_md('## Phase C - not run')
output.print_md('No detached copy: {0}'.format(blocker))
if log:
S.append(log, {'event': S.SKIPPED, 'route': 'after-close',
'reason': blocker})
# ------------------------------------------------------------------ phase B
output.print_md('---')
if not closing:
if cycling:
output.print_md('Revit stays open. The index is written and stamped, '
'and the cycle is running in processes of its own -- '
'nothing here was closed and your model was never '
'touched.')
else:
output.print_md('Revit stays open, as asked. The index is written and '
'stamped; nothing was closed.')
# A normal completion for the STAY / cycle choices: Revit stays open,
# the snapshot index is on disk. The CLOSE path below records its own
# completion (Revit is about to exit, so this is the last write).
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Revit stays open. Index written: {0}'
.format(report['path'] if report else '?')))
script.exit()
output.print_md('## Phase B - closing Revit')
output.print_md('Revit will ask you about unsaved changes if it has any. '
'That question is yours to answer -- this button never syncs '
'and never saves the model.')
posted = False
# There is no PostableCommand for quitting, so the internal command id is
# used -- `ID_APP_EXIT`, the same one pyRevit's own journal runner posts to
# end a headless session (`rjm/templates.py`). pyRevit wraps the lookup in
# `HOST_APP.post_command`; the raw form is kept as a fallback because that
# helper is a version-dependent convenience and this is the one step of the
# button that cannot be retried by pressing it again.
try:
from pyrevit import HOST_APP
HOST_APP.post_command('ID_APP_EXIT')
posted = True
except Exception:
try:
from pyrevit import HOST_APP
from Autodesk.Revit.UI import RevitCommandId
cmd = RevitCommandId.LookupCommandId('ID_APP_EXIT')
if cmd is not None:
HOST_APP.uiapp.PostCommand(cmd)
posted = True
except Exception:
posted = False
if not posted:
output.print_md('**Revit would not accept the close command.** The '
'snapshot is written and stamped; close Revit yourself.')
# The CLOSE path's completion. If the close command was accepted Revit is
# about to exit, so this record is the button's last write; if not, the
# run still produced its snapshot and says so.
runlog.record('Snapshot Close', doc_title=doc.Title, outcome='ok',
report_path=report['path'] if report else None,
report_text=('Close posted: {0}. Index written: {1}'
.format(posted,
report['path'] if report else '?')))
except Exception as e:
# The deliberate exits above never reach here (script.exit() raises
# SystemExit, which Exception does not catch) -- this is the
# unexpected-exception path, and pyRevit shows its own dialog after
# the record is written.
runlog.record('Snapshot Close',
doc_title=doc.Title if doc is not None else None,
outcome='error', error=str(e),
traceback_text=traceback.format_exc())
raise
@@ -0,0 +1,28 @@
# The panel's name on the ribbon is the FOLDER name ("Tools"), not a
# `title:` here -- `uimaker._produce_ui_panels` calls
# `create_ribbon_panel(panel.name)`. Rename the folder to rename the panel.
#
# REORGANISED 2026-09-05, on the owner's own instruction: "把 ribbon 重新
# organize 一下,加一个 Modeling pulldown,把 Reinforcing 和 TicketQC 放进去".
# This panel used to hold four large icons side by side (reinforce, LIFT,
# ticketqc, snapshot -- see the 2026-08-30 note this comment replaces). Two
# of those four moved into a new `Modeling.pulldown` (ReinforceSuggest and
# TicketQC, joined there by the new ReinforcingQC), and the third,
# PanelLiftCheck, moved the other way onto Design.panel/DesignHub.pulldown --
# see that bundle's own comment for why. SnapshotClose is the one large icon
# still on this panel: it never belonged to the modelling/ticketing group,
# it closes and archives a session, so it keeps its own button rather than
# joining a pulldown about a different kind of work.
#
# What is left is one pulldown and one large button, side by side. Nothing
# half-built may join them -- that is what Setup and Design are for.
#
# Every title here keeps each line under ~9 characters. A large ribbon button
# is as wide as its widest line of text, so one long title makes one button
# dwarf its neighbours, which is exactly what the owner objected to when
# these four were spread across three panels.
#
# Anything not listed here is not created. Add a button, add it here.
layout:
- Modeling
- SnapshotClose
@@ -0,0 +1,20 @@
# Panel order on the tab, left to right.
#
# Without this key pyRevit falls back to whatever order the filesystem hands
# it, which is alphabetical -- that is how "Agent" ended up left of "QC".
# The tab's own name on the ribbon is the FOLDER name (WillTools.tab):
# `uimaker._produce_ui_tab` calls `create_ribbon_tab(tab.name)` and never
# looks at a `title:` here, so do not add one expecting it to render.
#
# Four panels, and the split between them is what the owner asked for on
# 2026-08-30: the four finished tools stand together as large icons, the
# set-up commands collapse behind one arrow, the unbuilt design work
# collapses behind another, and GOD_MOD keeps its own corner.
#
# A panel not listed below is not created at all
# (`genericcomps.GenericUIContainer.__iter__`). Add a panel, add it here.
layout:
- Tools
- Setup
- Design
- Lab
+8
View File
@@ -0,0 +1,8 @@
{
"name": "WillTools",
"description": "Precast shop-ticket QC + live vibe-coding bridge",
"author": "will",
"version": "1.0.0",
"rocket_mode_compatible": true,
"default_enabled": true
}
+25
View File
@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
"""Shared library for the WillTools extension.
Anything in `<extension>/lib` is importable from every button script and from
`/vibe/exec` snippets:
from vibe import q, p, tx, out
"""
from vibe.helpers import ( # noqa: F401
doc, uidoc, uiapp,
q, all_of, types_of, by_name, selected, pick,
p, set_p, p_display,
tx, txg,
ft, mm, m, sqft_to_sqm,
out, table, log,
)
__all__ = [
'doc', 'uidoc', 'uiapp',
'q', 'all_of', 'types_of', 'by_name', 'selected', 'pick',
'p', 'set_p', 'p_display',
'tx', 'txg',
'ft', 'mm', 'm', 'sqft_to_sqm',
'out', 'table', 'log',
]
+316
View File
@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
"""Launch the second, AI extraction pass over a scraped design summary.
OWNER'S RULING, 2026-09-01 (Discord), approving the two-stage-extraction
proposal: "可以,不过别用opus,用dsv4f带vision吧" -- yes, but not opus, use
DSV4F with vision. That REPLACES the opus-via-Claude-Code-CLI path this
module used to launch. `source.extractor` is now stamped 'dsv4f-vision' by
the new pass (see `tools/aiextract_run.py`, which does the stamping -- this
module only launches it); specs written under the earlier rulings still say
'opus' or, before the model was named at all, 'claude-code'. Nothing reads
that field to make a decision (see `ExtractDesign/script.py` and
`specformat._SOURCE_NOTE`), so a reader only ever has to tolerate whichever
of the four values it finds, never branch on it.
WHERE THE OLD OPUS-CLI PATH WENT, AND WHY IT WAS DELETED RATHER THAN KEPT
BEHIND A FLAG
--------------------------------------------------------------------------
The old `PROMPT` / `build_prompt()` / `launch()` here spawned the Claude
Code CLI (`claude -p --model opus`) with a prompt written against an OLDER
spec shape -- it still asked for `spacings_in` / `positions_in` /
`strands_per_row` under `reinforcing.strand_layout`, three fields that
stopped existing the day the spec grew its `columns_in` / `rows` matrix
shape (commit `73fdd9a`, "spec 改成人类可读 YAML + 矩阵 + 去掉 AI") -- because
once the mechanical reader stopped calling this module, nothing ever ran
that prompt again to notice it had drifted. A dead code path that LOOKS
live is worse than an absent one: keeping it "behind a flag" would mean
shipping a second, silently-wrong extraction contract that only gets
exercised the day somebody flips the flag back on, which is exactly the
kind of quiet failure this codebase's other rules (see CLAUDE.md's
"'Could not judge' and 'judged, found nothing' are different answers")
exist to prevent. It is deleted outright. `find_claude()` (below) is
UNRELATED to that decision and stays: Self Check and the GOD_MOD button
both still use it to report whether the Claude Code CLI is installed on
this machine, independent of anything this module extracts.
The AI pass runs as a separate detached CPython process, same as the old
one did. That is still deliberate, for the same two reasons:
* a pushbutton script runs on Revit's UI thread, and a model call taking
a couple of minutes would freeze Revit for a couple of minutes;
* the extraction touches no Revit API at all -- it reads JSON and PNG
files and writes a spec file -- so there is no reason for it to run
inside Revit. `tools/aiextract_run.py` is plain CPython (dsh is talked
to over local HTTP, and IronPython 2.7 has no HTTP client worth
trusting with that).
THE NEVER-OVERWRITE CONTRACT lives in `aiextract_run.apply_ai_fill`, not
here: this module's whole job is gathering paths and starting that process.
See that module's docstring for the merge rules and for the DSV4F pricing
note (off-peak is the owner's own daytime use of the harness; a button
press is inherently daytime, so this never competes with that window).
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
from vibe import designspec
# The CLI is installed per-user and lands in a different place depending on how
# it was installed, so it is looked up rather than assumed. Set the
# CLAUDE_CODE_EXE environment variable to override on an unusual machine.
#
# UNRELATED TO THE AI EXTRACTION PASS below -- see the module docstring.
# `find_claude()` only ever answers "is the Claude Code CLI installed here",
# for Self Check and GOD_MOD.
_CLAUDE_NAMES = ('claude.exe', 'claude.cmd', 'claude.bat', 'claude')
_CLAUDE_DIRS = (
os.path.join(os.path.expanduser('~'), '.local', 'bin'),
os.path.join(os.environ.get('APPDATA', ''), 'npm'),
os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Programs', 'claude'),
os.path.join(os.path.expanduser('~'), 'AppData', 'Roaming', 'npm'),
)
def find_claude():
"""Absolute path to the Claude Code CLI, or None when it is not installed."""
override = os.environ.get('CLAUDE_CODE_EXE')
if override and os.path.isfile(override):
return override
dirs = list(_CLAUDE_DIRS) + os.environ.get('PATH', '').split(os.pathsep)
for folder in dirs:
if not folder:
continue
for name in _CLAUDE_NAMES:
candidate = os.path.join(folder, name)
if os.path.isfile(candidate):
return candidate
return None
def _datum_clause(know):
"""Tell the extractor which rail the strand chain is measured from.
Plant knowledge, not a guess: reading the chain from the wrong end mirrors
the whole pattern, and nothing downstream would look wrong.
"""
from vibe import knowledge as K
datum = K.get(know, 'rails.strand_datum', 'left_rail')
fixed = K.get(know, 'rails.left_rail_is_fixed', None)
where = {
'left_rail': 'the LEFT rail -- the left-hand edge where the concrete '
'starts. The first segment of the chain is that edge to '
'the first strand.',
'right_rail': 'the RIGHT rail -- the right-hand edge where the '
'concrete starts, reading right to left.',
'centerline': 'the CENTERLINE of the piece.',
}.get(datum, datum)
if fixed is True:
rail = ' That rail is the bed\'s fixed rail.'
elif fixed is False:
rail = (' That rail is NOT the bed\'s fixed rail on this job, so do '
'not assume dimensions accumulate away from it.')
else:
rail = (' Nobody has recorded whether that rail is the bed\'s fixed '
'rail, so do not reason about which edge tolerance '
'accumulates from.')
return '{0} (datum = "{1}").{2}'.format(where, datum, rail)
def prepare(doc, design, know=None):
"""Scrape the summary and write the evidence file. Returns its path.
know carries the plant's nomenclature, which decides which sheets make up
this design's packet, and its rails, which decide how the strand chain
reads.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
evidence = designspec.gather_evidence(doc, design, know=know)
path = designspec.evidence_path(doc, design)
designspec.write_json(path, evidence)
return path, evidence
# dsh's local HTTP RPC. Overridable per-machine/testing, same idiom as
# WILLTOOLS_JOBS and CLAUDE_CODE_EXE elsewhere in this file.
DSH_URL_ENV = 'WILLTOOLS_DSH_URL'
DEFAULT_DSH_BASE_URL = 'http://127.0.0.1:3080'
# The model name and timeout are resolved on the CPython side
# (`aiextract_run.resolve_model`) so the real ~/.dsh/settings.yaml can be
# read there -- IronPython has no reason to parse that file too, and a
# second parser is a second place for the two to disagree. Set either
# override here if it ever needs to travel from inside Revit instead
# (job.plant knowledge, say); today neither does.
MODEL_ENV = 'WILLTOOLS_DSV4F_MODEL'
TIMEOUT_ENV = 'WILLTOOLS_DSV4F_TIMEOUT_S'
# Vision model on a long prompt + drawing PNG measured well over 180s to
# produce even a first assistant message on 2026-09-03 -- the old 180s
# default cancelled a session that was still working, never one that had
# failed. Keep in lockstep with the identically-named constant in
# tools/aiextract_run.py.
DEFAULT_TIMEOUT_S = 900
def _repo_root():
"""The checkout root, from this file's own path.
Same idiom as `liftexport.panellift_src()`: three `dirname()` calls off
this file strip vibe/lib/WillTools.extension, landing on the folder that
holds both `WillTools.extension` and `tools`.
"""
here = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))) # WillTools.extension
return os.path.dirname(here)
def _job_dict(doc, design, know, image_paths):
"""The plain-data job description `tools/aiextract_run.py` reads.
Pure data assembly -- no process, no file write -- so it can be
inspected on its own. `launch()` is the only caller that also writes it
to disk and starts the process.
"""
return {
'design_number': design,
'evidence_path': designspec.evidence_path(doc, design),
'spec_path': designspec.spec_path(doc, design, 'yaml'),
'image_paths': list(image_paths or []),
'datum_clause': _datum_clause(know),
'dsh_base_url': os.environ.get(DSH_URL_ENV) or DEFAULT_DSH_BASE_URL,
'model': os.environ.get(MODEL_ENV) or None,
'timeout_s': int(os.environ.get(TIMEOUT_ENV) or DEFAULT_TIMEOUT_S),
'log_path': designspec.log_path(doc, design),
}
def launch(doc, design, work_dir=None, know=None, image_paths=None):
"""Start a detached process that runs the DSV4F-vision AI pass.
Returns the started `Process`. The window stays open (same as the old
opus-CLI path did) so the prompt, the reply and the merge outcome can be
read; the spec on disk is updated in place -- or left exactly as the
mechanical reader wrote it, with one new `unresolved` line -- regardless
of whether anybody is watching the window.
`image_paths` are PNGs of the design's summary view for the model to
read with its own vision tool (see `tools/aiextract_run.py`'s prompt) --
rendering them is Revit API work and is the caller's job (the
ExtractDesign button), not this module's: this file already has no
other reason to touch a live View.
"""
if know is None:
from vibe import knowledge as K
know = K.load(doc)
from vibe import liftexport
from System.Diagnostics import Process, ProcessStartInfo
python_exe = liftexport.find_python(know)
if not python_exe:
raise IOError(
'No CPython 3 found to run the AI pass with. Set '
'handling.python_exe in this job\'s knowledge file, or the '
'WILLTOOLS_PYTHON environment variable -- the same setting '
'Ticket QC\'s vision escalation uses.')
root = _repo_root()
runner_script = os.path.join(root, 'tools', 'aiextract_run.py')
job = _job_dict(doc, design, know, image_paths)
job_path = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.aijob.json')
designspec.write_json(job_path, job)
runner = os.path.join(designspec.sub_dir(doc, 'log'),
designspec._safe(design) + '.run.cmd')
log = designspec.log_path(doc, design)
with io.open(runner, 'w', encoding='utf-8') as fh:
fh.write(u'@echo off\r\n')
fh.write(u'chcp 65001 >nul\r\n')
fh.write(u'set "PYTHONIOENCODING=utf-8"\r\n')
fh.write(u'title AI pass (DSV4F vision) - {0}\r\n'.format(design))
fh.write(u'cd /d "{0}"\r\n'.format(work_dir or root))
fh.write(u'echo Running the DSV4F-vision AI pass for {0} ...\r\n'
.format(design))
fh.write(u'echo.\r\n')
fh.write(u'"{0}" "{1}" --job "{2}" > "{3}" 2>&1\r\n'
.format(python_exe, runner_script, job_path, log))
fh.write(u'type "{0}"\r\n'.format(log))
fh.write(u'echo.\r\n')
fh.write(u'echo [done - spec updated in place: specs\\{0}.yaml]\r\n'
.format(design))
fh.write(u'pause >nul\r\n')
psi = ProcessStartInfo()
wt = os.path.join(os.environ.get('LOCALAPPDATA', ''),
'Microsoft', 'WindowsApps', 'wt.exe')
if os.path.isfile(wt):
psi.FileName = wt
psi.Arguments = 'cmd.exe /c "{0}"'.format(runner)
else:
psi.FileName = 'cmd.exe'
psi.Arguments = '/c "{0}"'.format(runner)
psi.UseShellExecute = True
return Process.Start(psi)
# ------------------------------------------------------- independent check
def cross_check(spec, regex_spec):
"""Compare the extracted spec against the old regex reading of the summary.
Two readings that agree are worth more than either alone. This is not a
correctness proof -- both can be wrong the same way -- but a disagreement
is always worth a human glance before the spec is trusted.
"""
from vibe import ticketqc
rows = []
def row(label, ai, rx):
same = (ai is None and rx is None)
if not same and ai is not None and rx is not None:
try:
same = abs(float(ai) - float(rx)) < 0.5
except Exception:
same = str(ai).strip().upper() == str(rx).strip().upper()
rows.append({'field': label, 'ai': ai, 'regex': rx, 'agree': bool(same)})
row("f'c", designspec.get_path(spec, 'concrete.fc_psi'), regex_spec.get('fc'))
row("f'ci", designspec.get_path(spec, 'concrete.fci_psi'), regex_spec.get('fci'))
row('unit weight', designspec.get_path(spec, 'concrete.unit_weight_pcf'),
regex_spec.get('pcf'))
def mesh(value):
import re as _re
if not value:
return None
return _re.sub(r'\s+', '', str(value).upper()).replace('.0', '')
row('W.W.F. mesh', mesh(designspec.get_path(spec, 'reinforcing.wwf.mesh')),
mesh(regex_spec.get('wwf_mesh')))
ai_groups = designspec.get_path(spec, 'reinforcing.strand_groups', []) or []
ai_sig = ticketqc.strand_signature([
{'qty': g.get('qty'), 'dia': g.get('dia_in'),
'ksi': g.get('grade_ksi'), 'it': g.get('it_lb')} for g in ai_groups])
row('strand groups', ai_sig,
ticketqc.strand_signature(regex_spec.get('strands') or []))
envelope = regex_spec.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
rx = envelope.get('{0}_{1}'.format(bound.upper(), key))
row('{0} {1}'.format(dim, bound),
designspec.get_path(spec, 'envelope.{0}.{1}_ft'.format(dim, bound)),
rx[0] if rx else None)
return rows
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""Run a batch of job files on a background thread without ever throwing.
WHY THIS MODULE IS THE ONLY PLACE THIS CODE MAY LIVE.
A pyRevit button command can return while a thread it started is still
running, and pyRevit then tears down the command script's module scope.
A name looked up from that scope -- a module-level import such as `io`,
`os` or `liftexport` -- is gone. The lookup misses, and the miss happens
on a NON-MAIN thread, and Revit has no way to catch an exception on a
non-main thread: an unhandled one ends the whole process (measured
2026-08-27: UnboundNameException in a Panel Lift Check batch thread,
~7 s after the command returned; the event log named IronPython
lookup-of-global as the killer).
A thread cannot be made safe by wrapping each call -- the miss happens at
name lookup, before any body runs. It is only safe with NO global name
lookup at all. Everything this worker needs arrives as a default
parameter, bound at definition time (when the scope it was defined in is
still alive), and the whole body is wrapped so nothing can escape. A lost
log line is acceptable; a dead Revit is not.
ONE PRINCIPLE, stated because it was learned the hard way: a function
whose contract is "never raises" must keep EVERY statement inside the
protection -- including the first one. `list(compute)` can raise before
any try; so can unpacking a row; so can `os.path.splitext`. The outer
try/except below exists for exactly those, and the tests feed the worker
malformed inputs to prove it stays silent.
The free functions below are what the tests exercise: build the same
callable, inject a job that raises and a status path that cannot be
written, and assert it still does not raise. IronPython 2.7 is assumed:
no f-strings, no pathlib.
MANY AT ONCE, AND WHY THAT IS SAFE HERE.
Each job is already an isolated CPython subprocess that reads one job
file and writes its own report, log and summary beside it. Nothing is
shared between two of them -- not a file, not a number, not a random
seed -- so running eight at once is a scheduling change and not a
numerical one: the reports come out identical, only sooner. Measured
2026-08-30 on the 184-piece reference sweep: 42 minutes serial, 8.6
minutes on eight workers.
What running many at once DOES touch is coordination, and coordination
is exactly the thing this module is paranoid about. So the extra
machinery obeys the same rule as everything else here: the queue, the
lock and the thread factory are all created and bound in `make_worker`,
on the UI thread, while the defining scope is alive; the functions the
threads run are defined there too, with every name they need bound as a
default parameter. A worker thread looks nothing up. `pump` carries its
own outer try, because an exception escaping THERE is the same dead
Revit as one escaping `worker` -- there are now N threads that can kill
the process instead of one.
"""
# Most jobs a batch will compute at the same time, whatever the machine has.
# Eight subprocesses is already more than the sweep needs to fit inside a
# coffee break, and the number exists to stop a 64-core workstation from
# starting 62 of them.
MAX_WORKERS = 8
# Cores left for everything else. Revit is open, the user is still working in
# it, and a batch that makes the model unusable is a batch nobody starts.
SPARE_CORES = 2
def worker_count(os=None, cap=None, spare=None):
"""How many jobs to compute at once: min(cores - 2, 8), and never 0.
Called on the UI thread, before any thread exists, and the answer is then
bound as a default parameter -- so a background thread never asks this
question and never touches `os.environ`.
NUMBER_OF_PROCESSORS is read before `os.cpu_count`, because it is the one
that exists on IronPython 2.7 (Windows sets it for every process) and
`cpu_count` does not arrive until CPython 3.4. A machine that answers
neither gets 1, which is exactly today's behaviour.
"""
import os as _os
os = os or _os
cap = MAX_WORKERS if cap is None else cap
spare = SPARE_CORES if spare is None else spare
cores = 0
try:
cores = int(os.environ.get('NUMBER_OF_PROCESSORS') or 0)
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
try:
cores = int(os.cpu_count() or 0) # CPython 3.4+ only
except Exception: # pylint: disable=broad-except
cores = 0
if not cores:
return 1
count = cores - spare
if count < 1:
count = 1
if count > cap:
count = cap
return count
def make_worker(compute, status_path, run_job, io=None, os=None,
workers=None, threading=None):
"""A zero-argument callable that runs a batch and never raises.
compute -- list of [name, job_path] rows, as the batch engine in
Panel Lift Check builds them. The list is copied, so the
caller may reuse its own after handing it over.
status_path -- where progress lines are appended.
run_job -- callable(name, job_path, out_path) that computes one
report. It may raise; the worker records an ERROR line
and continues with the next job.
workers -- how many jobs to compute at once. None asks the machine
(see `worker_count`). 1 is the old behaviour exactly:
one job after another, on the calling thread, no extra
thread created at all.
io, os,
threading -- injected for tests (a fake io whose open raises, say).
Defaults to the real modules, bound NOW -- at def time,
while this module's scope is alive, which is the whole
point: a background thread must never look anything up
from a scope that may have been torn down.
"""
import io as _io
import os as _os
import threading as _threading
io = io or _io
os = os or _os
threading = threading or _threading
if workers is None:
workers = worker_count(os)
# One lock for the queue AND the status file. They are never held at the
# same time -- take() releases before run_job starts, note() takes it
# after -- so one lock cannot deadlock against itself, and two locks
# would be two things to reason about for no gain.
try:
lock = threading.Lock()
except Exception: # pylint: disable=broad-except
lock = None
workers = 1 # no lock, no sharing: run it the old way
def _grab(lock=lock):
try:
if lock is not None:
lock.acquire()
except Exception: # pylint: disable=broad-except
pass
def _drop(lock=lock):
try:
if lock is not None:
lock.release()
except Exception: # pylint: disable=broad-except
pass
def note(line, io=io, status_path=status_path, mode='a',
grab=_grab, drop=_drop):
"""One line into the status file. Never raises, ever.
acquire, write and release are three separate guards on purpose: a
release() on a lock that was never acquired raises, so it cannot sit
in a `finally` that runs after a failed acquire.
"""
grab()
try:
with io.open(status_path, mode, encoding='utf-8') as fh:
fh.write(line + '\n')
except Exception: # pylint: disable=broad-except
pass # cannot write the log -> stay silent
drop()
def take(queue, grab=_grab, drop=_drop):
"""The next [name, job_path], or None when the batch is empty."""
grab()
row = None
try:
if queue:
row = queue.pop(0)
except Exception: # pylint: disable=broad-except
row = None
drop()
return row
def pump(queue, take=take, note=note, run_job=run_job, os=os):
"""Drain the shared queue. Runs ON a worker thread, so it never
raises: an exception here ends the process the same way one in
`worker` does, and there are now several of these."""
try:
while True:
row = take(queue)
if row is None:
return
try:
name, job_path = row
out_path = os.path.splitext(job_path)[0] + '.html'
run_job(name, job_path, out_path)
line = 'DONE {0}'.format(name)
except Exception as exc: # pylint: disable=broad-except
# A row that would not even unpack still gets a line.
# Dropping it -- which the serial version did, by letting
# the outer guard swallow the whole rest of the batch --
# is a piece that silently never ran.
try:
line = 'ERROR {0}: {1}'.format(row[0], exc)
except Exception: # pylint: disable=broad-except
line = 'ERROR (unreadable row): {0}'.format(exc)
note(line)
except Exception: # pylint: disable=broad-except
pass
def worker(compute=compute, note=note, pump=pump, threading=threading,
workers=workers):
try:
queue = list(compute)
# The count is on the header line because a batch that is running
# eight at a time and one that is running one look identical from
# outside, and they are 42 minutes apart.
note('BATCH RUNNING ({0} panels), {1} at a time'
.format(len(queue), workers), mode='w')
helpers = []
# A counted `while` rather than `range(...)`: one fewer name this
# thread has to resolve through a scope that may be gone. The
# calling thread is worker number one, so it starts workers - 1.
started = 1
while started < workers:
started += 1
try:
thread = threading.Thread(target=pump, args=(queue,))
try:
thread.daemon = False
except Exception: # pylint: disable=broad-except
pass
thread.start()
helpers.append(thread)
except Exception: # pylint: disable=broad-except
# Could not start another thread. The ones already going
# plus this one still finish the batch, just slower.
break
pump(queue) # this thread pulls its share too
for thread in helpers:
try:
thread.join()
except Exception: # pylint: disable=broad-except
pass
note('BATCH FINISHED')
except Exception: # pylint: disable=broad-except
# "Never raises" includes the first statement. list(compute) can
# raise before any of the inner guards run; this one catches it.
pass
return worker
+423
View File
@@ -0,0 +1,423 @@
# -*- coding: utf-8 -*-
# Owner, verbatim: "我要把revit桥给你。你直接来sample_job。铁律是在live里面
# 只读不写。" -- I'm giving you the Revit bridge. Come straight into
# sample_job. The iron rule is: read-only inside live, never write.
"""Whether the bridge may serve THIS document, and how. NO REVIT, NO IO, NO STATE.
The rule this enforces is the owner's, and it was a memory until now: the
LIVE model is his, and nothing here may read it, run in it, or touch it. The
sandbox -- a timestamped detached copy, opened in a Revit session of its own
-- is the only session an agent is allowed to drive.
WHY IT HAS TO BE MECHANICAL
----------------------------
pyRevit's routes server picks its port by walking UP from the configured one
past every port a running Revit has already taken
(`serverinfo._get_next_available_port`). So "port 48884" does not mean "the
session I meant"; it means "whichever Revit started first". Restart the
sandbox while the live session is up and the live session is now on 48884 and
answering. Every request an agent then sends -- `status`, `exec`, a whole
sweep -- lands in the model it must never touch, and every one of them looks
completely normal.
Nothing about that is detectable from the client side: a `status` that says
`ok` and names the right title is exactly what a correct sandbox says (the
snapshot deliberately KEEPS the live title -- see `snapshotmeta`'s layout
note). So the refusal has to be made where the document actually is.
WHY A TITLE ALONE CANNOT DECIDE IT
-----------------------------------
`snapshotmeta.plan()`'s default layout puts the timestamp in the FOLDER and
leaves the model's own name -- and therefore `doc.Title` -- constant, because
the job folder is keyed on the title and a title that moves daily moves the
job folder with it. The consequence lands here: the sandbox copy's title is
IDENTICAL to the live model's. A denylist of titles would refuse both, which
is a bridge that refuses everything.
So the title is the ACCUSATION and the path is the ALIBI. A document whose
title is on the denylist is refused unless it can prove it is a copy:
1. its file lives under one of the snapshot roots (`snapshot.detached_dir`
plus anything in `bridge.allow_roots`), or
2. its file is the exact model named by `WILLTOOLS_SANDBOX_MODEL` in this
Revit process's own environment -- which only the pipeline that
launched the sandbox can have set.
The live cloud model has no local `PathName` at all (it is
`Autodesk Docs://...`, and the API reports it empty for a cloud document),
so it can produce neither alibi and is refused. That is the intended outcome
and it is not an accident of string matching.
WHAT IS NOT HERE, DELIBERATELY
-------------------------------
No file reading, no `knowledge.load`, no environment lookups except the two
helpers at the bottom that are handed their own input. Everything is a pure
function of arguments so `startup.py` can be tested at the function level
without a Revit -- the same shape `test_bridge_query_channel.py` had to work
around by parsing source text, which is a worse test than this one.
READ-ONLY, THE POLICY THE OWNER REVERSED THE SAME MORNING
------------------------------------------------------------
`bridge.refuse_titles` shipped 2026-09-01 to keep the bridge off the live
cloud model outright. Hours later the owner reversed it, in his own words:
"I'm giving you the Revit bridge. Come straight into sample_job. The iron
rule is: read-only inside live, never write." -- the
bridge may run on the live model now; the iron rule is it may never write to
it. `bridge.readonly_titles` is that rule. A title on it answers every read
route normally (`/status`, `/collect`, `/params`, `/selection` never carry a
transaction or arbitrary code, so they are reads by construction and never
consult this at all) and `/exec` -- the only route that can write -- refuses
when the request carries a transaction, or when the snippet text matches
`WRITE_PATTERNS` below.
`refuse_titles` and `readonly_titles` are NOT layered. A title on
`refuse_titles` is refused before anything here is reached; a title only on
`readonly_titles` is never refused outright, only stopped from writing.
THIS IS A GUARD RAIL, NOT A SANDBOX
-------------------------------------
`WRITE_PATTERNS` is a plain regex scan over snippet text, not a parser and
not a permissions model. It catches the ordinary way `/exec` writes -- an
explicit transaction, or a snippet that calls a mutating Revit API method by
its literal name -- because that is what an ordinary agent request looks
like. It does **not** catch a `.Name = ...` / `.Pinned = ...` style property
setter (Revit exposes several that mutate without any method call at all),
string-built or dynamically-dispatched calls (`getattr(el, 'De'+'lete')`),
code that reaches a write through a helper this list has not been told
about, or a snippet using `eval`/`exec` to build a call the scan cannot see.
A determined snippet gets through. This module only stops the request an
agent following instructions would send.
IronPython 2.7 on the extension side, CPython in the tests: no f-strings, no
pathlib, `os.path` throughout.
"""
# pylint: disable=broad-except,invalid-name
import ntpath
import os
import re
from vibe.snapshotmeta import MODEL_EXT
# A Revit session started by `tools/snapshot_cycle.py` carries the model it
# was launched on in its own environment. A session started from the taskbar
# does not, and cannot be made to look as though it did without somebody
# deciding to.
ENV_SANDBOX = 'WILLTOOLS_SANDBOX_MODEL'
# The denylist, semicolon-separated, for the case where the knowledge layers
# cannot be read. It is a way to STATE the answer, not a way to switch the
# guard off -- there is deliberately no off switch, because an off switch is
# the hole this module exists to close.
ENV_TITLES = 'WILLTOOLS_BRIDGE_REFUSE_TITLES'
# Extra folders whose models count as sandboxes, beyond snapshot.detached_dir.
ENV_ROOTS = 'WILLTOOLS_BRIDGE_ALLOW_ROOTS'
# The read-only list, semicolon-separated, same escape hatch as ENV_TITLES
# for a machine whose knowledge layers will not load. Stating the answer
# here does not widen what the bridge may do -- read-only is already the
# more permissive of the two lists.
ENV_READONLY_TITLES = 'WILLTOOLS_BRIDGE_READONLY_TITLES'
def normalize_title(text):
"""A title as it is compared: no model extension, trimmed, case-folded.
`doc.Title` is sometimes reported with the extension and sometimes
without, depending on version and on whether the document was opened from
a path or from the cloud, so both have to compare equal or the denylist
works on one machine and not the next.
"""
try:
text = '{0}'.format(text or '').strip()
except Exception:
return ''
if text.lower().endswith(MODEL_EXT):
text = text[:-len(MODEL_EXT)]
return text.strip().lower()
def split_titles(raw):
"""A `;`-separated (or list) denylist into a clean list of strings.
Accepts what a YAML layer produces (a list), what an environment variable
produces (one string), and a single string in the YAML -- the last is a
predictable mis-edit and reading it as a one-item list beats reading it as
a list of characters.
"""
if raw is None:
return []
if isinstance(raw, (list, tuple)):
items = list(raw)
else:
items = '{0}'.format(raw).split(';')
out = []
for item in items:
try:
text = '{0}'.format(item).strip()
except Exception:
continue
if text:
out.append(text)
return out
def _norm_path(path):
"""Normalize a model path. These are always WINDOWS paths (the bridge
only ever names Revit model files, drive letters and all) even when this
code runs on a Linux dev box, so the normalization has to use `ntpath`
rather than `os.path` -- `os.path.normcase` is a no-op on POSIX and would
silently break the case-insensitivity this promises.
"""
try:
text = '{0}'.format(path or '').strip()
except Exception:
return ''
if not text:
return ''
text = text.replace('/', ntpath.sep).replace('\\', ntpath.sep)
try:
text = ntpath.normcase(ntpath.normpath(text))
except Exception:
pass
return text.rstrip(ntpath.sep)
def is_under(path, root):
"""True when `path` sits inside `root`. Case- and separator-insensitive.
A prefix test on its own says `C:\\snapshots-old\\x` is under
`C:\\snapshots`, so the separator is required after the root.
"""
path = _norm_path(path)
root = _norm_path(root)
if not path or not root:
return False
return path == root or path.startswith(root + ntpath.sep)
def same_model(path_a, path_b):
"""True when two paths name the same model file, as strings."""
a, b = _norm_path(path_a), _norm_path(path_b)
return bool(a) and a == b
def _on_list_and_not_exempt(title, path, listed, sandbox_roots, sandbox_model):
"""True when `title` is on `listed` and this document cannot prove it is
a sandbox copy. Shared by `refusal()` (refuse_titles) and
`exec_refusal()` (readonly_titles) -- same accusation, same two alibis.
No document open (`title` falsy after normalising) is never "on the
list": there is nothing to protect and nothing to touch.
"""
banned = [normalize_title(t) for t in split_titles(listed)]
banned = [t for t in banned if t]
if not banned:
return False
here = normalize_title(title)
if not here or here not in banned:
return False
# The title is on the list. Can this document prove it is a copy?
if sandbox_model and same_model(path, sandbox_model):
return False
for root in (sandbox_roots or ()):
if root and is_under(path, root):
return False
return True
def refusal(title, path=None, titles=(), sandbox_roots=(), sandbox_model=None,
config_error=None):
"""None when the bridge may serve this document; one sentence when not.
`config_error` is how a caller says "I could not read the denylist". That
is NOT the same answer as "the denylist is empty", and it is refused --
a bridge that cannot prove it is not in the live model has not proved
anything. The refusal names the two ways to answer it, so the fix is in
the message rather than in somebody's memory.
"""
if config_error:
return ('REFUSED: this bridge cannot tell whether the open document '
'is the live model -- the refuse-titles configuration could '
'not be read ({0}). Fix the job/plant knowledge layer, or '
'state the answer in {1} (semicolon separated), then restart '
'Revit. Nothing was read or run.'
.format(config_error, ENV_TITLES))
if not _on_list_and_not_exempt(title, path, titles, sandbox_roots,
sandbox_model):
return None
return ('REFUSED: the open document is "{0}", a title on '
'bridge.refuse_titles -- the live model this bridge must never '
'touch. Nothing was read, run or changed. Its file is {1}, which '
'is under no configured snapshot root and is not {2}. If this '
'really is a detached snapshot, open it from under '
'snapshot.detached_dir, or launch its Revit with {2} set to its '
'path.'
.format(title, path or '(no local file -- a cloud model)',
ENV_SANDBOX))
# --------------------------------------------------------------- read-only
#
# See the module docstring's "READ-ONLY" section for the policy and "THIS IS
# A GUARD RAIL, NOT A SANDBOX" for what this does not catch.
# Grounded in what this codebase's own write idioms actually look like:
# `helpers.tx()` / `helpers.txg()` wrap `Transaction` / `TransactionGroup`;
# `helpers.set_p()` is `prm.Set(value)`; CLAUDE.md's "any change to the model
# needs a Transaction" and "never reload" sections name the rest. Matched
# case-sensitively -- Revit's own API is PascalCase, so this does not fire on
# an unrelated lowercase `.create(` / `.set(` in ordinary Python.
WRITE_PATTERNS = [
('Transaction(', re.compile(r'\bTransaction\s*\(')),
('SubTransaction(', re.compile(r'\bSubTransaction\s*\(')),
('TransactionGroup(', re.compile(r'\bTransactionGroup\s*\(')),
('vibe.tx() / tx()', re.compile(r'(?<!\w)tx\s*\(')),
('vibe.txg() / txg()', re.compile(r'(?<!\w)txg\s*\(')),
('vibe.set_p()', re.compile(r'\bset_p\s*\(')),
('Parameter.Set(', re.compile(r'\.Set\s*\(')),
('SetParameterValue(', re.compile(r'\.SetParameterValue\s*\(')),
('.Delete(', re.compile(r'\.Delete\w*\s*\(')),
('.Create*( -- element/type/family creation', re.compile(r'\.Create\w*\s*\(')),
('.New*( -- element/family-manager creation', re.compile(r'\.New\w*\s*\(')),
('ElementTransformUtils', re.compile(r'\bElementTransformUtils\b')),
('.Duplicate(', re.compile(r'\.Duplicate\w*\s*\(')),
('doc.Regenerate(', re.compile(r'\.Regenerate\s*\(')),
('doc.SaveAs(', re.compile(r'\.SaveAs\s*\(')),
('doc.Save(', re.compile(r'\.Save\s*\(')),
('LoadFamily(', re.compile(r'\.LoadFamily\w*\s*\(')),
('Purge', re.compile(r'\bPurge\w*\s*\(')),
('.Rename(', re.compile(r'\.Rename\s*\(')),
('WorksharingUtils / SynchronizeWithCentral',
re.compile(r'\bWorksharingUtils\b|\.SynchronizeWithCentral\s*\(')),
]
def write_signature(code):
"""The name of the first write-capable pattern found in `code`, or None.
A plain regex scan over the snippet text -- not a parser, and not a
defence against an obfuscated call. See the module docstring.
"""
text = '{0}'.format(code or '')
for name, pattern in WRITE_PATTERNS:
if pattern.search(text):
return name
return None
def exec_refusal(title, path, readonly_titles, sandbox_roots=(),
sandbox_model=None, tx_name=None, code=None):
"""None when `/exec` may run this request; one sentence when not.
Only reached once `refusal()` above has already let the request through
-- a title on `bridge.refuse_titles` is refused outright and this is
never consulted. `readonly_titles` is the softer rule: the document may
still be read, but nothing that could write to it may run.
"""
if not _on_list_and_not_exempt(title, path, readonly_titles,
sandbox_roots, sandbox_model):
return None
if tx_name:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This request asked to run '
'inside a transaction ("{1}"), so it was not run. Nothing '
'was changed.'.format(title, tx_name))
hit = write_signature(code)
if hit:
return ('REFUSED: "{0}" is on bridge.readonly_titles -- the iron '
'rule is read-only, never write. This snippet contains '
'{1}, which can write to the model, so it was not run. '
'Nothing was changed.'.format(title, hit))
return None
def payload(title, reason):
"""The refusal as a routes response body.
`ok: False` so every existing client already treats it as a failure, plus
`refused: True` so `tools/snapshot_cycle.py` can tell "the live Revit
grabbed the port" apart from "the sandbox is not up yet" -- two failures
that look identical from the outside and need opposite responses.
The title is reported even though the request is refused: "which model is
open" is the one question a refusal still has to answer, or nobody can
work out which session they reached.
"""
return {'ok': False, 'refused': True, 'doc_title': title,
'error': reason}
def config_from(know, getter, environ=None):
"""(titles, roots) out of the knowledge tree and the environment.
`getter` is `knowledge.get` passed in rather than imported, so this module
keeps its promise of importing nothing that reads a file. The environment
is a UNION with the configured values, never a replacement: a denylist you
can shrink by unsetting a variable is not a denylist.
"""
environ = os.environ if environ is None else environ
titles = []
roots = []
try:
titles.extend(split_titles(getter(know, 'bridge.refuse_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_TITLES)))
except Exception:
pass
try:
roots.extend(split_titles(getter(know, 'bridge.allow_roots', [])))
detached = getter(know, 'snapshot.detached_dir', None)
if detached:
roots.append(detached)
except Exception:
pass
try:
roots.extend(split_titles(environ.get(ENV_ROOTS)))
except Exception:
pass
seen = set()
titles = [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
seen = set()
roots = [r for r in roots if not (_norm_path(r) in seen or
seen.add(_norm_path(r)))]
return titles, roots
def readonly_titles_from(know, getter, environ=None):
"""`bridge.readonly_titles`, unioned with its environment escape hatch.
Mirrors `config_from`'s treatment of `bridge.refuse_titles` exactly. Kept
as its own function, returning one list rather than folding a third value
into `config_from`'s tuple, so every existing caller of `config_from`
(and its test, which unpacks a 2-tuple) is untouched by this. Sandbox
roots are shared with `config_from` -- a copy is a copy whichever list is
being checked against -- so this does not read `bridge.allow_roots` or
`snapshot.detached_dir` again; callers pass the roots `config_from`
already returned.
"""
environ = os.environ if environ is None else environ
titles = []
try:
titles.extend(split_titles(getter(know, 'bridge.readonly_titles', [])))
except Exception:
pass
try:
titles.extend(split_titles(environ.get(ENV_READONLY_TITLES)))
except Exception:
pass
seen = set()
return [t for t in titles if not (t.lower() in seen or seen.add(t.lower()))]
+31
View File
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""The small Python 2 / Python 3 bridge.
We do not choose the interpreter -- pyRevit does, from the engine configured
for the host. This extension was written on Revit 2025 with IronPython 2.7,
but the same folder may be dropped on a machine whose pyRevit is set to
IronPython 3 or CPython 3, where `unicode` and `basestring` are a NameError.
A NameError at module scope takes the whole button down with a traceback the
user cannot act on, so the two names are resolved once, here.
Everything else in this extension is already written in the intersection of
the two languages: no f-strings, no print statement, no implicit integer
division, no pathlib. Keep it that way and the engine stops mattering.
"""
try: # Python 2 (IronPython 2.7)
text_type = unicode # noqa: F821
string_types = (str, unicode) # noqa: F821
except NameError: # Python 3 (IronPython 3, CPython 3)
text_type = str
string_types = (str,)
def as_text(value, encoding='utf-8'):
"""Return value as the interpreter's unicode text type."""
if isinstance(value, text_type):
return value
if isinstance(value, bytes):
return value.decode(encoding)
return text_type(value)
+768
View File
@@ -0,0 +1,768 @@
# -*- coding: utf-8 -*-
"""The whole configuration of one project, written to one file.
Knowledge arrives in five layers -- built-in defaults, the catalogue, the
plant, the job, and the project config file -- and every one of them but the
defaults lives somewhere nobody browses to: the tool's own install, APPDATA,
a folder beside the model, and wherever the engineer chose to put the config
file. That is the right way to store it and the wrong way to hand it to
somebody. A reviewer asking "what was this checked against?" should get one
file, not a tour of three machines.
So this flattens the layers into a single document that says, for every
setting, what the value is and which layer it came from. `layer_values()` is
where the five are enumerated, and it is also where the one subtlety lives:
the project config layer is left out of the provenance by default, for the
reason set out in that function's own docstring. The knowledge section
is written in the same shape a knowledge.yaml has, which means the file is not
only a record: save it as the job's knowledge file and it reproduces exactly
the configuration it was taken from.
Written for IronPython 2.7: no f-strings, no pathlib, no OrderedDict literals.
"""
# pylint: disable=import-error,invalid-name,broad-except
import datetime
import os
import re
from vibe import knowledge
from vibe.compat import string_types, text_type
# The order sections come out in -- the order somebody setting a project up
# would work through them, which is not alphabetical. Anything not listed
# follows, sorted, so a new knowledge key never silently vanishes from a dump
# written by an older build.
SECTION_ORDER = (
'nomenclature', 'parameters', 'families', 'orientation', 'rails',
'legend', 'sheet', 'concrete', 'lifters', 'handling', 'interference',
'catalogue',
)
SECTION_NOTES = {
'nomenclature': 'How a design number is written, and what reduces to what',
'orientation': 'Which end of a piece is the top once it is erected',
'rails': 'The casting bed: which rail is fixed, how wide the bed is',
'parameters': "This office's Revit parameter name for each role the "
'tool reads',
'families': 'Which family names count as strand, mesh, or a lifting insert',
'lifters': 'Part number -> catalogue designation, and the checks on top',
'handling': ('Lifting and handling: stages, insert roles, where reports '
'go. rig_mode and dunnage are decided in the decisions '
'section at the top; a stage with no rig_mode here inherits '
'the default.'),
'interference': 'The clash check between members of one assembly',
'catalogue': 'Published hardware ratings. Not job data -- ships with the '
'tool',
'legend': 'The strand-pattern legend a ticket has to carry',
'sheet': 'What is compared against the printed ticket',
'concrete': 'Release strength rules',
}
def sections(know):
"""Knowledge section names, in reading order, nothing dropped.
Except the underscore keys. `_config_missing` and `_config_problems` are
notes load() leaves for config_problems ABOUT this run -- not settings --
and writing them into the file would make a transient complaint look like
a permanent setting, and hand it back to the next merge as one.
"""
listed = [key for key in SECTION_ORDER if key in know]
rest = sorted(key for key in know
if key not in SECTION_ORDER and not str(key).startswith('_'))
return listed + rest
# ------------------------------------------------------------ a YAML writer
_PLAIN = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_ ./+-]*$')
_RESERVED = ('true', 'false', 'null', 'yes', 'no', 'on', 'off', '~')
def _scalar(value):
if value is None:
return 'null'
if value is True:
return 'true'
if value is False:
return 'false'
if isinstance(value, float):
text = repr(value)
return text[:-2] if text.endswith('.0') else text
if not isinstance(value, string_types):
return str(value)
text = text_type(value)
if not text:
return "''"
if text.lower() in _RESERVED or not _PLAIN.match(text) \
or text != text.strip() or text.endswith(':'):
return "'" + text.replace("'", "''") + "'"
# A bare number-looking string has to be quoted or it reads back as a
# number, and a part number like 4.10 would come back as 4.1.
if re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', text):
return "'" + text + "'"
return text
def _is_flat(node):
"""A list of plain scalars, short enough to sit on one line."""
if not isinstance(node, list) or not node:
return False
if any(isinstance(v, (dict, list)) for v in node):
return False
return sum(len(_scalar(v)) + 2 for v in node) <= 66
def emit(node, indent=0):
"""A dict/list/scalar tree as YAML lines. Deterministic key order."""
pad = ' ' * indent
out = []
if isinstance(node, dict):
if not node:
return [pad + '{}']
for key in sorted(node):
value = node[key]
label = pad + _scalar(key) + ':'
if isinstance(value, dict) and value:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, list) and value:
if _is_flat(value):
out.append('{0} [{1}]'.format(
label, ', '.join(_scalar(v) for v in value)))
else:
out.append(label)
out.extend(emit(value, indent + 1))
elif isinstance(value, (dict, list)):
out.append('{0} {1}'.format(label,
'{}' if isinstance(value, dict)
else '[]'))
else:
out.append('{0} {1}'.format(label, _scalar(value)))
return out
if isinstance(node, list):
for item in node:
if isinstance(item, dict) and item:
lines = emit(item, indent + 1)
# The first key rides on the dash, the rest line up under it.
out.append(pad + '- ' + lines[0].strip())
out.extend(lines[1:])
elif isinstance(item, list) and item:
lines = emit(item, indent + 1)
out.append(pad + '-')
out.extend(lines)
else:
out.append(pad + '- ' + _scalar(item))
return out
return [pad + _scalar(node)]
# --------------------------------------------------------------- provenance
def _layer_rows(doc, include_config=False):
"""[(label, path, exists, data, unreadable)] -- what layer_values() and
layer_problems() are both built out of.
`unreadable` is `None` for an ordinary layer (absent, or present and read
clean) and the parser's message (a non-empty string) for a layer whose
file IS there and would not parse.
This used to be `layer_values()` itself, calling
`knowledge._merge_file(holder, path)` with NO `layer` argument -- which
means `_note_layer_unreadable` returned immediately (`if not layer: ...`)
and a file that exists and will not parse came back as `data = {}`,
indistinguishable from a file that genuinely sets nothing. The five-layer
merge in `knowledge.load()` was never fooled by this -- it always passes
the token -- so a real run's `_plant_unreadable` marker was sitting right
there in `know`; only THIS dump's own, separate re-read of the files
(needed to say which layer a value came from) was blind to it. Threading
the token through here, and popping the marker back out of `data` before
handing it to a caller, fixes that without changing what a marker-free
`data` dict means to `origins()` / `overridden()` / every existing caller
of `layer_values()`.
"""
from vibe import designspec
layers = [('defaults', None, True,
knowledge._deep_copy(knowledge.DEFAULTS), None)]
# The plant layer is per plant, and which plant is a key in the JOB layer
# -- so it has to be resolved through the same pre-pass load() uses, or
# this dump attributes settings to a file the run never read.
# The token doubles as the label for these three -- 'catalogue', 'plant'
# and 'job' are both what `_FILE_LAYERS` calls the layer and what a
# human-readable line calls it.
for label, path in (('catalogue', knowledge.catalogue_path()),
('plant',
designspec.plant_knowledge_path(
knowledge.plant_id(doc))),
('job', designspec.knowledge_path(doc))):
layer = label
data = {}
unreadable = None
exists = bool(path and os.path.isfile(path))
if exists:
holder = {}
knowledge._merge_file(holder, path, layer)
marker = holder.pop(knowledge._unreadable_key(layer), None)
if marker:
unreadable = marker[1]
data = holder
layers.append((label, path, exists, data, unreadable))
if include_config:
path = designspec.active_config_path(doc)
if path:
data = {}
unreadable = None
exists = os.path.isfile(path)
if exists:
# Through _merge_config_file, not _merge_file: the file's
# `decisions:` block is mapped back onto the keys really read,
# and it beats the tree below it inside the same file. A
# provenance view that read only the tree would name the wrong
# value for the eight settings that have both spellings.
# _merge_config_file already threads 'config' through to
# _merge_file on its own (`notes=merged`), so the marker
# lands on `holder` here the same way it does for the other
# three layers -- just popped back out below instead.
holder = {}
knowledge._merge_config_file(holder, path)
marker = holder.pop(knowledge._unreadable_key('config'), None)
if marker:
unreadable = marker[1]
data = holder
layers.append(('config', path, exists, data, unreadable))
return layers
def layer_values(doc, include_config=False):
"""[(label, path, exists, data)] for every layer, in the order they merge.
Read separately from load() so the dump can say which layer a value came
from. Saying only what a setting ended up as leaves the reader unable to
tell a deliberate plant rule from a default nobody has looked at.
The project config layer is deliberately NOT included by default. This is
what writes it: folding the previous version of the file into the
provenance would stamp every setting as coming from the file about to be
overwritten, and the answer would be true and useless. What a reader wants
is which of the underlying layers each setting really came from.
`include_config=True` adds it, for the one caller that needs the opposite
answer: an editor showing what is in force has to show the layer that is
WINNING, and while a config file is live that is the config file. Leaving
it out there would show somebody a plant value that nothing is reading.
The flag is additive rather than a change of default so the dump's own
provenance keeps meaning what it has always meant.
`data` never carries an `_<layer>_unreadable` marker -- `_layer_rows()`
pops it back out before this wraps the row. A caller that wants to know
whether a layer was unreadable wants `layer_problems()`, not a key it has
to know to go looking for inside `data`.
"""
return [(label, path, exists, data)
for label, path, exists, data, _unreadable
in _layer_rows(doc, include_config)]
def layer_problems(doc, include_config=False):
"""[(label, path, message)] for every layer whose file is on this machine
and would not parse -- in merge order, empty in the ordinary case.
This is the dump's own version of `knowledge.unreadable_layers()`: same
question, asked against the fresh per-file read `layer_values()` does
rather than against an already-merged `know` tree, because the dump
reads the layers itself (see `layer_values()`'s docstring) and has to be
right on its own -- a caller here may not have a merged tree with the
markers already on it.
"""
return [(label, path, unreadable)
for label, path, _exists, _data, unreadable
in _layer_rows(doc, include_config)
if unreadable]
def _paths(node, prefix=''):
"""Every leaf in a tree as a dotted path. Lists count as one leaf."""
found = []
if isinstance(node, dict):
for key in node:
found.extend(_paths(node[key],
prefix + '.' + str(key) if prefix else str(key)))
elif prefix:
found.append(prefix)
return found
def origins(doc):
"""{dotted key: the label of the LAST layer that set it}."""
out = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
out[path] = label
return out
def overridden(doc):
"""[(key, [(layer, value), ...])] for settings a later layer CHANGES.
The interesting rows: where a plant rule beats a default, or a job beats
its plant. A layer that restates a value it agrees with is not an
override, and listing it would bury the handful of rows that matter under
a hundred that do not.
"""
seen = {}
for label, _path, _exists, data in layer_values(doc):
for path in _paths(data):
seen.setdefault(path, []).append(
(label, knowledge.get(data, path)))
rows = []
for path in sorted(seen):
chain = seen[path]
values = [value for _label, value in chain]
if len(chain) > 1 and any(v != values[0] for v in values[1:]):
rows.append((path, chain))
return rows
# ------------------------------------------------------------ the document
def project_info(doc, know):
"""What piece of work this configuration belongs to."""
from vibe import designspec, ticketqc
info = {
'model': doc.Title,
'model_path': doc.PathName or '(unsaved)',
'qc_data_dir': designspec.root_dir(doc),
'written': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'),
}
try:
grouped = ticketqc.assemblies_by_design(doc, know)
specs = designspec.list_specs(doc) if doc.PathName else {}
designs = []
for key in sorted(grouped):
designs.append({
'design': key,
'pieces': len(grouped[key]),
'extracted_spec': key in specs,
})
info['designs'] = designs
info['piece_count'] = sum(len(v) for v in grouped.values())
except Exception as exc:
info['designs'] = []
info['designs_error'] = str(exc)
return info
DECISION_BANNER = u'''# =========================================================================
# YOUR DECISIONS
#
# Everything in this section is yours. None of it can be worked out from the
# model, and the tool will not guess at any of it -- it uses what is written
# here. Edit a value and the next run uses it.
#
# Everything after this section is either a record of how this file was built
# or the merged knowledge it was built from. Worth reading, rarely worth
# editing: anything named down there and also named up here is overruled by
# the copy up here.
# ========================================================================='''
# The comment for each decision, written directly above the value it belongs
# to rather than gathered into one block at the top of the section. A note
# four lines away from the line it describes is a note that gets edited past.
DECISION_NOTES = (
('report_dir', u'''# Where Panel Lift Check writes its HTML. Used exactly as written, so an
# absolute path anywhere works -- a job folder on a share, say. Takes effect
# on the next run.'''),
('open_report_when_done', u'''# Open each report as it is finished. The gap between a report being written
# and being read is one double-click that is easy not to take. Turn it off
# when working through a lot of pieces in a row, or forty browser tabs is the
# result.'''),
('prestress_loss_pct', u'''# Everything lost between the jack and the stage being checked, as one
# percentage. It is NOT calculated: a loss model needs an age, a humidity and
# a curing schedule this tool does not have, and a plausible-looking number
# from guessed inputs is worse than a figure you can argue with.
#
# 8 is conservative. A commercial check of one real piece computed 1.6 at
# release. LOWER IT AND EVERY PIECE GETS MORE CREDIT.'''),
('transfer_length_in', u'''# Where a cut strand has handed its force back to the concrete. Blank uses
# ACI 318's f_se * db / 3, worked out from the pull stated on the design
# rather than assumed.'''),
('insert_position', u'''# How an insert's position is measured, per run mode.
#
# VALID VALUES -- write exactly one of these:
# fast the element's cached bounding box centre. No geometry solve.
# precise the solved solid centroid.
#
# They agree on a symmetric embed and disagree by a few inches on an L-shaped
# one -- and a face lifter's position IS the moment arm it hangs on. On-edge
# anchors are snapped to the edge either way. 'precise' is roughly an order of
# magnitude slower.
#
# The two modes are set apart on purpose. A batch runs unattended, so the time
# costs nobody anything and it should be right. One piece at a time is
# somebody waiting at the screen -- and a piece close enough for a few inches
# to decide it is a piece to put through Eriksson, not to squeeze a sharper
# number out of here.
#
# one_piece Panel Lift Check on a single selected assembly
# batch Panel Lift Check on several, the sweep, and the run Ticket QC
# starts for itself'''),
('rig_mode', u'''# How the piece hangs. This changes the mechanics, not just a coefficient.
#
# VALID VALUES -- write exactly one of these:
# rolling equalising blocks / roller sheaves. Every line in a cable group
# carries the same force; two groups, statically determinate.
# static fixed-length slings / static lift. A continuous beam on rigid
# supports; the reactions follow stiffness, so a blockout beside a
# support really does change them.
#
# NOT VALID: static_lift, static_lifting, fixed, 1, yes. A misspelled value is
# REFUSED, not silently inherited -- a typo that looked like "inherit" is
# exactly the kind of quiet wrongness this section exists to prevent.
#
# "default" applies to every stage. A stage named below overrides it for that
# stage only; put null / leave it blank there to inherit the default.
#
# Example -- strip with static slings, erect with equalising blocks:
# default: rolling
# stripping: static
# transportation: null
# erection: rolling
#
# Worth settling before anything else is tuned. A commercial check of one real
# piece stripped on fixed slings and erected on an equalising block, and its
# stripping moment came out 14.1 k-ft where one rolling mode everywhere gives
# 23.8. Which is right depends on how YOUR yard rigs -- nothing here guesses
# it.'''),
('group_tolerance_ft', u'''# How far apart ALONG THE PIECE two inserts may sit and still count as one
# pick row.
#
# The beam solve hangs each ROW from one rigid support line -- but the job
# names individual inserts and nothing names rows, so this tolerance is what
# decides who belongs together. Top view, x running along the piece's length:
#
# x=14.92 x=15.92
# | |
# edge o--+ | the edge anchor and face insert B are flush;
# B o--+ | C belongs to the SAME row but sits 1.00 ft
# | C o---+ further back along the piece.
#
# gap <= group_tolerance_ft -> A, B, C hang as ONE row, one support line.
# gap > group_tolerance_ft -> C becomes an extra "row" of its own. The
# solve then balances on a support that does
# not exist: one line goes NEGATIVE (pulled
# down) while its neighbour overloads -- on
# a real panel this read -936 lb on one
# insert and 116% of rating on the next.
#
# SET IT just above the largest stagger your yard actually leaves within a
# row. Too large merges two rows that were genuinely separate -- the two
# failure modes point opposite ways, so this number is yours to set, not a
# default to trust.'''),
('dunnage', u'''# Where the piece sits on the trailer during transportation.
#
# VALID VALUES for mode:
# null old lift approximation (transportation is still checked as a
# pick; the report labels it approximate)
# lifter_rows dunnage under the same lines as the stripping rows
# fraction two dunnage lines inset fraction*L from each end
#
# fraction is only used when mode is fraction, e.g. 0.2 means supports at
# 0.2L and 0.8L. This is plant knowledge; it is lifted up here so you can see
# and edit it without collapsing the whole knowledge tree.'''),
)
def report_dir(doc, know):
"""Where handling reports go: what was set, or beside the model."""
from vibe import designspec
return (knowledge.get(know, 'handling.report_dir') or
os.path.join(designspec.root_dir(doc), 'handling'))
def decisions(doc, know):
"""Everything in this file that is the engineer's to decide, in one place.
Every one of these lives three or four levels down the merged knowledge
tree, which is where it is read from and a hopeless place to find it.
Each is here because it was asked for by name: where the reports go,
whether they open, the prestress loss, the transfer length, how the
piece hangs at each stage, and how much stagger within a pick row still
counts as one row.
They come FIRST in the file on purpose. A config file is read from the top
and abandoned somewhere in the middle, so the part somebody opened it to
change has to sit above the part they did not.
"""
modes = {'default': knowledge.get(know, 'handling.rig_mode', 'rolling')}
for stage in knowledge.get(know, 'handling.stages', []) or []:
if stage.get('name'):
modes[stage['name']] = stage.get('rig_mode')
fast = knowledge.get(know, 'handling.fast_lifter_box', True)
fast_batch = knowledge.get(know, 'handling.fast_lifter_box_batch', False)
return {
'report_dir': report_dir(doc, know),
'open_report_when_done': knowledge.get(know, 'handling.open_report',
True),
'prestress_loss_pct': knowledge.get(know,
'handling.prestress_loss_pct',
8.0),
'transfer_length_in': knowledge.get(know,
'handling.transfer_length_in'),
'group_tolerance_ft': knowledge.get(know,
'handling.group_tolerance_ft',
1.0),
'dunnage': knowledge.get(know, 'handling.dunnage') or {},
'insert_position': {
'one_piece': 'fast' if fast else 'precise',
'batch': 'fast' if fast_batch else 'precise',
},
'rig_mode': modes,
}
def decisions_block(doc, know):
"""The decisions section as text: every value under its own comment."""
body = decisions(doc, know)
out = [DECISION_BANNER, u'', u'decisions:']
for key, note in DECISION_NOTES:
out.append(u'')
out.append(u'\n'.join(u' ' + line for line in note.split(u'\n')))
out.extend(emit({key: body[key]}, 1))
return out
def output_locations(doc, know):
"""Where each thing this tool produces is written.
Every path here is DERIVED from where the model sits. None of it is a
setting any more -- the one that was, `lifting_check_reports`, moved up
into `decisions` as `report_dir`. Two editable copies of one path is
exactly the trap this file exists not to be.
They are still listed, because "where did the specs go" is a question
people have and printing the answer beats hunting for it.
"""
from vibe import designspec
return {
'extracted_specs': designspec.sub_dir(doc, 'specs'),
'evidence': designspec.sub_dir(doc, 'evidence'),
'logs': designspec.sub_dir(doc, 'log'),
'job_knowledge_file': designspec.knowledge_path(doc),
# Resolved for THIS job's plant, not the machine-wide file -- see
# knowledge.plant_id() and the two-pass note in knowledge.load().
'plant_knowledge_file': designspec.plant_knowledge_path(
knowledge.plant_id(doc)),
}
LOCATION_NOTES = (
u'# All derived from where the model sits. Shown so you do not have to\n'
u'# hunt for them; editing them does nothing. The one path here that IS a\n'
u'# setting is report_dir, at the top of this file.'
)
HEADER = u"""# WillTools project configuration
#
# model {model}
# written {when}
#
# THIS FILE IS LIVE. It is the configuration {model} runs on, and it is the
# last word -- every setting in here beats the same setting in the layered
# files it was built from. Edit it and the next run uses what you wrote.
#
# It got that way because somebody pressed Project Config. A one-line pointer
# beside the model records where this file is:
#
# {pointer}
#
# Delete that pointer, or press Project Config again, and the model goes back
# to the layered files. Nothing else reads this path.
#
# The layers underneath, in the order they override each other. This file sits
# after all of them:
#
{layers}
#
# One consequence worth knowing. The `knowledge:` section below is the MERGED
# result, so it contains the plant's settings as values of its own. While this
# file is live, editing the plant file no longer reaches this model -- the
# copy in here wins. That is the point of pinning a configuration to a job,
# and it is also how a plant-wide fix quietly fails to arrive. When you want
# the plant layer back, clear the pointer.
"""
# Only ever appended when `layer_problems()` is non-empty. An unreadable
# layer and an empty one are OPPOSITE conditions -- one is a 255KB plant file
# silently voided (the 2026-09-01 incident that `knowledge._merge_file`'s
# marker exists for), the other is genuinely nothing -- and this file is
# where a human looks first, so the two must never render the same way.
# `# set by:` in the KNOWLEDGE section below only ever names layers that DID
# contribute a value; a layer that could not be read contributes none, which
# is exactly why it needs a banner of its own instead of relying on that line
# to mention it.
UNREADABLE_LAYER_BANNER = u'''
# =========================================================================
# ONE OR MORE KNOWLEDGE LAYERS COULD NOT BE READ
#
# Each layer named below is a file that IS on this machine, and the YAML
# reader refused it. A half-parsed layer is never merged, so NONE of that
# file was used -- every value it would have set has fallen back to
# whatever the layer underneath it says. A layer that fails to load looks
# EXACTLY like a layer that agrees with the defaults; nothing below this
# banner can tell you that happened, only this can.
#
{rows}
# Fix the file and dump again -- a key stated twice inside one mapping is
# the usual cause, and `python tools/knowledgelint.py` names it with a line
# number. The tool will not guess at what the file said.
# ========================================================================='''
def unreadable_layer_banner(problems):
"""The banner text for `layer_problems()`'s rows, or '' when there are none.
Split out from `build()` so a caller assembling its own view of the same
facts (see `layer_problems()`) does not have to reconstruct this from
scratch -- and so a test can check the wording without going through a
live `doc`.
"""
if not problems:
return u''
rows = u'#\n'.join(
u'# {0} layer -- {1}\n# {2}\n'.format(label, path, message)
for label, path, message in problems)
return UNREADABLE_LAYER_BANNER.format(rows=rows)
def _layer_status_suffix(exists, unreadable):
"""The trailing note on one layer's line in the header's layer listing.
Three facts, three different suffixes -- collapsing any two of them is
exactly the bug this exists to prevent: 'read clean' (''), 'not present'
(the file is not there, ordinary and quiet) and 'unreadable' (the file
IS there and the YAML reader refused it, which is the loud one and must
never look like either of the other two, and least of all like 'read
clean and simply set nothing').
"""
if unreadable:
return u' !! UNREADABLE !!'
if not exists:
return u' -- not present'
return u''
def build(doc, know):
"""The whole document, as text."""
from vibe import designspec
rows = _layer_rows(doc)
layer_lines = [
u'# {0:<10} {1}{2}'.format(
label, path or '(built into the tool)',
_layer_status_suffix(exists, unreadable))
for label, path, exists, _data, unreadable in rows]
body = {
'decisions': decisions(doc, know),
'project': project_info(doc, know),
'output_locations': output_locations(doc, know),
'knowledge': know,
}
text = [HEADER.format(when=datetime.datetime.now()
.strftime('%Y-%m-%d %H:%M'),
model=doc.Title,
pointer=designspec.config_pointer_path(doc),
layers=u'\n'.join(layer_lines))]
problems = [(label, path, message)
for label, path, _exists, _data, message in rows
if message]
banner = unreadable_layer_banner(problems)
if banner:
text.append(banner)
# Above everything else, because a config file is read from the top and
# abandoned in the middle.
text.append(u'')
text.append(u'\n'.join(decisions_block(doc, know)))
text.append(u'\n# What this configuration belongs to. Read for the record,'
u' not by the tool.')
text.append(u'project:')
text.append(u'\n'.join(emit(body['project'], 1)))
text.append(u'\n# Where output goes.\n#\n' + LOCATION_NOTES)
text.append(u'output_locations:')
text.append(u'\n'.join(emit(body['output_locations'], 1)))
rows = overridden(doc)
if rows:
text.append(u'\n# ---- settings more than one layer touches ----------'
u'--------------------\n#\n'
u'# Where a plant rule beats a default, or a job beats its '
u'plant. The last\n# entry on each line is the one in '
u'force.\n#')
for path, chain in rows:
text.append(u'# {0}\n# {1}'.format(
path, u' -> '.join(
u'{0}: {1}'.format(label, _scalar(value))
for label, value in chain)))
text.append(u'\n# =================================================================')
text.append(u'# KNOWLEDGE -- everything below this line is the merged configuration.')
text.append(u'#')
text.append(u'# This whole section is one `knowledge:` block, so most editors let you')
text.append(u'# collapse it (click the triangle / fold marker next to `knowledge:`).')
text.append(u'# You almost never need to edit anything here. The values the engineer')
text.append(u'# actually decides are in the `decisions:` section at the top of this')
text.append(u'# file; anything also named there is overruled by the copy up there.')
text.append(u'#')
text.append(u'# The "set by" comments say which underlying layer each value came from,')
text.append(u'# so you can tell a deliberate plant rule from a default nobody has')
text.append(u'# looked at.')
text.append(u'\nknowledge:')
text.append(_knowledge_body(doc, know, indent=1))
return u'\n'.join(text) + u'\n'
def _knowledge_body(doc, know, indent):
"""The knowledge tree, section by section, each one labelled and sourced.
Emitting the whole tree in one go would be shorter and much harder to
read: a merged configuration is several hundred lines and every one of
them looks equally important until something says otherwise.
"""
where = origins(doc)
pad = ' ' * indent
out = []
for name in sections(know):
note = SECTION_NOTES.get(name)
layers = sorted(set(
where[path] for path in _paths({name: know[name]})
if path in where))
out.append(u'')
if note:
out.append(u'{0}# {1}'.format(pad, note))
if layers:
out.append(u'{0}# set by: {1}'.format(pad, ', '.join(layers)))
out.extend(emit({name: know[name]}, indent))
return u'\n'.join(out)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+557
View File
@@ -0,0 +1,557 @@
# -*- coding: utf-8 -*-
"""The FULL VIEW of the configuration: everything, and where each thing is set.
The Project Config button is a window of four boxes of controls
(`vibe.configform`): the settings a person actually changes between one press
and the next, and nothing else. This is the other half -- the page behind its
"Full view" button. Every setting the job layer may carry, every layer file,
every read-only value with the sentence saying why it is read-only, and the
provenance chain of anything more than one file had an opinion about.
The two are deliberately not two answers to the same question. The window says
WHAT IS SET AND LETS YOU CHANGE IT; this says WHAT EXISTS AND WHERE IT IS
CHANGED, and the `change it in` column is the join: it names the box that
carries each setting, or says the file. That column comes from
`configform.placement()`, so the mapping is written once.
WHAT THIS FIXED, AND WHY IT IS STILL A PAGE
--------------------------------------------
The button used to be a `CommandSwitchWindow` menu, and it had three defects
in one press:
1. THE SENTENCE THAT EXPLAINS THE SCREEN WAS INSIDE THE SCREEN'S CHROME.
`CommandSwitchWindow`'s `message` is a WPF `Label` docked left in a
36-pixel row it shares with the search box, in a window fixed at 600 wide
with `ResizeMode="NoResize"`. A Label does not wrap, so the explanation
was cut off. Prose belongs where there is room for it: on this page, or in
the real window's wrapping header.
2. THE PAGE WAS PRINTED IN THE MIDDLE OF A DIALOG LOOP, so a modal popup came
straight back over it and dismissing that popup ended the run. Nothing is
printed now until every window is shut.
3. THE OPTIONS WERE NAMED BY THEIR DOTTED KEYS. Every row in
`configmodel.SETTINGS` already carries a `label` ("Report folder") and a
`section` ("Output"); the screen showed `handling.report_dir` instead.
Everything user-facing here is label first. The dotted key still appears
-- it is what somebody grepping the YAML needs -- but as reference small
print in its own column, never as the name of the thing.
THE SHAPE, WHICH IS THE TAB'S SHAPE
------------------------------------
`vibe.foldreport`: the answer is the headline and is never folded; everything
else starts closed; a section carrying a problem opens itself and says why in
a sentence. Here the answer is *every setting the job layer may carry, what it
reads now, who decided it, and where it is changed*, so that is the headline.
The three things that open a section by themselves are the three that mean
somebody is about to be surprised:
* a project config file is live (`!`), so the layers on screen are not what
the model reads;
* a setting no layer sets at all (`?`), which is not a value of nothing --
it means nothing here could judge it;
* a layer file that is not present, which is how a plant rule silently
stops applying.
NO REVIT AND NO `doc`. Everything Revit-shaped -- the layer list, the
locations, the run options -- is read by the button and passed in as data, so
the whole page can be built and asserted on host CPython
(`WillTools.extension/tests/test_configpage.py`).
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
from vibe import configmodel
from vibe import foldreport as fold
NOT_SET = '(not set in any layer)'
# Comma-separated rather than dot-separated on purpose: `foldreport.md_text`
# restores `&mdash;`, `&nbsp;`, `&dagger;` and `&amp;` and nothing else, so a
# `&middot;` here would reach the plain-text copy of the page as its own
# source. Entities the text renderer does not know are not decoration that
# degrades, they are visible breakage.
LEGEND = ('> `D` defaults, `C` catalogue, `P` plant, `J` job, '
'`!` project config, `?` no layer sets it, `+` several layers each '
'set part of it (the layers MERGE that key, entry by entry). A `?` '
'is **not** a value of nothing: it means nothing here could judge '
'the setting.')
# ------------------------------------------------------------------ the rows
def value_text(key, res):
"""What one setting reads as, and never as a blank.
An unknown value gets a sentence rather than an empty cell, for the reason
`configmodel.resolve` gives: "judged and found nothing" and "could not
judge" are different answers and an empty cell says the first one.
"""
if not res or not res.get('known'):
return NOT_SET
return configmodel.display_value(key, res['value'])
def editable_rows(layers, settings=None):
"""Every setting the job layer may carry, in SETTINGS order.
`n` is a stable position, not an interface: the editing is done in the
four-module window (`vibe.configform`), and what this page adds is the
column saying WHICH BOX each setting is in -- or that it is in none of
them and is changed in the file.
`settings` overrides the source rows for tests. It defaults to
`configmodel.editable()`, which is whatever the loaded build of
`configmodel` knows about -- so a build that has not yet learned a setting
simply does not number one, rather than numbering a row it cannot edit.
"""
rows = configmodel.editable() if settings is None else list(settings)
out = []
for index, row in enumerate(rows):
res = configmodel.resolve(layers, row['key'])
chip = configmodel.chip(res)
value = value_text(row['key'], res)
if configmodel.merges(row['key']):
# A key the layers MERGE has no single winning layer, and
# `resolve` -- which answers for a key that replaces -- would name
# the last one that set an entry as though it owned all of them.
# `J` on a map the plant also writes is the same lie as a silent
# override, one level down.
merged = configmodel.merged_map(layers, row['key'])
chip = configmodel.map_chip(merged)
value = configmodel.format_merged_map(merged) if merged \
else NOT_SET
res = dict(res)
res['layer'] = ' + '.join(configmodel.map_layers(merged)) or None
out.append({'n': index + 1,
'key': row['key'],
'label': row['label'],
'section': row['section'],
'row': row,
'res': res,
'chip': chip,
'value': value})
return out
def group_rows(rows):
"""[(section, [row])] in the order the sections first appear."""
order = []
holder = {}
for row in rows or []:
if row['section'] not in holder:
holder[row['section']] = []
order.append(row['section'])
holder[row['section']].append(row)
return [(name, holder[name]) for name in order]
def unknown_rows(rows):
"""The settings nothing in any layer decides."""
return [row for row in rows or [] if not row['res']['known']]
def config_rows(rows):
"""The settings a live project config file is deciding, over the layers."""
return [row for row in rows or [] if row['chip'] == '!']
def contested_rows(rows):
"""The settings more than one layer had an opinion about.
The full chain of a setting exactly one file mentions is one line long and
it is already on the page. These are the ones where reading the chain
tells the reader something the headline could not.
"""
return [row for row in rows or []
if len(row['res']['chain']) > 1 or row['res']['overrides']]
# -------------------------------------------------------------- the whole page
def render(title=None, layers=None, rows=None, problems=None,
job_path=None, config_path=None, config_missing=False,
env=None, run_options=None, run_options_rows=None,
run_options_path=None, locations=None, report_dir=None,
subtrees=None, muted=None, muted_note=None, muted_rows=None,
notes=None, full_path=None, placement=None):
"""The page as `foldreport.Section`s: headline first, the rest folded.
Everything is data. `layers` is `configdump.layer_values(doc,
include_config=True)`; `subtrees` is [(name, note, [(key, value, layer)])];
`locations` is `configdump.output_locations()`; `notes` are extra sentences
for the headline (a build that could not show something says so there).
`placement` is {key: where a person changes it} -- the four-module
window's boxes. A key it does not name is changed in the file, and the
page says so rather than leaving the column blank: a blank there would
read as "nowhere", and everything in the job layer is changeable
somewhere.
"""
layers = list(layers or [])
rows = editable_rows(layers) if rows is None else rows
sections = [_headline(title, rows, problems, job_path, config_path,
config_missing, notes, full_path, placement)]
missing_layers = [label for label, _p, exists, _d in layers if not exists]
unknown = unknown_rows(rows)
if unknown:
sections.append(_unknown(unknown))
if config_path:
sections.append(_config(config_path, config_missing,
config_rows(rows)))
sections.append(_read_only(layers))
if muted is not None or muted_note or muted_rows:
sections.append(_muted(muted, muted_note, muted_rows))
sections.append(_run_options(run_options, run_options_rows,
run_options_path))
sections.extend(_subtrees(subtrees))
sections.append(_layers(layers, missing_layers, env, job_path))
sections.append(_output(locations, report_dir))
sections.append(_chains(contested_rows(rows)))
sections.append(_job_file())
return sections
def _headline(title, rows, problems, job_path, config_path, config_missing,
notes, full_path, placement=None):
section = fold.Section('now', 'What this job is set to', headline=True)
section.md('# Configuration of {0}'.format(title or 'this model'))
section.md('Every setting the job layer may carry, what it reads now, '
'and which layer decided it. The **change it in** column says '
'where: one of the four boxes in the Project Config window, or '
'the file itself. Everything is written to the job layer and '
'only the job layer: `{0}`'
.format(job_path or '(not created yet)'))
section.md('Everything else &mdash; what is read-only and why, where each '
'file lives, the whole provenance chain, the run-time options '
'&mdash; is folded below. **If nothing below is open, nothing '
'below needs you.**')
for line in notes or []:
section.md('> ' + line)
for line in problems or []:
section.md('**{0}**'.format(line))
if config_path:
section.md(
'> ## This model runs on a project config file\n>\n> `{0}`{1}\n>\n'
'> It is the last word. **An edit made here does not reach this '
'model** until that file is refreshed from the layers or cleared.'
.format(config_path, ' **(MISSING)**' if config_missing else ''))
for name, group in group_rows(rows):
section.md('### ' + name)
section.table(['setting', 'in force', 'from', 'change it in',
'key'],
[[_cell(row['label']), _cell(row['value']),
'[{0}] {1}'.format(row['chip'],
row['res']['layer'] or 'nothing'),
_where(placement, row['key']),
'`{0}`'.format(row['key'])]
for row in group])
section.md(LEGEND)
if full_path:
section.md('The whole page, nothing folded, is also written to `{0}` '
'on every press.'.format(full_path))
return section
def _unknown(unknown):
section = fold.Section(
'unknown', 'Settings no layer sets',
summary='{0} setting(s)'.format(len(unknown)),
problem='{0} setting(s) that nothing in any layer decides. That is '
'not a value of nothing -- whatever reads them falls back to '
'its own idea, and nothing here can tell you what that is.'
.format(len(unknown)))
section.table(['setting', 'key'],
[[_cell(row['label']), '`{0}`'.format(row['key'])]
for row in unknown])
section.md('Each one is still editable: in its box in the window, or in '
'the job file.')
return section
def _config(config_path, config_missing, overridden):
problem = ('A project config file is live, so the layers below are NOT '
'what this model reads.')
if config_missing:
problem = ('The project config file this model points at is MISSING. '
'Nothing is falling back quietly -- put it back, refresh '
'it, or go back to the layered files.')
section = fold.Section(
'config', 'The project config file, which is the last word',
summary='{0} setting(s) decided by it'.format(len(overridden)),
problem=problem)
section.md('`{0}`'.format(config_path))
if overridden:
section.table(['setting', 'in force', 'key'],
[[_cell(row['label']), _cell(row['value']),
'`{0}`'.format(row['key'])] for row in overridden])
else:
section.md('It sets none of the settings on this page, so every value '
'above still comes from the layer named beside it.')
section.md('Until it is refreshed from the layers or cleared, a '
'job-layer edit does not reach this model.')
return section
def _read_only(layers):
rows = []
whys = []
for row in configmodel.read_only():
res = configmodel.resolve(layers, row['key'])
rows.append([_cell(row['label']),
_cell(value_text(row['key'], res)),
'[{0}] {1}'.format(configmodel.chip(res),
res['layer'] or 'nothing'),
'`{0}`'.format(row['key'])])
whys.append('**{0}** &mdash; {1}'.format(row['label'],
row['why'] or ''))
section = fold.Section('readonly', 'Shown here, not editable here',
summary='{0} setting(s)'.format(len(rows)))
section.md('This button writes the job layer and nothing else. What a '
'plant\'s hardware is, and which part number is a face lifter, '
'is not a job fact: one plant edit reaches every job, so '
'editing that file stays a deliberate act somewhere else. '
'Every one of these is still shown, with the sentence saying '
'why it is not yours to change here.')
section.table(['setting', 'in force', 'from', 'key'], rows)
for line in whys:
section.md('> ' + line)
return section
WHERE_MUTES_ARE_SET = (
'Muting is configured in the **Ticket QC** box of the Project Config '
'window: it lists every row the checklist asks, with a speaker beside '
'each. Clicking one asks for the reason -- required, dated for you, '
'written to the job layer.')
WHAT_A_MUTE_DOES = (
'**Muting does not stop a check.** A muted row runs on every ticket '
'exactly as before and keeps its place on the table; what changes is how '
'it reports, quietly instead of as a red row, when the result is not a '
'failure. **A failure is never quietened** &mdash; it stays on the table, '
'in the tally, and in the machine-readable JSON, whatever this list says.')
def _muted(muted, muted_note, muted_rows=None):
"""What is muted, whose mute it is, and WHERE THE CONTROL IS.
That last sentence is on the page because its absence was the defect: the
owner went looking for the mute control twice and did not find it, and
this page -- the one surface that lists what is muted -- said nothing at
all about where muting is done. A page that shows a state and not the
thing that sets it sends the reader hunting.
`muted_rows` is `configform.mute_table(...)`: the same rows the window
draws, so the layer column here and the buttons there cannot disagree. It
is passed in rather than computed because it needs the ticket checklist
table, which this module has no other business loading. Without it the
section falls back to the flat map, which is what every older caller
hands over.
"""
muted = muted if isinstance(muted, dict) else {}
listed = [row for row in (muted_rows or []) if row.get('muted')]
theirs = [row for row in listed if not row.get('editable')]
stale = [row for row in listed if not row.get('known')]
problem = None
if muted:
problem = ('{0} checklist item(s) are muted on this job. Each one '
'still RUNS on every ticket and keeps its row -- muting '
'only makes a result that is not a failure report quietly '
'rather than red. It never quietens a FAIL, and its '
'findings still reach the JSON.'.format(len(muted)))
if stale:
problem = ('{0} muted number(s) are not checklist items at all, so '
'they mute NOTHING. Somebody believes a row is off while '
'it is on.'.format(len(stale)))
section = fold.Section('muted', 'Muted checklist items',
summary='{0} muted'.format(len(muted)),
problem=problem)
if listed:
section.table(['item', 'checklist row', 'why this job never has one',
'set by'],
[[_cell(row['number']), _cell(row['name']),
_cell(row['reason'] or '(no reason given)'),
_cell(row['layer'] or 'nothing')] for row in listed])
elif muted:
section.table(['item', 'why this job never has one'],
[[_cell(key), _cell(muted[key])]
for key in sorted(muted)])
else:
section.md('Nothing is muted on this job. Every checklist row reports '
'at full volume.')
section.md('> ' + WHAT_A_MUTE_DOES)
section.md('> ' + WHERE_MUTES_ARE_SET)
if theirs:
# Not editable there, and saying so HERE as well: this page is where
# somebody lands after failing to change one in the window, and a
# second silence would send them to the wrong file.
section.md('> **{0} of them are not the job layer\'s.** The knowledge '
'layers MERGE this map, so a job entry cannot take a mute '
'set higher up away -- {1}. Edit that layer\'s file to '
'unmute.'.format(
len(theirs),
', '.join('{0} ({1})'.format(row['number'], row['layer'])
for row in theirs)))
if muted_note:
section.md('> ' + muted_note)
return section
def _run_options(options, option_rows, path):
options = options or {}
option_rows = list(option_rows if option_rows is not None
else configmodel.RUN_OPTIONS)
section = fold.Section(
'runopts', 'Run-time options &mdash; not knowledge',
summary='{0} option(s)'.format(len(option_rows)))
section.md('> No layers, no provenance, nothing overrides them. They are '
'choices about what a run is willing to spend, which is a '
'property of the run and not of the plant. `{0}`'
.format(path or '(beside the model)'))
section.table(
['what it is', 'now', 'default', 'key'],
[[_cell(row['label']),
_cell('{0}'.format(options.get(row['key'], row['default']))),
_cell('{0}'.format(row['default'])), '`{0}`'.format(row['key'])]
for row in option_rows])
return section
def _subtrees(subtrees):
"""One section for the parameter map, one for everything else shown raw."""
out = []
rest = []
for name, note, leaves in subtrees or []:
if name == 'parameters':
section = fold.Section(
'params', 'Revit parameter names this tool reads',
summary='{0} role(s)'.format(len(leaves)),
problem=None if leaves else
'No parameter names are mapped, so the checks that read them '
'do not run and say nothing about it.')
section.md('> ' + note)
section.table(['role', 'parameter name read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
else:
rest.append((name, note, leaves))
if rest:
section = fold.Section(
'published', 'Published data and plant knowledge, read-only',
summary='{0} group(s)'.format(len(rest)))
for name, note, leaves in rest:
section.md('### ' + name)
section.md('> ' + note)
section.table(['key', 'value read', 'set by'],
[[_cell(k), _cell(v), _cell(w)]
for k, v, w in leaves])
out.append(section)
return out
def _layers(layers, missing, env, job_path):
problem = None
if missing:
problem = ('{0} layer file(s) are not present: {1}. Whatever they say '
'is not being applied, and nothing else reports that.'
.format(len(missing), ', '.join(missing)))
section = fold.Section(
'layers', 'Where every layer lives',
summary='{0} layer(s)'.format(len(layers)), problem=problem)
section.md('Each one overrides the one above it.')
section.table(['layer', 'file', 'present'],
[[_cell(label), _cell(path or '(built into the tool)'),
'yes' if exists else 'MISSING']
for label, path, exists, _data in layers])
section.md('The job layer, which is the one this button writes: `{0}`'
.format(job_path or '(not created yet)'))
section.table(['environment variable', 'value'],
[[_cell(name), _cell(value)] for name, value in env or []])
return section
def _output(locations, report_dir):
locations = locations or {}
section = fold.Section('output', 'Where output goes',
summary='{0} place(s)'.format(len(locations)))
section.table(['what', 'where'],
[[_cell(key.replace('_', ' ')), _cell(locations[key])]
for key in sorted(locations)])
section.md('`{0}` is the report folder in force, and it is the one path '
'here that is a setting.'.format(report_dir))
return section
def _chains(rows):
section = fold.Section(
'chains', 'The full chain, where more than one layer had an opinion',
summary='{0} setting(s)'.format(len(rows)))
section.md('A setting exactly one file mentions is already answered on '
'the page above. These are the ones where a layer was beaten, '
'which is the case worth reading.')
for row in rows:
# No key line of its own: `chain_lines` leads with the dotted key and
# the chip, so printing one here would say it twice.
section.md('### ' + row['label'])
for line in configmodel.chain_lines(row['res']):
section.md(' ' + line)
if not rows:
section.md('Every setting comes from exactly one place.')
return section
def _job_file():
section = fold.Section('jobfile', 'What panellift is handed',
summary='8 field(s)')
section.md('> The job file (`*.job.json`) written beside every report is '
'not a second place to configure anything: it is generated '
'from the settings above on every run. These are the fields it '
'carries and where each one comes from, so the two halves of '
'"the job layer" read as one thing.')
section.table(['job file field', 'comes from'],
[['rigging.mode', 'handling.rig_mode'],
['rigging.sling_angle_deg', 'handling.sling_angle_deg'],
['rigging.group_tolerance_ft',
'handling.group_tolerance_ft'],
['stages[].rig_mode', 'handling.stages[].rig_mode'],
['stages[].multiplier / pci_case / strength',
'handling.stages[]'],
['stages[].lifters',
'clicked on the piece in Panel Lift Check'],
['rigging.tension_fos / shear_fos',
'catalogue.safety_factors.*'],
['panel.*', 'measured from the model']])
return section
def _where(placement, key):
"""Which of the window's boxes changes this setting, or the file.
Never blank. A blank cell in a column headed "change it in" reads as
"nowhere", and every setting on this page is changeable somewhere.
"""
if placement is None:
return ''
return placement.get(key) or 'the file'
def _cell(value):
"""One table cell. A pipe in a value would otherwise split the row.
`output.print_table` builds a markdown pipe table and hands it to the
markdown renderer, so a muting reason containing a `|` -- a sentence
somebody typed -- would silently become two columns and shift every cell
after it.
"""
text = '' if value is None else '{0}'.format(value)
return text.replace('|', '&#124;')
+818
View File
@@ -0,0 +1,818 @@
# -*- coding: utf-8 -*-
"""Design summaries as reviewable data.
A design summary is a drafting view full of free text that a human wrote. The
checking scripts want numbers. Turning one into the other is the only part of
the job that genuinely needs judgement, so it happens once, up front, and the
result is written to disk where you can read and correct it:
<model folder>/_ticketqc/
evidence/<DESIGN>.json everything we could scrape off the summary
specs/<DESIGN>.json the structured spec <-- the thing to review
knowledge.yaml hand-written rules that outlive one project
log/<DESIGN>.txt what the extractor printed
Evidence is gathered deterministically here. So is the spec now: the shape
of a spec, and how it reads and writes as text, live in `specformat.py` --
which touches no Revit API at all, on purpose, so that shape can be unit
tested without a licence or a model (see its docstring). This module is the
Revit side of that boundary: it reads the model, and it reads and writes
specformat's shapes to disk beside it. Everything downstream reads only the
spec, so a checking run is pure, repeatable arithmetic.
The spec file on disk is YAML, not JSON: JSON cannot carry a comment, and the
whole point of this file is that an engineer reads it and corrects it by hand.
An older spec written as plain JSON is still read -- see load_spec.
IronPython 2.7: no f-strings, no pathlib.
"""
# pylint: disable=import-error,invalid-name,broad-except
import os
import io
import re
import json
import datetime
from Autodesk.Revit.DB import (
FilteredElementCollector, View, ViewSheet, ViewType, BuiltInCategory,
BrowserOrganization, Element, StorageType,
)
from vibe import specformat
from vibe import specsave
from vibe.specformat import ( # noqa: F401 -- re-exported, see below
SCHEMA_VERSION, SPEC_TEMPLATE, REQUIRED_PATHS, get_path, validate_spec,
normalize_spec, new_spec, strand_positions, strand_row_marks,
strand_diagram, spec_text, parse_spec_text, design_key,
)
from vibe.compat import as_text
# Re-exported, not re-implemented. Choosing the plant knowledge file is pure
# path arithmetic and lives in a module with no Revit import, so it can be
# tested on host CPython -- but every caller has always asked designspec for
# it, and a second spelling of the same question is how two answers start.
from vibe.plantpath import ( # noqa: F401 -- re-exported
PLANT_FOLDER, PLANT_ENV, valid_plant_id, plant_root, plant_knowledge_path,
)
# Control characters and format characters that ride along in Revit text and
# crash downstream printers / parsers. Measured: a model parameter whose
# ValueString was exactly U+200E (a lone left-to-right mark) made it all the
# way into evidence/WP1.json and broke a tool that printed it. Everything
# that scrapes model text goes through clean_text at the boundary; the
# function itself lives in ticketpage (the Revit-free module, unit-tested).
from vibe.ticketpage import clean_text
# The strand-matrix geometry reader. A sibling module, developed in parallel
# -- guarded, and PERMANENTLY guarded even once it always ships, because a
# machine missing the file must get an honest refusal from build_spec rather
# than an ImportError that takes the whole extraction down with it.
try:
from vibe import strandmatrix
except ImportError:
strandmatrix = None
FOLDER = '_ticketqc'
JOBS_ENV = 'WILLTOOLS_JOBS'
# ------------------------------------------------------------------- layout
def root_dir(doc):
"""Where this project's QC data lives.
WILLTOOLS_JOBS wins if it is set: the job data goes in a per-model folder
underneath it. Everything a project knows then sits in one place the
engineer chose, rather than in two that the tool chose for him.
Without it, beside the model, falling back to APPDATA when the model has
no path yet or its folder cannot be written. That fallback is the reason
the override exists. Saving a detached copy gives it a path, so the
lookup silently moves from APPDATA to the model folder, and a job
knowledge file written before the save stops being read -- measured
2026-08-30, and from the outside it looks exactly like edits that did not
take.
"""
override = os.environ.get(JOBS_ENV)
if override:
return _ensure(os.path.join(override, doc.Title or 'untitled'))
model = doc.PathName
if model:
base = os.path.join(os.path.dirname(model), FOLDER)
try:
_ensure(base)
return base
except Exception:
pass
fallback = os.path.join(os.environ.get('APPDATA', os.path.expanduser('~')),
'pyrevit-ticketqc', doc.Title or 'untitled')
_ensure(fallback)
return fallback
def _ensure(path):
if not os.path.isdir(path):
os.makedirs(path)
return path
def sub_dir(doc, name):
return _ensure(os.path.join(root_dir(doc), name))
def evidence_path(doc, design):
return os.path.join(sub_dir(doc, 'evidence'), _safe(design) + '.json')
def spec_path(doc, design, ext='yaml'):
"""Where one design's spec lives. New specs are written as '.yaml'.
A spec written before this file carried comments is still on disk as
'.json' -- pass ext='json' to name it, or use find_spec_path() to find
whichever one is actually there.
"""
return os.path.join(sub_dir(doc, 'specs'), _safe(design) + '.' + ext)
def find_spec_path(doc, design):
"""(path, ext) of the spec actually on disk for this design.
'.yaml' wins when both exist -- it is the one a human could have
corrected, and load_spec must not silently prefer the one they didn't
touch. Neither existing, this is where a new spec would be written.
"""
yaml_path = spec_path(doc, design, 'yaml')
if os.path.isfile(yaml_path):
return yaml_path, 'yaml'
json_path = spec_path(doc, design, 'json')
if os.path.isfile(json_path):
return json_path, 'json'
return yaml_path, 'yaml'
def log_path(doc, design):
return os.path.join(sub_dir(doc, 'log'), _safe(design) + '.txt')
def knowledge_path(doc):
return os.path.join(root_dir(doc), 'knowledge.yaml')
# ------------------------------------------------------- the project config
#
# A project's configuration file is written wherever the engineer wants it --
# the job folder on a share, usually, where the rest of the job's paperwork
# lives and where a reviewer will look for it. That is the whole point of
# letting them choose, and it means the tool cannot go and find it by
# convention afterwards.
#
# So a one-line pointer stays behind, beside the model with the rest of the QC
# data. It holds the path and nothing else. Losing it costs one button press;
# putting the config itself here would defeat the choice.
CONFIG_POINTER = 'active-config.txt'
def config_pointer_path(doc):
return os.path.join(root_dir(doc), CONFIG_POINTER)
def active_config_path(doc):
"""The project config file in force for this model, or None.
Returns the path even when the file has gone missing. A pointer to
nothing is a fact the caller has to be told about -- resolving it to None
would silently fall back to the layered files, and the run would use a
different configuration than the one the engineer set, without saying so.
"""
pointer = config_pointer_path(doc)
if not os.path.isfile(pointer):
return None
try:
with io.open(pointer, 'r', encoding='utf-8') as fh:
path = fh.read().strip()
except Exception:
return None
return path or None
def set_active_config(doc, path):
"""Make this file the configuration this model runs on."""
with io.open(config_pointer_path(doc), 'w', encoding='utf-8') as fh:
fh.write(as_text(path))
return path
def clear_active_config(doc):
"""Go back to the layered files. Returns what was in force, or None."""
pointer = config_pointer_path(doc)
previous = active_config_path(doc)
if os.path.isfile(pointer):
os.remove(pointer)
return previous
def _safe(name):
"""A design number as a filename.
One rule, and it lives in `specsave` -- the Extract Design button names
the COPY it saves elsewhere with the same function, so a copy can never
end up called something the canonical file is not.
"""
return specsave.safe_name(name)
# ---------------------------------------------------------- design numbers
#
# `design_key` MOVED TO `specformat` and is re-exported at the top of this
# file, alongside the rest of the spec shape. It is the same function under
# the same name; nothing that calls `designspec.design_key` has to change.
#
# Why it moved: this module imports `Autodesk.Revit.DB` at module scope, so
# asking "are these two design numbers the same design?" needed a licence.
# `reinforcebind` has to ask exactly that -- to keep a donor of another design
# out of a suggestion -- and it must answer both inside Revit and in
# `tools/reinforce_offline.py`, which runs with no Revit at all. Reimplementing
# the reduction there would have been a second spelling of one question, which
# is how two answers start; so the function went to the Revit-free module and
# the callers stayed put. `_FALLBACK_DESIGN_RE` went with it as
# `specformat.FALLBACK_DESIGN_RE`.
def _nomen(know, key, default):
"""One nomenclature setting, tolerating a knowledge dict that is not there.
Imported late: knowledge imports this module, so a module-level import
would be circular. `tools/knowledge_drift.py` knows this wrapper by name
and resolves `nomenclature.<key>` at each call site, so do not rename it
without teaching the scanner.
"""
if know is None:
return default
from vibe import knowledge as K
return K.get(know, 'nomenclature.' + key, default)
def list_specs(doc):
"""Design numbers that already have a spec on disk.
A design may have both a '.yaml' and a leftover '.json' -- the '.yaml'
wins, same rule as find_spec_path, so a spec a human has never touched
(still '.json') does not shadow one they have.
"""
folder = sub_dir(doc, 'specs')
by_design = {}
for fn in sorted(os.listdir(folder)):
low = fn.lower()
if low.endswith('.yaml'):
rank = 0
elif low.endswith('.json'):
rank = 1
else:
continue
spec = normalize_spec(read_spec_file(os.path.join(folder, fn)))
if not spec or not spec.get('design_number'):
continue
design = spec['design_number']
prior = by_design.get(design)
if prior is None or rank < prior[0]:
by_design[design] = (rank, spec)
return dict((d, rank_spec[1]) for d, rank_spec in by_design.items())
# --------------------------------------------------------------------- io
def read_json(path):
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
return json.loads(fh.read())
except Exception:
return None
def write_json(path, data):
_ensure(os.path.dirname(path))
text = as_text(json.dumps(data, indent=2, sort_keys=True,
ensure_ascii=False))
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(text)
return path
def read_yaml(path):
"""A spec file written by save_spec, or None. Comments are just dropped."""
if not os.path.isfile(path):
return None
try:
with io.open(path, 'r', encoding='utf-8') as fh:
text = fh.read()
except Exception:
return None
try:
return specformat.parse_spec_text(text)
except Exception:
return None
def read_spec_file(path):
"""A spec, whichever of the two formats it was written in."""
if path.lower().endswith('.json'):
return read_json(path)
return read_yaml(path)
def load_spec(doc, design):
path, _ext = find_spec_path(doc, design)
return normalize_spec(read_spec_file(path))
def save_spec(doc, design, spec):
"""Write a spec as hand-editable YAML.
Always '.yaml', regardless of what was there before -- a spec that gets
re-extracted goes back to being machine-written, and a stale '.json'
left beside it would otherwise look like an untouched original. Nothing
here deletes the old '.json'; find_spec_path prefers the '.yaml' from
here on, same as list_specs.
"""
path = spec_path(doc, design, 'yaml')
_ensure(os.path.dirname(path))
text = specformat.spec_text(spec)
with io.open(path, 'w', encoding='utf-8') as fh:
fh.write(as_text(text))
return path
# ------------------------------------------------------------ element utils
def _name(el):
try:
return Element.Name.GetValue(el)
except Exception:
return ''
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _params(el, wanted=None):
out = {}
for p in el.Parameters:
pname = p.Definition.Name
if wanted is not None and pname not in wanted:
continue
try:
if p.StorageType == StorageType.String:
val = p.AsString()
elif p.StorageType == StorageType.Integer:
val = p.AsInteger()
elif p.StorageType == StorageType.Double:
val = p.AsValueString()
else:
val = p.AsValueString()
except Exception:
val = None
if val not in (None, ''):
out[pname] = val
return out
def _dimension(d):
"""One dimension, with enough geometry to say what it measures.
A bare string is not enough for a strand chain. Where the strands sit is
dimensioned as a multi-segment chain -- rail to first strand, then strand
to strand, then last strand to the far edge -- so the segments in order,
their exact lengths, and which way the chain runs are all part of the
reading. N segments locate N-1 strands and the total is the piece width,
which is what makes the reading checkable.
Two traps, both met on real summaries:
* Dimension.Curve throws on these drafting views, so the direction is
inferred from how the segment origins are spread instead.
* segment.Value is the drawn geometry and segment.ValueString is what is
printed. On an overridden segment they disagree, and only the printed
text is the instruction -- so both are recorded and neither is
silently preferred.
"""
entry = {}
try:
entry['value'] = clean_text(d.ValueString)
except Exception:
entry['value'] = None
for key, attr in (('above', 'Above'), ('below', 'Below'),
('prefix', 'Prefix'), ('suffix', 'Suffix')):
try:
val = getattr(d, attr, None)
except Exception:
val = None
if val:
entry[key] = val
try:
raw = d.Value
if raw is not None:
entry['value_ft'] = round(float(raw), 6)
except Exception:
pass
try:
entry['references'] = d.References.Size
except Exception:
pass
try:
origin = d.Origin
entry['x'] = round(origin.X, 4)
entry['y'] = round(origin.Y, 4)
except Exception:
pass
segments = []
try:
if d.NumberOfSegments > 1:
for seg in d.Segments:
item = {}
try:
item['value'] = clean_text(seg.ValueString)
except Exception:
item['value'] = None
try:
if seg.Value is not None:
item['value_ft'] = round(float(seg.Value), 6)
except Exception:
pass
try:
point = seg.Origin
item['x'] = round(point.X, 4)
item['y'] = round(point.Y, 4)
except Exception:
pass
segments.append(item)
except Exception:
pass
if segments:
entry['segments'] = segments
entry['segment_count'] = len(segments)
xs = [s['x'] for s in segments if 'x' in s]
ys = [s['y'] for s in segments if 'y' in s]
if xs and ys:
entry['axis'] = ('horizontal' if (max(xs) - min(xs)) >= (max(ys) - min(ys))
else 'vertical')
lengths = [s['value_ft'] for s in segments if 'value_ft' in s]
if len(lengths) == len(segments):
entry['total_ft'] = round(sum(lengths), 6)
if entry.get('value') or segments:
return entry
return None
def _view_content(doc, view):
"""Text, dimensions and drawn components of one view, positions included.
Positions matter: the min/max table in a summary is a label note and a
value note sitting side by side, and only the geometry links them.
"""
notes = []
for t in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_TextNotes).ToElements()):
try:
c = t.Coord
notes.append({'text': t.Text.replace('\r', '').strip(),
'x': round(c.X, 4), 'y': round(c.Y, 4)})
except Exception:
notes.append({'text': t.Text.replace('\r', '').strip()})
notes.sort(key=lambda n: (-n.get('y', 0), n.get('x', 0)))
dims = [_dimension(d) for d in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_Dimensions)
.WhereElementIsNotElementType().ToElements())]
dims = [d for d in dims if d]
components = {}
symbols = []
symbols_skipped = 0
for e in (FilteredElementCollector(doc, view.Id)
.OfCategory(BuiltInCategory.OST_DetailComponents).ToElements()):
try:
key = e.Symbol.Family.Name
except Exception:
key = type(e).__name__
components[key] = components.get(key, 0) + 1
# Positions, for the strand-matrix reader: which symbol is drawn
# where. A FilledRegion (the usual strand marker -- see
# knowledge.DEFAULTS['design_summary']) has no .Symbol, so `key`
# above is already its class name; the same key is reused here so
# the two counts describe the same population.
try:
bbox = e.get_BoundingBox(view)
if bbox is None:
symbols_skipped += 1
continue
cx = (bbox.Min.X + bbox.Max.X) / 2.0
cy = (bbox.Min.Y + bbox.Max.Y) / 2.0
w = bbox.Max.X - bbox.Min.X
h = bbox.Max.Y - bbox.Min.Y
symbols.append({'kind': key, 'x': round(cx, 4), 'y': round(cy, 4),
'w': round(w, 4), 'h': round(h, 4)})
except Exception:
symbols_skipped += 1
return {'name': view.Name, 'id': _eid(view), 'type': str(view.ViewType),
'text_notes': notes, 'dimensions': dims, 'components': components,
'symbols': symbols, 'symbols_skipped': symbols_skipped}
# ------------------------------------------------------------ the gathering
def _sheet_folder(org, sheet):
"""The sheet's path in the project browser, e.g. 'DESIGN PRODUCT / WALLS'."""
if org is None:
return ''
try:
return ' / '.join([item.Name for item in org.GetFolderItems(sheet.Id)])
except Exception:
return ''
def find_summary_views(doc, design, know=None):
"""The views and sheets that make up one design's packet.
Driven by SHEETS, not by view names. A view belongs to the design of the
sheet it is placed on, whatever it is called -- a view still titled PNL2.0
sitting on sheet PNL1.0 belongs to PNL1.0, and matching on the view name
would file it under the wrong design without anything looking wrong.
Sheets are reduced through design_key, so the whole packet -- cover sheet
PNL1.0 plus PNL1.A, PNL1.B1 and the rest -- comes back together, in sheet
order, which puts the cover sheet (the design summary itself) first.
"""
key = design_key(design, know)
if not key:
return [], []
folder_wanted = (_nomen(know, 'design_folder', '') or '').strip().upper()
ignore = [str(p).upper()
for p in (_nomen(know, 'ignore_sheet_prefixes', []) or [])]
stale = _nomen(know, 'stale_view_re', None)
try:
org = BrowserOrganization.GetCurrentBrowserOrganizationForSheets(doc)
except Exception:
org = None
sheets = []
for s in (FilteredElementCollector(doc).OfClass(ViewSheet)
.WhereElementIsNotElementType().ToElements()):
if s.IsTemplate:
continue
number = (s.SheetNumber or '').strip()
if any(number.upper().startswith(p) for p in ignore):
continue
if design_key(number, know) != key:
continue
if folder_wanted and folder_wanted not in _sheet_folder(org, s).upper():
continue
sheets.append(s)
sheets.sort(key=lambda s: (s.SheetNumber or ''))
views, seen = [], set()
for sheet in sheets:
for vid in sheet.GetAllPlacedViews():
if vid in seen:
continue
seen.add(vid)
view = doc.GetElement(vid)
if view is None:
continue
if stale:
try:
if re.search(stale, view.Name):
continue
except Exception:
pass
views.append(view)
return views, sheets
def gather_evidence(doc, design, know=None):
"""Scrape everything the extractor could possibly need. No interpretation."""
views, sheets = find_summary_views(doc, design, know)
evidence = {
'schema': SCHEMA_VERSION,
'design_number': design,
'model': doc.Title,
'model_path': doc.PathName,
'gathered': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
'summary_views': [_view_content(doc, v) for v in views],
'sheets': [],
}
for sheet in sheets:
entry = {
'number': sheet.SheetNumber,
'name': sheet.Name,
'id': _eid(sheet),
'parameters': _params(sheet),
'views': [],
}
for vid in sheet.GetAllPlacedViews():
view = doc.GetElement(vid)
if any(v.Id == view.Id for v in views):
continue
entry['views'].append(_view_content(doc, view))
evidence['sheets'].append(entry)
# A worked example of a real piece helps the extractor ground its units.
evidence['sample_piece'] = _sample_piece(doc, design, know=know)
return evidence
def _sample_piece(doc, design, know=None):
"""One assembly on this design, so the extractor can see real magnitudes."""
from Autodesk.Revit.DB import AssemblyInstance
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if not (member.Category and member.Category.Name == 'Structural Framing'):
continue
from vibe.ticketqc import rstr
# Compared as reduced keys: the piece may be stamped PNL1 while
# the summary sheet is PNL1.0, and they are the same design.
if design_key(rstr(member, know, 'panel.design_number'),
know) != design_key(design, know):
break
bom = {}
for m2 in [doc.GetElement(i) for i in asm.GetMemberIds()]:
try:
bom[m2.Symbol.Family.Name] = bom.get(m2.Symbol.Family.Name, 0) + 1
except Exception:
pass
return {
'mark': asm.AssemblyTypeName,
'family': member.Symbol.Family.Name,
'panel_parameters': _params(member),
'type_parameters': _params(doc.GetElement(member.GetTypeId())),
'bill_of_material': bom,
}
else:
continue
return None
# ------------------------------------------------------------- spec shape
#
# SPEC_TEMPLATE, get_path, validate_spec, normalize_spec and new_spec all live
# in specformat.py now -- see the imports at the top of this file -- because
# none of them touch Revit and every one of them is worth testing without it.
# They are imported here so `designspec.SPEC_TEMPLATE` etc. keep working for
# code written against this module.
def build_spec(doc, design, know=None, evidence=None):
"""A spec built purely from the regex/geometry reader. No AI, no judgement.
Everything below either matched one of ticketqc's rules or it did not --
there is no third option, and nothing here guesses. What matched is
filled in; every text note on the design summary that nothing matched is
recorded in 'unresolved' rather than silently dropped. The strand matrix
is read from GEOMETRY, not guessed at from text -- the drawn strand
symbols and the dimension chain that locates them -- and only when every
identity check on that reading passes: the symbol count equals the
stated strand quantity, each row's spacing matches a dimension chain, and
row depths match a vertical chain. Any one of those failing leaves
columns_in and rows empty with the reason on 'unresolved' rather than a
grid nobody can trust; a wrong grid is worse than an honest blank one,
and the AI pass and a human both fill what stays blank. datum comes from
knowledge (rails.strand_datum) because that much genuinely is known
already, independent of whether the geometry read succeeds.
Returns None when this design has no summary view to read.
"""
from vibe import knowledge as K
from vibe import ticketqc
if know is None:
know = K.load(doc)
views, sheets = find_summary_views(doc, design, know)
if not views:
return None
if evidence is None:
evidence = gather_evidence(doc, design, know=know)
view = views[0] # the design's own summary view --
# the rest of the packet's views
# are sub-details, not this design
reading = ticketqc.parse_design_summary(doc, view)
spec = new_spec(design, evidence)
spec['source']['extractor'] = 'regex'
spec['piece_description'] = sheets[0].Name if sheets else None
spec['concrete']['fc_psi'] = reading.get('fc')
spec['concrete']['fci_psi'] = reading.get('fci')
spec['concrete']['unit_weight_pcf'] = reading.get('pcf')
spec['reinforcing']['mild_grade_ksi'] = reading.get('mild_ksi')
spec['reinforcing']['wwf']['mesh'] = reading.get('wwf_mesh')
spec['reinforcing']['wwf']['layers'] = reading.get('wwf_layers')
spec['reinforcing']['wwf']['per_face'] = reading.get('wwf_per_face')
spec['reinforcing']['strand_groups'] = [
{'qty': g.get('qty'), 'dia_in': g.get('dia'), 'grade_ksi': g.get('ksi'),
'it_lb': g.get('it'), 'role': None, 'note': None}
for g in (reading.get('strands') or [])
]
spec['reinforcing']['strand_layout']['datum'] = K.get(
know, 'rails.strand_datum', 'left_rail')
envelope = reading.get('envelope') or {}
for dim, key in (('length', 'LENGTH'), ('width', 'WIDTH')):
for bound in ('min', 'max'):
entry = envelope.get('{0}_{1}'.format(bound.upper(), key))
if entry:
val, raw = entry
# Rounded, not truncated: parse_feet divides by 12 and 16ths,
# which does not land on a clean decimal even when the
# drawing dimension is exact -- 34'-11 1/2" is really
# 34.958333333333336 in binary float. Round to the nearest
# 1/16" (6 decimal places comfortably clears that) so the
# spec shows the number a person would actually write, not
# float noise nobody typed.
spec['envelope'][dim][bound + '_ft'] = round(val, 6)
spec['envelope'][dim][bound + '_text'] = raw
# ---- the strand matrix, read from geometry -------------------------
#
# `strandmatrix.read_layout` is the identity-checked reader: it counts
# the drawn strand symbols against the stated quantity, matches each
# row's spacing to a dimension chain and each row's depth to a vertical
# chain, and refuses -- one sentence saying which identity failed --
# rather than fill in a grid that only half matches. `symbol_kind` is
# plant knowledge (design_summary.strand_symbol) because the drafting
# standard's choice of marker is not universal: a filled region on this
# default, a detail-component family name on another plant. Computed
# here, applied below once `spec['unresolved']` exists to carry a
# refusal reason -- never raised out of this function, because a bad
# geometry read must lose only the matrix, not the whole spec.
matrix_layout, matrix_why = None, 'strandmatrix module not installed'
if strandmatrix is not None:
try:
qty = sum([g.get('qty') for g in spec['reinforcing']['strand_groups']
if g.get('qty') is not None])
view_ev = (evidence.get('summary_views') or [None])[0]
symbol_kind = K.get(know, 'design_summary.strand_symbol', 'FilledRegion')
width_ft = spec['envelope']['width']['max_ft']
matrix_layout, matrix_why = strandmatrix.read_layout(
view_ev, qty, thickness_in=spec.get('thickness_in'),
width_ft=width_ft, symbol_kind=symbol_kind)
except Exception as ex:
matrix_layout = None
matrix_why = 'strand matrix read failed: {0}: {1}'.format(
type(ex).__name__, ex)
if matrix_layout:
layout = spec['reinforcing']['strand_layout']
layout['columns_in'] = matrix_layout.get('columns_in')
layout['rows'] = matrix_layout.get('rows')
layout['total_in'] = matrix_layout.get('total_in')
layout['note'] = matrix_layout.get('note')
# Every note the generic rules could not place. The plant's declared rule
# packs then get one look at that list and may move a note into `notes`
# (prose a person reads) or leave it where it is with a better sentence
# beside it -- never remove one. `apply_to` returns both halves and their
# lengths always sum back to what went in, which is the property that
# keeps a house-style rule from becoming a silent exclusion.
#
# No packs declared, or a declaration this build refuses, means this is
# exactly the single assignment it used to be. A refusal is carried on the
# spec rather than raised: a spec that lost its extraction because a
# knowledge file named an unknown pack would be a worse outcome than one
# that says so on its face and leaves every note visible.
from vibe import extractrules
entries = ticketqc.unresolved_in_view(doc, view)
packs, problems = extractrules.load(know)
notes, unresolved = extractrules.apply_to(entries, packs)
spec['notes'] = notes
spec['unresolved'] = unresolved
if problems:
spec['source']['rule_pack_problems'] = problems
# Appended AFTER extractrules.apply_to so the rule packs never see this
# line -- it is not a note off the drawing, it is this function's own
# refusal, and folding it into `entries` above would let a house-style
# rule pack move or reinterpret it like ordinary drawing text.
if not matrix_layout:
spec['unresolved'].append({'text': 'STRAND PATTERN (matrix)',
'reason': matrix_why})
return spec
@@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
"""PLANT EXTRACTION RULE PACKS: one producer's drawing habits, kept out of
the generic reader and loaded only where the plant knowledge says so.
WHY THIS EXISTS
---------------
`ticketqc.parse_design_summary` reads a design summary with rules that are
true of precast drawings generally -- an f'c line, a strand callout, a mesh
callout. Every text note it cannot place lands in the spec's `unresolved`
list verbatim, which is what stops a narrow rule set from being a lossy one.
But a real sheet carries a second population of notes that are not general at
all. They are one producer's house style: the title of a standard detail, the
label above a legend, a standing instruction that appears on every sheet that
producer draws. The generic reader cannot place them and should not try --
they are not facts about precast, they are facts about an office.
Left alone they are not harmless. Seven of them on one sheet turn `unresolved`
into a list nobody reads, and the day a genuinely unread value lands there it
is the eighth line of a list the reader has already learned to skim. A signal
that always fires is not a signal -- the same argument `.setup-opts` makes for
a check that always FAILs, and the same one `checks.muted` makes for a row
that is permanently NOT RUN.
WHAT A PACK MAY DO, AND WHAT IT MAY NEVER DO
--------------------------------------------
A pack classifies a note the generic rules did not match. It may say:
* `note` -- prose that belongs in the spec's `notes` list. An
instruction, a condition, a standing rule. It is CARRIED,
verbatim, into a field a person reads.
* `furniture` -- drawing furniture that carries no data at all: the bare
title of a legend or a detail. It STAYS in `unresolved`,
with the pack's reason in place of the blanket one.
A pack may NEVER remove a note from the spec, and there is no third verdict
that makes one disappear. `furniture` is not an ignore list: the line is still
written out, still readable, still countable -- only the sentence beside it
changes, from "no rule matched this" to "this is a legend title, it carries no
data". An exclusion the code chose for itself is the one failure this project
has already paid for (see `vibe/scope.py`), and nothing here may repeat it.
WHEN UNSURE, A PACK SAYS `note`, NEVER `furniture`
--------------------------------------------------
The two errors are not symmetric, in the same way `vibe/scope.py`'s two are
not. A note wrongly called `note` is copied into a field a person reads and
costs them one line of attention. A note wrongly called `furniture` has been
declared meaningless by a regex, and the reader is told there is nothing
there. So a rule that is not certain writes `note`, and a title that carries
any condition beyond its own name (`STRIPPING & BLOCKING DETAIL < 25'`) is a
note, not furniture -- the condition is the part that could matter.
WHICH PACKS LOAD IS DECLARED, NEVER INFERRED
--------------------------------------------
From the PLANT layer, `extract.rule_packs`. Not from `plant.id`, even though
that key exists and would be enough to guess with: which house style a job is
drawn in is a thing somebody knows and can write down, and a guess that is
right today is a guess that goes wrong silently the first time a job is drawn
to another producer's standard on this plant's template.
extract:
rule_packs: [mc]
Undeclared means NO PACK LOADS and every note behaves exactly as it did
before this module existed -- unmatched notes land in `unresolved` under the
generic reason. That is the honest default: a machine that has been told
nothing about an office's habits should claim to recognise nothing.
A declared name this module does not know is REFUSED, loudly, and no pack
loads at all. Same discipline as `tools/setup_machine.py`'s `.setup-opts`
check: an opt file that has gone quiet about a line nobody honours is worse
than no opt file, because the reader believes something is in force that is
not. `problems` comes back beside the packs and the caller prints it.
Nothing here imports a module named in knowledge. `PACKS` is a whitelist of
names this package ships; a name outside it is an error, never an import.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
# Declared name -> module basename inside this package. A WHITELIST: the
# knowledge layer chooses among these, it never names a module to import.
PACKS = {
'mc': 'mc',
}
KEY = 'extract.rule_packs'
# What a pack may return from `classify`. `note` is carried into the spec's
# notes; `furniture` stays in unresolved with a better reason. There is no
# verdict that drops a line -- see the module docstring.
NOTE = 'note'
FURNITURE = 'furniture'
KINDS = (NOTE, FURNITURE)
class Placement(object):
"""One pack's verdict on one note.
`rule` is the pack-qualified id of the rule that fired (`mc/legend-title`)
so a reader of the spec can find the line of code that made the claim, and
argue with it. A placement that cannot say which rule made it is not
accepted -- an unattributable classification is exactly the kind nobody
can correct.
"""
__slots__ = ('kind', 'rule', 'reason')
def __init__(self, kind, rule, reason):
if kind not in KINDS:
raise ValueError('unknown placement kind: %r' % (kind,))
if not rule:
raise ValueError('a placement must name the rule that made it')
self.kind = kind
self.rule = rule
self.reason = reason
def __repr__(self):
return 'Placement(%r, %r)' % (self.kind, self.rule)
def load(know, get=None):
"""(packs, problems) for the rule packs this knowledge declares.
`packs` is a list of imported pack modules, in declared order. `problems`
is a list of one-line strings naming anything refused; when it is
non-empty `packs` is EMPTY -- a half-loaded rule set would classify some
notes and not others, and the reader could not tell which sentences to
trust. All or nothing, and say why.
"""
if get is None:
from vibe import knowledge as K
get = K.get
declared = get(know, KEY, None) or []
if isinstance(declared, str):
declared = [declared]
problems = []
names = []
for raw in declared:
name = (raw or '').strip().lower()
if not name:
problems.append('an empty name in %s -- remove the entry or '
'name a pack' % (KEY,))
elif name not in PACKS:
problems.append("%s names '%s', which this build does not ship. "
'Known packs: %s'
% (KEY, name, ', '.join(sorted(PACKS)) or '(none)'))
elif name in names:
problems.append("%s names '%s' twice" % (KEY, name))
else:
names.append(name)
if problems:
return [], problems
packs = []
for name in names:
packs.append(__import__('vibe.extractrules.' + PACKS[name],
globals(), locals(), ['classify'], 0))
return packs, []
def classify(text, packs):
"""The first pack that recognises `text`, or None.
First, not best: packs are tried in the order they were declared, and the
reader can see that order in the job file. Nothing here merges two packs'
opinions -- a note that two house styles both claim is a fact about the
declaration, not something this function should quietly average.
"""
for pack in (packs or []):
placed = pack.classify(text)
if placed is not None:
return placed
return None
def apply_to(entries, packs):
"""Split `unresolved` entries into (notes, unresolved) using the packs.
`entries` are the dicts `ticketqc.unresolved_in_view` returns -- each
carries at least 'text' and 'reason'. Every entry comes back in exactly
one of the two lists and none is dropped: `len(notes) + len(unresolved)`
always equals `len(entries)`, which `test_extractrules` asserts directly
because it is the property the whole module rests on.
A `note` gains 'rule' so the spec says which rule claimed it. A
`furniture` keeps its place in unresolved and only its 'reason' changes.
"""
notes = []
unresolved = []
for entry in (entries or []):
placed = classify(entry.get('text') or '', packs)
if placed is None:
unresolved.append(entry)
continue
out = dict(entry)
out['rule'] = placed.rule
if placed.reason:
out['reason'] = placed.reason
if placed.kind == NOTE:
notes.append(out)
else:
unresolved.append(out)
return notes, unresolved
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""The `mc` pack: one producer's design-summary habits.
Loaded only when the plant layer declares it (`extract.rule_packs: [mc]`) --
see `vibe/extractrules/__init__.py` for why that is declared and not inferred
from `plant.id`.
WHERE THESE RULES COME FROM
---------------------------
Every rule below was written against text observed on a real sheet, and each
one says which. They are not a model of how this producer draws; they are a
record of lines that actually landed in `unresolved` and what they turned out
to be. A rule with no observation behind it does not belong here -- this file
is evidence, and a guess mixed into evidence is indistinguishable from it
afterwards.
Observed on `WPA1.0` (sample_job, 8" horizontal wall, read 2026-09-01):
all seven notes the generic reader could not place, listed in the order they
sit on the sheet.
WHAT IS DELIBERATELY NOT HERE
------------------------------
`STRIPPING & BLOCKING DETAIL < 25'` is NOT treated as the same thing as the
bare `STRIPPING & BLOCKING DETAIL` beside it. The bare one is the title of a
detail drawn on the sheet and carries no data. The other one carries a
CONDITION -- which detail applies below twenty-five feet -- and a condition is
the part that could change what gets built. So the bare title is furniture and
the conditional one is a note, and the rule that separates them tests for
anything trailing the title rather than matching both with one loose pattern.
That asymmetry is the module's standing rule (`__init__`: when unsure, note).
Nothing here encodes a NUMBER. `*USE MC STANDARD REINFORCEMENT ...` names a
standard this office keeps; what that standard says is plant knowledge and
belongs in the plant knowledge file, looked up by name, never frozen into a
regex in this file. The rule's whole job is to carry the sentence to a field a
person reads.
IronPython 2.7: no f-strings, no pathlib, no typing, no dataclasses.
"""
import re
from vibe.extractrules import Placement, NOTE, FURNITURE
NAME = 'mc'
def _rule(rule_id, pattern, kind, reason):
return (rule_id, re.compile(pattern, re.IGNORECASE), kind, reason)
# Order matters and is not alphabetical: a more specific rule must come before
# the looser one it would otherwise be swallowed by. The conditional detail
# title is tested before the bare one for exactly that reason.
RULES = (
# ---------------------------------------------------------- instructions
_rule('mc/standard-reinforcement',
r"^\*?\s*USE\s+MC\s+STANDARD\s+REINFORCEMENT\b",
NOTE,
'a standing instruction naming this plant\'s standard reinforcement '
'for cut-down panels and openings -- prose, not a number; what the '
'standard says is plant knowledge, looked up by name'),
_rule('mc/conditional-bar',
r"^#\s*\d+\s+BAR\b.*\bONLY\s+NEEDED\s+WHEN\b",
NOTE,
'a conditional reinforcing instruction -- the bar is required only '
'when the condition in the sentence holds, so the condition is the '
'value and it stays as prose'),
_rule('mc/pick-instruction',
r"^USE\s+FOR\b.*\bPICK\b",
NOTE,
'a handling instruction saying which pick this detail is for -- '
'prose, and it qualifies a detail rather than stating a number'),
_rule('mc/detail-title-conditional',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\b\s*\S+",
NOTE,
'a standard-detail title carrying a CONDITION (which detail applies, '
'and when) -- the title alone would be furniture, the condition is '
'not'),
# ------------------------------------------------------------- furniture
_rule('mc/detail-title',
r"^STRIPPING\s*&\s*BLOCKING\s+DETAIL\s*$",
FURNITURE,
'the bare title of a standard detail drawn on the sheet -- names '
'the detail, states nothing about this design'),
_rule('mc/legend-title',
r"^STRAND\s+PATTERN\s*$",
FURNITURE,
'the title above the strand-pattern legend -- the legend\'s DATA is '
'the dimension chain under it, which is read as geometry, not as '
'this text'),
_rule('mc/legend-label',
r"^STRAND\s*$",
FURNITURE,
'a bare column label in the strand legend -- a heading over values '
'read elsewhere, with no value of its own'),
)
def classify(text):
"""A Placement for `text`, or None when this pack does not recognise it.
`text` arrives already whitespace-normalised by the caller (one space
between words, no leading or trailing run), so every pattern here can
assume single spaces and anchor at both ends where it means to.
"""
probe = (text or '').strip()
if not probe:
return None
for rule_id, pattern, kind, reason in RULES:
if pattern.search(probe):
return Placement(kind, rule_id, reason)
return None
+521
View File
@@ -0,0 +1,521 @@
# -*- coding: utf-8 -*-
# Owner, verbatim (2026-08-30), about the reinforcing report:
# "最大的,最上面的部分是target跟doner。其余的部分都跟在后面,小一点,并且做成
# 可以折叠的样子。"
# "这里面其实有大量的我不在乎的信息。除非有啥问题我感觉我不会读。这种东西都
# 给做成默认折叠。"
"""Reports that fold: a headline you read, and detail that opens only when it
has a problem in it. No Revit here, and nothing report-specific either.
THE RULE THIS IMPLEMENTS, AND WHOSE IT IS
------------------------------------------
The owner, 2026-08-30, about the reinforcing report:
"The biggest, top-most part is the target and the donor. Everything
else follows after, smaller, and made foldable."
"There is actually a large amount of information in here that I do not
care about. Unless something is wrong I don't think I will read it.
Make this kind of thing default-folded."
Which is a stronger statement than a layout preference, and it is written down
here rather than in one button because it generalises to every report this
tool prints:
* THE ANSWER IS THE HEADLINE. Biggest, first, never folded.
* EVERYTHING ELSE STARTS CLOSED. He will not read it and does not want to
see it.
* A SECTION THAT CARRIES A PROBLEM OPENS ITSELF. A check that did not run, a
caveat that actually bites, a gap somebody has to act on. The reader's
contract becomes: **if nothing below the headline is open, nothing needs
me.** That contract is the whole value, and it is worth more than any
individual section being visible -- which is why `problem` is a SENTENCE
naming what is wrong, not a boolean: a section that opens has to say why
it opened.
* FOLDED IS NOT GONE. Every closed section still prints a one-line header
saying what is inside it and how much. Nothing is hidden; things are
folded. A caveat that has been removed from the page is a caveat the tool
has decided the reader does not need, and this project has already paid
for one of those.
WHAT THE OUTPUT PANEL ACTUALLY IS, AND WHY THE HTML LOOKS DATED
----------------------------------------------------------------
pyRevit's output window is a `System.Windows.Forms.WebBrowser`
(`pyrevit/output/__init__.py`, line 106) -- the IE engine hosted in a WinForms
control, NOT a modern browser and not the environment the panellift HTML
report renders in. A WinForms WebBrowser runs in IE7 document mode unless the
host process has a `FEATURE_BROWSER_EMULATION` registry entry, and pyRevit
sets none. So:
* `<details>` / `<summary>` DO NOT EXIST here. They are HTML5. This is the
trap, because they are the obvious answer and they fail silently -- an
unknown element renders as an inline span and the "collapsed" content is
simply always visible.
* `addEventListener`, `classList`, `querySelector` are all out.
* `document.getElementById(id).style.display` and an inline `onclick`
attribute are IE5-era DOM and work in every mode. That is what this uses.
JavaScript definitely runs in the panel: pyRevit's own charts feature injects
Chart.js into it (`coreutils/charts.py`). So the toggle should work. But it
has NOT been exercised inside Revit by the agent that wrote it -- there was no
bridge available -- so the failure is designed for rather than hoped against:
* A PROBLEM SECTION IS NEVER FOLDED IN THE FIRST PLACE. It renders open, as
plain HTML, with no dependence on the script running at all. If the toggle
is dead, every section that matters is still on the page.
* A folded section that will not open loses nothing either, as long as the
caller writes the full text out as well -- `text(sections, show_all=True)`
is that, and `reinforcereport` hands the button a path to say so in the
footer.
THE BLOCK SHAPE
---------------
A section holds `('md', text)` and `('table', columns, rows)` tuples, the same
two the reports already produce. Markdown, not HTML, because the same sections
have to render as plain text at a command line -- `tools/reinforce_offline.py`
has no panel -- and a report that became HTML-only would have to be written
twice and would drift.
IronPython 2.7: no f-strings, no pathlib, no dataclasses.
"""
# pylint: disable=invalid-name
import re
import textwrap
MD = 'md'
TABLE = 'table'
class Section(object):
"""One foldable part of a report.
`key` an id, unique on the page. Used for the toggle's element id.
`title` the one-line header. ALWAYS shown, open or closed.
`summary` what is inside, in numbers, shown on the header when closed.
A header that says only "details" tells the reader nothing
about whether to open it.
`blocks` ('md', text) / ('table', columns, rows).
`problem` None for routine. A SENTENCE when something in here needs the
reader -- it is printed on the header and it forces the
section open.
`headline` True for the part that is the answer: printed big, first, and
never given a fold at all.
"""
def __init__(self, key, title, summary=None, blocks=None, problem=None,
headline=False):
self.key = key
self.title = title
self.summary = summary
self.blocks = list(blocks or [])
self.problem = problem or None
self.headline = bool(headline)
@property
def is_open(self):
"""Open when it is the answer, or when it has a problem in it."""
return self.headline or bool(self.problem)
def add(self, block):
self.blocks.append(block)
return self
def md(self, text):
return self.add((MD, text))
def table(self, columns, rows):
return self.add((TABLE, columns, rows))
def __repr__(self):
return '<Section {0} {1}>'.format(
self.key, 'OPEN' if self.is_open else 'folded')
def sections_with_problems(sections):
"""The ones that opened themselves, in order. What the reader must see."""
return [s for s in sections if s.problem]
def problem_count(sections):
return len(sections_with_problems(sections))
# ------------------------------------------------------------------ as blocks
def blocks(sections, show_all=False):
"""Every section flattened to blocks, folding the routine ones.
A folded section becomes ONE line -- its title, its summary and nothing
else. `show_all` prints everything, which is what a file written for the
record wants and what `--all` gives at a command line.
"""
out = []
for section in sections or []:
if section.headline:
out.extend(section.blocks)
continue
if section.is_open or show_all:
out.append((MD, _open_header(section)))
out.extend(section.blocks)
else:
out.append((MD, _folded_header(section)))
return out
def _open_header(section):
head = '## ' + _text(section.title)
if section.problem:
head += ' &mdash; ' + _text(section.problem)
return head
def _folded_header(section):
bits = ['**' + _text(section.title) + '**']
if section.summary:
bits.append(_text(section.summary))
return '- ' + _text(' &mdash; ').join(bits) + ' _(folded)_'
# ------------------------------------------------------------------- as text
def _text(value):
"""`value` as the interpreter's own text type -- never through `str()`.
THE TEXT REPORT LOST THE OWNER'S OWN WORDS THROUGH THIS PATH, measured
2026-08-31 in the sibling renderer: `last-ticketqc.txt` was written with
every CJK character of the job file's mute sentences removed -- the ASCII
fragments and the gaps between them survived -- while the same sentences
rendered correctly in the panel and in `qc-run.json`. A file promised as
the complete record that silently drops half a sentence is worse than one
that fails to write.
`vibe.compat` is the project's one answer to this class: it returns the
engine's text type, decoding bytes as UTF-8, and the caller encodes once
on the way to disk. Imported lazily so this module keeps the standard-
library-only shape it has at import time.
"""
from vibe import compat
return compat.as_text('' if value is None else value)
def text(sections, show_all=False, width=78):
"""The whole report as plain text. What a terminal and a log file want."""
lines = []
for block in blocks(sections, show_all=show_all):
lines.append(_text(as_text(block, width=width)))
return _text('\n').join(lines)
def as_text(block, width=78):
if block[0] == TABLE:
return table_text(block[1], block[2])
return md_text(block[1], width=width)
def md_text(md, width=78):
"""Markdown as these reports write it -> plain text.
Nothing is reworded. Entities become ASCII (the extension is IronPython
2.7, where a non-ASCII source literal is a byte string, so the reports
write `&mdash;` rather than the character), emphasis marks are dropped,
and headings get a rule under them.
NOR IS ANYTHING DROPPED. Every line goes out through `_fill`, and
`_flat` coerces with `_text` on the way in -- read both before
putting a bare `textwrap` call or a `str()` back here.
"""
body = (md.replace('&mdash;', '--').replace('&nbsp;', ' ')
.replace('&dagger;', '+').replace('&amp;', '&'))
if body.startswith('# '):
title = _flat(body[2:])
return '\n' + '=' * width + '\n' + title + '\n' + '=' * width
if body.startswith('## '):
title = _flat(body[3:])
return '\n' + title + '\n' + '-' * min(width, len(title))
if body.strip() == '---':
return '-' * width
quoted = body.startswith('> ')
if quoted:
body = body[2:]
listed = body.startswith('- ')
prefix = ' ! ' if quoted else ' '
# A folded section's header is a list item, and its continuation lines are
# indented past the dash so a two-line header cannot read as two headers.
tail = ' ' * (len(prefix) + (2 if listed else 0))
# NOTHING LONG IS EVER BROKEN. A file path or a piece mark split across
# two lines is not a wrapped sentence, it is an unusable value: the reader
# copies these into Revit's Select by ID or into a shell. Better a line
# that runs past the margin than one that has to be reassembled.
return _fill(_flat(body), width, prefix, tail)
def _fill(body, width, initial_indent, subsequent_indent):
"""`textwrap.fill`, with its whitespace TRANSLATE step taken out of
the path -- and the reason is invisible on the interpreter the suite
runs on.
`textwrap` munges whitespace by calling `text.translate(<a 256-entry
table>)` on anything it takes for a `str`. On CPython that table is
applied to a byte string, where every byte has an entry; the engine
this extension ships to is IronPython 2.7, whose `str` holds .NET
UTF-16 text, so A CHARACTER ABOVE U+00FF HAS NO ENTRY IN THE TABLE AT
ALL -- and the report came back missing exactly those characters and
nothing else. Measured on the sibling renderer, 2026-08-31.
Everything reaching here has already been flattened by `_flat`, so
there is no whitespace left to munge and `textwrap` is told not to
try. Nothing about the wrapping changes.
`ticketpage._wrap_text` is the same cure at the other renderer; the
two are one measured defect, not two style preferences.
NOTHING LONG IS EVER BROKEN, unchanged: a file path or a piece mark
split across two lines is not a wrapped sentence, it is an unusable
value -- the reader copies these into Revit or into a shell.
"""
return textwrap.fill(_text(body), width=width,
initial_indent=initial_indent,
subsequent_indent=subsequent_indent,
break_long_words=False, break_on_hyphens=False,
expand_tabs=False, replace_whitespace=False)
def _flat(body):
return (' '.join(_text(body).split())
.replace('**', '').replace('`', '').replace('__', ''))
def table_text(columns, rows):
"""A fixed-width table. Nothing is truncated: every cell is a measurement
or a piece mark, and a silently cut mark is a wrong answer that looks like
a right one.
The column widths are counted in CHARACTERS, so a cell of CJK -- two
columns wide in a terminal font and one character long -- leaves its
column looking ragged. That is cosmetic and it is the only thing this
gets wrong about such a cell: nothing is dropped, cut or reordered.
Straightening it needs east-asian-width tables; nobody has asked."""
if not rows:
return ' (nothing)'
grid = [[_cell(c) for c in columns]]
grid += [[_cell(c) for c in row] for row in rows]
width = [max(len(row[i]) for row in grid) for i in range(len(columns))]
out = [' ' + ' '.join(c.ljust(width[i]) for i, c in enumerate(grid[0])),
' ' + ' '.join('-' * w for w in width)]
for row in grid[1:]:
out.append(' ' + ' '.join(c.ljust(width[i])
for i, c in enumerate(row)))
return '\n'.join(out)
def _cell(value):
# `_text`, not `str`: a cell can hold a mark, a note, or a sentence
# somebody typed. This is the text path's OTHER coercion boundary --
# `md_text` itself has none, its only exposure was the wrap.
return (_text(value).replace('&mdash;', '--').replace('&dagger;', '+')
.replace('&nbsp;', ' ').replace('&amp;', '&'))
# ------------------------------------------------------------------- as HTML
#
# Everything below is written for an IE-engine WebBrowser control. See the
# module docstring: no HTML5 elements, no modern DOM, inline handlers only.
STYLE = """
.wt-fold { margin: 10px 0 0 0; }
.wt-head { background: #eef1f5; border-left: 4px solid #9fb0c4;
padding: 5px 8px; cursor: pointer; font-size: 8pt; color: #445; }
.wt-head-open { background: #f9f2f4; border-left: 4px solid #c7254e;
color: #c7254e; cursor: pointer; padding: 5px 8px;
font-size: 8pt; }
.wt-body { padding: 2px 8px 8px 14px; font-size: 8pt; }
.wt-why { font-weight: bold; }
.wt-hint { color: #889; font-size: 7pt; }
"""
# The toggle. `document.getElementById` and `style.display` are IE5-era DOM
# and work in every document mode a WinForms WebBrowser can be in; nothing
# here uses addEventListener, classList or querySelector, and nothing uses
# <details>, which does not exist in this engine at all.
SCRIPT = """
function wtFold(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.display == 'none') {
el.style.display = 'block';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
} else {
el.style.display = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
}
}
function wtClip(id) {
var el = document.getElementById(id);
var hd = document.getElementById(id + '_h');
if (!el) { return; }
if (el.style.maxHeight == 'none') {
el.style.maxHeight = el.getAttribute('wtcap');
if (hd) { hd.innerHTML = hd.innerHTML.replace('[-]', '[+]'); }
} else {
el.style.maxHeight = 'none';
if (hd) { hd.innerHTML = hd.innerHTML.replace('[+]', '[-]'); }
}
}
"""
# `wtClip` IS `wtFold`'S SIBLING, NOT A SECOND MECHANISM.
#
# Same conventions, deliberately: an inline `onclick`, an element id, a
# header at `id + '_h'`, and the same `[+]`/`[-]` marker swap. It exists
# because the two folds hide differently and one function cannot do both:
#
# wtFold toggles `display`, for a block that is ABSENT when closed. Used
# by every section of a folding report.
# wtClip toggles `max-height` against the element's own `wtcap`
# attribute, for a block that is WHOLLY PRESENT and merely too
# tall -- the checklist's note cells (`ticketpage.note_cell_html`).
# Its text stays in the document when collapsed, which is exactly
# why it is not `display: none`: a reader can still select and
# copy the part he cannot see, and a browser's own find still
# reaches it.
#
# Both are IE5-era DOM. `style.maxHeight` and `getAttribute` are as old as
# `style.display`, and nothing here uses addEventListener, classList,
# querySelector or `<details>`.
def html(sections, md_to_html=None, headline=False):
"""The folding half of the report as one HTML string.
`headline` sections are skipped by default: the button prints those
through pyRevit's own `print_md` / `print_table`, because that is what can
linkify an element id and what the reader is used to reading. Pass
`headline=True` to render everything.
`md_to_html` converts one markdown block. The default handles exactly the
vocabulary these reports use; a caller with a real markdown library (the
button has `pyrevit.coreutils.markdown`) can pass that instead.
"""
md_to_html = md_to_html or md_html
out = []
for section in sections or []:
if section.headline and not headline:
continue
out.append(_section_html(section, md_to_html))
# ONE LINE, NO NEWLINE ANYWHERE IN IT. pyRevit's `print_html` is a
# `print(..., end='')` into the panel's stream, and every newline in that
# stream starts a new output entry -- which would cut a `<div>` in half
# and leave a fold's body outside its own container. So joined with
# nothing, here and inside each section.
return ''.join(out)
def _section_html(section, md_to_html):
body = []
for block in section.blocks:
if block[0] == TABLE:
body.append(table_html(block[1], block[2]))
else:
body.append(md_to_html(block[1]))
shown = section.is_open
mark = '[-]' if shown else '[+]'
# Escaped, then the report's own entities put back -- exactly as
# `_cell_html` and `md_html` do. A header may carry `&mdash;` like any
# other report string; without the restore it would reach the panel as
# the literal source text.
head = [mark, '<b>' + _entities(_escape(section.title)) + '</b>']
if section.problem:
head.append('<span class="wt-why">&mdash; ' +
_entities(_escape(section.problem)) + '</span>')
elif section.summary:
head.append('&mdash; ' + _entities(_escape(section.summary)))
if not shown:
head.append('<span class="wt-hint">(click to open)</span>')
return (
'<div class="wt-fold">'
'<div id="{0}_h" class="{1}" onclick="wtFold(\'{0}\')">{2}</div>'
'<div id="{0}" class="wt-body" style="display:{3}">{4}</div>'
'</div>').format(
_id(section.key),
'wt-head-open' if section.problem else 'wt-head',
' '.join(head),
'block' if shown else 'none',
''.join(body))
def _id(key):
return 'wt_' + re.sub(r'[^0-9A-Za-z_]', '_', str(key))
def table_html(columns, rows):
if not rows:
return '<p><i>(nothing)</i></p>'
out = ['<table><tr>']
for column in columns:
out.append('<th>' + _escape(str(column)) + '</th>')
out.append('</tr>')
for row in rows:
out.append('<tr>')
for cell in row:
out.append('<td>' + _cell_html(cell) + '</td>')
out.append('</tr>')
out.append('</table>')
return ''.join(out)
def _cell_html(value):
# Cells already carry the report's own entities (`&mdash;`, `&dagger;`)
# and its marks. They are escaped and the entities put back, so a mark
# containing an ampersand cannot break the row and a dagger still prints.
return _entities(_escape(str(value)))
# The report's whole markdown vocabulary, and nothing else: it is a fixed set
# written by one module, not arbitrary user text, so a 30-line converter is
# honest here where a general one would be a dependency.
_BOLD = re.compile(r'\*\*(.+?)\*\*')
_CODE = re.compile(r'`(.+?)`')
_ITALIC = re.compile(r'(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])')
def md_html(md):
"""One markdown block -> HTML, for the panel."""
text_ = md or ''
if text_.strip() == '---':
return '<hr/>'
tag, body = 'p', text_
if body.startswith('### '):
tag, body = 'h4', body[4:]
elif body.startswith('## '):
tag, body = 'h3', body[3:]
elif body.startswith('# '):
tag, body = 'h2', body[2:]
quoted = body.startswith('> ')
if quoted:
body = body[2:]
body = _escape(body)
body = _BOLD.sub(r'<b>\1</b>', body)
body = _CODE.sub(r'<code>\1</code>', body)
body = _ITALIC.sub(r'<i>\1</i>', body)
body = _entities(body)
if quoted:
return ('<div style="border-left:3px solid #9fb0c4;padding-left:8px">'
'<' + tag + '>' + body + '</' + tag + '></div>')
return '<' + tag + '>' + body + '</' + tag + '>'
def _escape(body):
return (body.replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;'))
def _entities(body):
"""Put back the handful of entities the reports write on purpose.
They are escaped along with everything else on the way in -- that is the
point of escaping -- and then restored by name here, so the reports keep
writing `&mdash;` (ASCII source, IronPython 2.7 safe) and the panel keeps
printing an em dash.
"""
for name in ('mdash', 'dagger', 'nbsp', 'ndash', 'hellip'):
body = body.replace('&amp;' + name + ';', '&' + name + ';')
return body
+279
View File
@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""Small, boring helpers that remove the usual Revit API boilerplate.
Written for IronPython 2.7 (pyRevit's default engine) but 3.x-safe:
no f-strings, no walrus.
"""
# pylint: disable=import-error,invalid-name,broad-except
from contextlib import contextmanager
from Autodesk.Revit import DB, UI # noqa: F401
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, BuiltInParameter,
ElementId, StorageType, Transaction, TransactionGroup,
UnitUtils,
)
# UnitTypeId arrived in Revit 2021 and DisplayUnitType was removed in 2022, so
# the two never overlap for long. Resolving the unit handles once here keeps a
# host outside that window from failing the import of the whole library --
# which would take every button down, not just the four conversions below.
try:
from Autodesk.Revit.DB import UnitTypeId
_MILLIMETERS = UnitTypeId.Millimeters
_METERS = UnitTypeId.Meters
_SQUARE_METERS = UnitTypeId.SquareMeters
except ImportError: # Revit 2020 and older
from Autodesk.Revit.DB import DisplayUnitType
_MILLIMETERS = DisplayUnitType.DUT_MILLIMETERS
_METERS = DisplayUnitType.DUT_METERS
_SQUARE_METERS = DisplayUnitType.DUT_SQUARE_METERS
from pyrevit import revit, script, HOST_APP
# ------------------------------------------------------------ session handles
#
# revit.doc / revit.uidoc are LIVE @property accessors -- they always return
# whichever document is active RIGHT NOW. Assigning one to a module global
# snapshots its value ONCE, at the first import of `vibe`, and freezes it to
# whatever document happened to be active then. With two models open and the
# user switching between them that snapshot goes stale, and any helper that
# read it would silently act on the wrong model -- a transaction opened on the
# stale doc, a collector enumerating the stale doc's elements with no error.
#
# So the helpers below NEVER read these globals; they call _doc()/_uidoc()/
# _uiapp(), which re-resolve on every call. The globals are kept only for
# backward compatibility: `vibe/__init__.py` re-exports them, and some callers
# do `from vibe.helpers import doc`. IronPython 2.7 has no module-level
# __getattr__ (that is Python 3.7+), so these names cannot be made to
# re-resolve on plain attribute access -- a caller that reads helpers.doc /
# vibe.doc still gets the frozen snapshot. Any such caller in a
# model-reading or transaction path must be repointed to _doc() (or read
# revit.doc directly) instead of these names.
uiapp = HOST_APP.uiapp
uidoc = revit.uidoc
doc = revit.doc
def _uiapp():
"""The live UIApplication, re-resolved on each call (never a snapshot)."""
return HOST_APP.uiapp
def _uidoc():
"""The active UIDocument, re-resolved on each call (never a snapshot)."""
return revit.uidoc
def _doc():
"""The active Document, re-resolved on each call (never a snapshot)."""
return revit.doc
# ----------------------------------------------------------------- collecting
def _as_bic(category):
"""Accept BuiltInCategory, 'OST_Walls', or 'Walls'."""
if isinstance(category, BuiltInCategory):
return category
name = str(category)
if not name.startswith('OST_'):
name = 'OST_' + name.replace(' ', '')
bic = getattr(BuiltInCategory, name, None)
if bic is None:
raise ValueError('unknown BuiltInCategory: ' + str(category))
return bic
def q(category=None, cls=None, view=None, types=False, document=None):
"""One-liner element query. Returns a list.
q('OST_Walls') -> all wall instances
q(cls=DB.Wall) -> same, by class
q('OST_Doors', view=doc.ActiveView)-> only what's visible in the view
q('OST_Walls', types=True) -> wall types
"""
d = document or _doc()
col = FilteredElementCollector(d, view.Id) if view is not None \
else FilteredElementCollector(d)
if category is not None:
col = col.OfCategory(_as_bic(category))
if cls is not None:
col = col.OfClass(cls)
col = col.WhereElementIsElementType() if types \
else col.WhereElementIsNotElementType()
return list(col.ToElements())
def all_of(category, document=None):
"""All instances of a category."""
return q(category, document=document)
def types_of(category, document=None):
"""All element types of a category."""
return q(category, types=True, document=document)
def by_name(elements, name):
"""First element whose .Name matches, else None."""
for el in elements:
try:
if el.Name == name:
return el
except Exception:
continue
return None
def selected(document=None):
"""Currently selected elements as a list."""
d = document or _doc()
return [d.GetElement(i) for i in _uidoc().Selection.GetElementIds()]
def pick(message='Pick elements', multiple=True):
"""Interactive pick. Returns a list of elements (empty if cancelled)."""
from Autodesk.Revit.UI.Selection import ObjectType
_uidoc_ = _uidoc()
_doc_ = _doc()
try:
if multiple:
refs = _uidoc_.Selection.PickObjects(ObjectType.Element, message)
return [_doc_.GetElement(r) for r in refs]
ref = _uidoc_.Selection.PickObject(ObjectType.Element, message)
return [_doc_.GetElement(ref)]
except Exception:
return []
# ---------------------------------------------------------------- parameters
def _find_param(el, name):
"""Resolve a parameter by BuiltInParameter, exact name, then case-insensitive."""
if isinstance(name, BuiltInParameter):
return el.get_Parameter(name)
prm = el.LookupParameter(str(name))
if prm is not None:
return prm
lowered = str(name).lower()
for candidate in el.Parameters:
if candidate.Definition.Name.lower() == lowered:
return candidate
return None
def p(el, name, default=None):
"""Read a parameter value, typed. Falls back to `default` if missing."""
prm = _find_param(el, name)
if prm is None:
return default
st = prm.StorageType
if st == StorageType.String:
return prm.AsString()
if st == StorageType.Integer:
return prm.AsInteger()
if st == StorageType.Double:
return prm.AsDouble()
if st == StorageType.ElementId:
return prm.AsElementId()
return default
def p_display(el, name, default=None):
"""Read a parameter as the string Revit shows in the UI (with units)."""
prm = _find_param(el, name)
if prm is None:
return default
val = prm.AsValueString()
return val if val is not None else prm.AsString()
def set_p(el, name, value):
"""Write a parameter. Must be called inside a transaction.
Returns True on success, False if the parameter is missing or read-only.
"""
prm = _find_param(el, name)
if prm is None or prm.IsReadOnly:
return False
return bool(prm.Set(value))
# -------------------------------------------------------------- transactions
@contextmanager
def tx(name='vibe'):
"""Transaction context manager - commits on success, rolls back on error.
with tx('Rename walls'):
set_p(wall, 'Comments', 'checked')
"""
t = Transaction(_doc(), name)
t.Start()
try:
yield t
t.Commit()
except Exception:
if t.HasStarted() and not t.HasEnded():
t.RollBack()
raise
@contextmanager
def txg(name='vibe group'):
"""Transaction group - lets you undo several transactions as one step."""
g = TransactionGroup(_doc(), name)
g.Start()
try:
yield g
g.Assimilate()
except Exception:
if g.HasStarted() and not g.HasEnded():
g.RollBack()
raise
# --------------------------------------------------------------------- units
# Revit stores lengths in decimal FEET internally, always.
def ft(millimeters):
"""mm -> internal feet."""
return UnitUtils.ConvertToInternalUnits(millimeters, _MILLIMETERS)
def mm(internal_feet):
"""internal feet -> mm."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _MILLIMETERS)
def m(internal_feet):
"""internal feet -> meters."""
return UnitUtils.ConvertFromInternalUnits(internal_feet, _METERS)
def sqft_to_sqm(internal_area):
"""internal square feet -> square meters."""
return UnitUtils.ConvertFromInternalUnits(internal_area, _SQUARE_METERS)
# -------------------------------------------------------------------- output
def out():
"""The pyRevit output window for the running script."""
return script.get_output()
def log(*args):
"""print() that always lands in the pyRevit output window."""
print(' '.join(str(a) for a in args))
def table(rows, columns=None, title=None):
"""Render a list of lists as a table in the pyRevit output window."""
output = script.get_output()
if title:
output.print_md('### ' + title)
output.print_table(table_data=[[str(c) for c in r] for r in rows],
columns=columns or [])
+185
View File
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""Which job's knowledge layer a HOST-SIDE tool should read, with no doc open.
Inside Revit, `designspec.root_dir(doc)` finds a job's `_ticketqc` folder by
`doc.Title` -- WILLTOOLS_JOBS wins when it is set, APPDATA is the fallback for
a model with no path yet. That lookup needs a live document and cannot be
ported here: a terminal running `tools/snapshot_cycle.py` or
`tools/knowledge_drift.py` has no `doc.Title` to pick a folder by, and
guessing one from several candidates is a worse failure than asking, because a
wrong guess runs silently on the wrong job's settings and looks completely
normal -- the exact failure mode this whole codebase is built against
(see CLAUDE.md's "could not judge" discipline).
THE RESOLUTION ORDER, deliberately, and in this order only:
1. an EXPLICIT file (a `--knowledge` flag, or an env var that names a
FILE) wins outright. The caller already knows which file; nothing
here second-guesses it, and a bad path is reported the ordinary way a
missing file is reported (by whoever tries to read it), not specially
here.
2. WILLTOOLS_JOBS names a folder holding EXACTLY ONE job subfolder (one
that has a `knowledge.yaml` in it) -- unambiguous, so it is used with
no further input.
3. a NAMED job (`--job "sample_job"`) picks one out of several by
name.
4. otherwise: REFUSE, and name every candidate folder found. This is the
correct behaviour when several real job folders exist and none was
named -- not a hole to fill in later. Silently picking "the newest
one" or "the one that happens to set the key being asked for" is a
guess wearing the clothes of a rule, and the day it guesses wrong the
run looks completely normal while reading another job's tolerances.
This is intentionally the only place this order is written down. Two tools
reading job knowledge off disk had two different half-answers to "where is
it" before this module existed -- `tools/snapshot_cycle.py` required an
explicit `--knowledge` and reported the resulting absence as if the setting
itself were missing, and `tools/knowledge_drift.py` walked a path hardcoded
to this checkout and ignored WILLTOOLS_JOBS entirely. Both import this now.
`plantpath.py` is this file's sibling for the PLANT layer -- one FILE, chosen
by an env var that may name either a file or its folder. This is a different
shape (WILLTOOLS_JOBS names a folder that may hold several jobs, and there is
no `doc.Title` here to break the tie) so it earns its own module rather than
being bent to fit plantpath's shape, but the discipline is the same one:
never fall back to a plausible-looking wrong answer in silence, and say
which of "not configured", "not found" and "found but ambiguous" happened,
because they are different facts and read as different sentences.
IronPython 2.7: no f-strings, no pathlib. (This module has no Revit import at
module scope -- or at all -- so it is also safe to load from host CPython the
same way `plantpath.py` is: `tools/snapshot_cycle.py` and
`tools/knowledge_drift.py` both do, via the `vibe` package stub documented in
`WillTools.extension/tests/conftest.py`.)
"""
# pylint: disable=invalid-name
import os
JOBS_ENV = 'WILLTOOLS_JOBS' # same spelling as designspec.JOBS_ENV
KNOWLEDGE_FILENAME = 'knowledge.yaml'
class JobLocateResult(object):
"""What `resolve_job_knowledge()` found, or precisely why it did not.
`path` is set only on success -- callers should treat `bool(result)` (or
`result.path`) as the found/not-found test. `blocking` marks the
not-found cases that are a genuine misconfiguration worth stopping a run
over (an ambiguous WILLTOOLS_JOBS, a `--job` that names nothing, a
WILLTOOLS_JOBS that is not even a folder) as opposed to the ordinary
"nothing configured yet" case, which a caller may reasonably continue
past on DEFAULTS alone -- exactly as it always could when no `--knowledge`
was given.
"""
__slots__ = ('path', 'how', 'reason', 'candidates', 'root', 'blocking')
def __init__(self, path=None, how=None, reason=None, candidates=None,
root=None, blocking=False):
self.path = path
self.how = how
self.reason = reason
self.candidates = list(candidates or [])
self.root = root
self.blocking = bool(blocking)
def __bool__(self):
return self.path is not None
__nonzero__ = __bool__ # IronPython 2.7
def __repr__(self):
if self.path:
return 'JobLocateResult(path={0!r}, how={1!r})'.format(
self.path, self.how)
return 'JobLocateResult(reason={0!r})'.format(self.reason)
def job_candidates(jobs_root):
"""Every subfolder of `jobs_root` that carries a `knowledge.yaml`.
Sorted, so a refusal that lists them is stable across runs and a "single
unambiguous" check does not depend on directory-walk order. Nothing here
tries to tell a "real" job folder from a stray one (a leftover detach
copy, an old bootstrap) -- that would be exactly the judgement call this
module exists to refuse to make silently. Every folder with a
`knowledge.yaml` in it is a candidate; a human names the right one.
"""
out = []
if not jobs_root or not os.path.isdir(jobs_root):
return out
for name in sorted(os.listdir(jobs_root)):
folder = os.path.join(jobs_root, name)
if not os.path.isdir(folder):
continue
candidate = os.path.join(folder, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
out.append(candidate)
return out
def _job_name_of(knowledge_path):
return os.path.basename(os.path.dirname(knowledge_path))
def resolve_job_knowledge(explicit=None, jobs_root=None, job_name=None,
env=None):
"""Which job knowledge.yaml a host-side tool should read. A JobLocateResult.
explicit a path stated outright -- a `--knowledge` flag, or an env var
that already names a FILE (`WILLTOOLS_JOB_KNOWLEDGE`, as
`tools/snapshot_cycle.py`'s argparse default already reads).
Wins over everything below. Not checked for existence here:
whoever reads the file reports a missing one the ordinary
way, and duplicating that check here would be a second,
possibly-drifted answer to "does this file exist".
jobs_root WILLTOOLS_JOBS, already resolved by the caller. Pass `None`
to have it read from `env` here instead.
job_name a folder name under `jobs_root`, stated explicitly (a `--job`
flag) to pick one out of several.
env `os.environ` by default; overridable so tests never touch the
real environment.
"""
env = os.environ if env is None else env
if explicit:
return JobLocateResult(path=explicit, how='explicit')
root = jobs_root if jobs_root is not None else env.get(JOBS_ENV)
if not root:
return JobLocateResult(
reason='no job knowledge file was named (--knowledge) and {0} '
'is not set, so there is no folder to look for a job '
'under.'.format(JOBS_ENV))
if not os.path.isdir(root):
return JobLocateResult(
root=root, blocking=True,
reason='{0} is set to {1}, but that is not a folder.'
.format(JOBS_ENV, root))
if job_name:
candidate = os.path.join(root, job_name, KNOWLEDGE_FILENAME)
if os.path.isfile(candidate):
return JobLocateResult(path=candidate, how='named', root=root)
candidates = job_candidates(root)
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='no job named {0!r} under {1} (looked for {2}). Job '
'folders found there: {3}'
.format(job_name, root, candidate,
', '.join(names) or '(none)'))
candidates = job_candidates(root)
if len(candidates) == 1:
return JobLocateResult(path=candidates[0], how='single', root=root)
if not candidates:
return JobLocateResult(
root=root,
reason='{0} is set to {1}, but no job folder under it has a '
'{2}.'.format(JOBS_ENV, root, KNOWLEDGE_FILENAME))
names = [_job_name_of(c) for c in candidates]
return JobLocateResult(
root=root, candidates=candidates, blocking=True,
reason='{0} is {1}, which holds {2} job folders ({3}) -- there is '
'no doc.Title here to pick one by, so nothing here guesses. '
'Say which with --job, or point --knowledge at the file '
'directly.'.format(JOBS_ENV, root, len(candidates),
', '.join(names)))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""The Revit half of the job-level reconciliation: gather, then hand over.
`vibe.jobrecon` holds the reconciliation itself and knows nothing about
Revit, so it can be tested on host CPython. This module is the thin gatherer
that turns a live document into the plain dicts that module eats -- the same
split `ticketpage.py` / `ticketchecks.py` uses, for the same reason: the
arithmetic is where a wrong answer hides, and arithmetic must be testable
without a licence.
NO OFFICE PARAMETER NAME APPEARS IN THIS FILE. Everything is read by ROLE
through `vibe.knowledge`, and a role nobody has mapped reads as a REFUSAL,
not as an empty job. That distinction is the whole point of this tool: a
reconciliation that cannot see the control numbers must say so, because
"found nothing" and "looked at nothing" are the same output otherwise, and
this project has already shipped that bug once.
Roles this module needs, none of which the older checks used:
member.control_number the piece's control number -- ONE PHYSICAL
PIECE. The unit of the whole reconciliation.
sheet.control_numbers the title-block slots holding the control
numbers a ticket covers. A list of parameter
names, or leave it unset and give
`nomenclature.control_slot_re` instead, a
regex matched against sheet parameter names --
which is what a plant with numbered slots
(..._01 .. _30) wants.
sheet.total_required the quantity the title block states.
sheet.sheet_count how many pages the ticket says it has.
member.ticket_created the model's own claim that a ticket exists.
member.ticket_detailed ... and that it was detailed. Both optional.
Already-mapped roles it reuses: `member.mark`, `panel.design_number`,
`sheet.design_number`.
Cost, measured on a 2480-piece job: the piece sweep is about 6 s across six
categories and the sheet sweep about 1 s across 1105 sheets -- no geometry is
built anywhere, which is what keeps it in seconds rather than the 10 s a
690-piece geometry pass costs.
IronPython 2.7: no f-strings, no pathlib, no typing.
"""
# pylint: disable=import-error,invalid-name,broad-except
import re
import time
from Autodesk.Revit.DB import (
FilteredElementCollector, BuiltInCategory, AssemblyInstance, ViewSheet,
)
from vibe import jobrecon
# Where precast pieces can live. Not a filter on what counts as a piece --
# a piece is anything carrying a control number -- but the set of collectors
# the sweep opens. Every category here is reported with its tally, INCLUDING
# the zeros, so a reader can see what was looked at; categories outside this
# list are named in the result's blind spots rather than silently omitted.
#
# Never widen this to an unfiltered collector. A model-wide
# WhereElementIsNotElementType() walk killed Revit here on 2026-08-18.
PIECE_CATEGORIES = (
'OST_StructuralFraming',
'OST_StructuralColumns',
'OST_Walls',
'OST_Floors',
'OST_StructuralFoundation',
'OST_GenericModel',
)
# WHAT WAS LOOKED AT IS NOT A BLIND SPOT. `gather` returns ONE list of notes
# -- everything it wants said about the run -- and two different kinds live in
# it: 'I could not see X', which is a blind spot, and 'I opened these six
# category collectors and here is the tally', which is an inventory. The
# report folds the second and opens the first, so `run` has to tell them
# apart, and these two constants are how: the inventory notes are written
# with them three lines below and recognised by them in `is_inventory_note`.
#
# `gather`'s signature is deliberately NOT changed to return them separately.
# `vibe.takeoffgather` calls `gather` too and collects the same notes; a
# different tuple there would be a change to another button's path for the
# sake of this one's formatting.
INVENTORY_LEAD = 'categories searched:'
INVENTORY_INDENT = ' '
def is_inventory_note(note):
"""True for a note that is an INVENTORY of what was swept, not a blind
spot. See the constants above."""
text = str(note or '')
return text.startswith(INVENTORY_LEAD) or text.startswith(INVENTORY_INDENT)
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _param_text(param):
"""A parameter as text, whatever it is stored as."""
if param is None:
return None
try:
value = param.AsString()
except Exception:
value = None
if value in (None, ''):
try:
value = param.AsValueString()
except Exception:
value = None
return value
def _read(el, names):
"""First non-empty value across candidate parameter names."""
for name in names or ():
value = _param_text(el.LookupParameter(name))
if value not in (None, ''):
return value
return None
def _roles(know, overrides=None, _doc_for_knowledge=None):
"""Resolve every role this module reads, once.
`overrides` -- {role: name or [names]}, for a caller that knows the
template but whose knowledge file has not been taught these roles yet.
Explicit and visible beats a hardcoded office name in the repo.
"""
from vibe import knowledge as K
from vibe.compat import string_types
overrides = overrides or {}
# `know=None` used to mean "nothing is mapped", which made a caller who
# simply forgot the argument indistinguishable from an unconfigured
# plant: every role resolved empty and the whole reconciliation refused.
# It refused LOUDLY, which is the only reason this was survivable. Every
# other module in vibe loads knowledge itself when it is handed none, and
# so does this one now. A genuine refusal still happens -- it just has to
# be because the file really does not map the role.
if know is None and _doc_for_knowledge is not None:
try:
know = K.load(_doc_for_knowledge)
except Exception:
know = None
out = {}
for role in ('member.control_number', 'member.mark', 'member.ticket_created',
'member.ticket_detailed', 'panel.design_number',
'sheet.design_number', 'sheet.control_numbers',
'sheet.total_required', 'sheet.sheet_count'):
if role in overrides:
value = overrides[role]
out[role] = ([value] if isinstance(value, string_types)
else list(value))
elif know is not None:
out[role] = K.param_names(know, role)
else:
out[role] = []
slot_re = overrides.get('control_slot_re')
if slot_re is None and know is not None:
slot_re = K.get(know, 'nomenclature.control_slot_re', None)
out['control_slot_re'] = slot_re
return out
def gather(doc, know=None, overrides=None, categories=None):
"""Read the document. Returns (pieces, sheets, notes, timing).
`notes` collects everything the gather itself could not do -- an unmapped
role, a category that could not be collected. It becomes part of the
reconciliation's blind spots; it is never swallowed.
"""
roles = _roles(know, overrides, _doc_for_knowledge=doc)
notes = []
t0 = time.time()
ctrl_names = roles['member.control_number']
if not ctrl_names:
notes.append('the role member.control_number is not mapped -- no '
'piece could be identified, so NOTHING was reconciled. '
'Map it in the plant knowledge file.')
slot_names = roles['sheet.control_numbers']
slot_re = roles['control_slot_re']
if not slot_names and not slot_re:
notes.append('neither the role sheet.control_numbers nor '
'nomenclature.control_slot_re is set -- no ticket could '
'be read, so every piece will read as unticketed. This '
'is a refusal, not a finding.')
# ---- which elements are already inside an assembly ------------------
in_assembly = {}
for asm in (FilteredElementCollector(doc).OfClass(AssemblyInstance)
.WhereElementIsNotElementType().ToElements()):
name = asm.AssemblyTypeName
for mid in asm.GetMemberIds():
member = doc.GetElement(mid)
if member is not None:
in_assembly[_eid(member)] = name
# ---- the pieces -----------------------------------------------------
pieces = []
per_category = {}
for cat_name in (categories or PIECE_CATEGORIES):
bic = getattr(BuiltInCategory, cat_name, None)
if bic is None:
notes.append('this Revit build has no category {0}; it was not '
'searched'.format(cat_name))
continue
try:
elements = (FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception as exc:
notes.append('could not collect {0}: {1}'.format(cat_name, exc))
continue
found = 0
for el in elements:
control = _read(el, ctrl_names)
if control in (None, ''):
continue
found += 1
family = None
try:
family = el.Symbol.Family.Name
except Exception:
try:
family = el.Name
except Exception:
family = None
pieces.append({
'id': _eid(el),
'category': cat_name,
'control': control,
'mark': _read(el, roles['member.mark']),
'design': _read(el, roles['panel.design_number']),
'family': family,
'in_assembly': in_assembly.get(_eid(el)),
'ticket_created': _read(el, roles['member.ticket_created']),
'ticket_detailed': _read(el, roles['member.ticket_detailed']),
})
per_category[cat_name] = {'elements': len(elements), 'pieces': found}
t1 = time.time()
# ---- the sheets -----------------------------------------------------
#
# One pass over each sheet's own Parameters, not thirty LookupParameter
# calls per sheet: with numbered slots that is the difference between
# about a second and about ten across a 1105-sheet job, and the slots are
# discovered rather than assumed to be contiguous.
slot_pattern = re.compile(slot_re, re.IGNORECASE) if slot_re else None
sheets = []
for sheet in FilteredElementCollector(doc).OfClass(ViewSheet).ToElements():
slots = {}
if slot_pattern is not None:
for param in sheet.Parameters:
try:
name = param.Definition.Name
except Exception:
continue
if slot_pattern.match(name):
slots[name] = _param_text(param)
controls = [slots[k] for k in sorted(slots)]
for name in slot_names:
controls.append(_param_text(sheet.LookupParameter(name)))
sheets.append({
'id': _eid(sheet),
'number': sheet.SheetNumber,
'name': sheet.Name,
'design': _read(sheet, roles['sheet.design_number']),
'controls': controls,
'total_required': _read(sheet, roles['sheet.total_required']),
'sheet_count': _read(sheet, roles['sheet.sheet_count']),
})
t2 = time.time()
searched = set(categories or PIECE_CATEGORIES)
notes.append(INVENTORY_LEAD + ' {0}. A precast piece modelled in any '
'other category was not seen by this run.'
.format(', '.join(sorted(searched))))
for cat_name in sorted(per_category):
row = per_category[cat_name]
notes.append(INVENTORY_INDENT +
'{0}: {1} element(s), {2} carrying a control number'
.format(cat_name, row['elements'], row['pieces']))
timing = {'pieces_s': round(t1 - t0, 2), 'sheets_s': round(t2 - t1, 2),
'total_s': round(t2 - t0, 2)}
return pieces, sheets, notes, timing
def _numeric_sort(value):
"""Sort control numbers the way a person reads them: 9 before 10."""
text = str(value)
parts = re.split(r'(\d+)', text)
key = []
for part in parts:
if part.isdigit():
key.append((1, int(part), ''))
elif part:
key.append((0, 0, part))
return key
def run(doc, know=None, overrides=None, categories=None, scope=None):
"""Gather and reconcile. Returns the jobrecon result, plus timings.
The DECLARED SCOPE is read from the job knowledge here and handed to
`reconcile`, which sets aside what is outside it and reports that. The
gather itself is deliberately unscoped: it reads the whole job, so the
count of what was left alone is a measured number rather than an
estimate of what was never looked at.
"""
pieces, sheets, notes, timing = gather(doc, know=know,
overrides=overrides,
categories=categories)
if scope is None:
from vibe import scope as scopelib
if know is None:
from vibe import knowledge as K
know = K.load(doc)
scope = scopelib.of(know)
recon = jobrecon.reconcile(pieces, sheets, sort_key=_numeric_sort,
scope=scope)
# Split, not merged: what the sweep opened folds away as routine, what it
# could not see opens itself. Both are printed either way.
recon['blind_spots'] = ([n for n in notes if not is_inventory_note(n)]
+ recon['blind_spots'])
recon['searched'] = [n for n in notes if is_inventory_note(n)]
recon['timing'] = timing
return recon
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
"""The Revit half of lateral extraction. READ-ONLY. No Transaction, ever.
This is the `ticketchecks.py` of the pair: it imports the Revit API at module
scope and therefore cannot be unit tested, which is exactly why it does as
little thinking as possible. It reads parameters and geometry, hands plain
dicts to `vibe.latmodel`, and makes no judgement of its own beyond "the
model did not tell me".
WHAT WAS MEASURED IN THE LIVE MODEL, 2026-08-30
-----------------------------------------------
Two findings shaped this file, and both are the reason it refuses things
rather than inventing them.
1. ZERO elements carry the lateral marker today. Every string parameter of
every structural framing, wall, foundation and floor element, plus 1200
generic models, was read: nothing anywhere contains 'lateral' or
'resist'. The marker exists as a convention and has not been applied yet.
So the first honest run of this tool reports NOTHING MARKED, and that
must not look like an answer.
PARAMETER NAMES ARE NOT IN THIS FILE
------------------------------------
Not one Revit parameter is named here by its literal name. Every one is
reached through a ROLE resolved against the plant's knowledge file --
`member.identity_description` for the field the marker is typed into,
`member.mark` for the piece mark. That is not tidiness: a parameter name IS
this office's template, and a name written into shareable code means the
next plant cannot be served without editing code.
An unmapped role therefore makes this stand DOWN, with a sentence naming
the role. It never falls back to a name that happens to work here -- a
fallback would work on this model and silently find nothing on the next
one, which is precisely the failure this codebase is built against. The
only exception is Revit's own built-in Mark, used purely to label a row in
the report; it is a Revit concept, not a plant's, and nothing depends on it.
2. Every structural framing element in that model has a LocationPoint, not
a LocationCurve. They are point-placed precast families. A member axis
therefore cannot be read the usual way, and the bounding box is not a
substitute: a column whose COLUMN_LENGTH parameter reads 90'-5" has a
bounding box 140 ft tall, because the family carries geometry beyond the
member. Deriving an axis from that box would produce coordinates that
look completely reasonable and are wrong by 50 feet.
So point-placed elements are SKIPPED with that reason, unless a plant
explicitly opts in via `lateral.point_member_axis`. The default is None
-- refuse -- for the same reason `rails.left_rail_is_fixed` defaults to
None: a wrong default here is invisible.
IronPython 2.7.
"""
# pylint: disable=import-error,invalid-name,broad-except
from Autodesk.Revit.DB import (FilteredElementCollector, BuiltInCategory,
BuiltInParameter, ElementId, StorageType,
LocationCurve, LocationPoint, Line)
from vibe import knowledge as K
from vibe import latmodel
# The role the marker field is reached through. The NAME behind it lives in
# the plant's knowledge file and deliberately not here.
DESC_ROLE = 'member.identity_description'
MARK_ROLE = 'member.mark'
# Scanned when the user has selected nothing. Never unscoped -- an
# unfiltered collector took Revit down on 2026-08-18.
DEFAULT_CATEGORIES = [
'OST_StructuralFraming', 'OST_StructuralColumns', 'OST_Walls',
'OST_StructuralFoundation', 'OST_GenericModel',
]
def _eid(el):
try:
return el.Id.IntegerValue
except Exception:
return el.Id.Value
def _sparam(el, name):
try:
pr = el.LookupParameter(name)
if pr is None or pr.StorageType != StorageType.String:
return None, False
v = pr.AsString()
return (v.strip() if v else None), True
except Exception:
return None, False
def read_description(el, param_names):
"""(text, source_param, any_such_param_exists).
`param_names` comes from the `member.identity_description` role. This
function never invents a name of its own.
The third value is the one that matters. If NO mapped parameter exists
on any element, the marker cannot have been written anywhere, and that
is 'could not judge' -- not 'nothing is marked'.
"""
exists = False
for name in param_names:
v, present = _sparam(el, name)
exists = exists or present
if v:
return v, name, True
return None, None, exists
def _mark(el, know):
"""The piece mark, for labelling report rows only.
The plant's own mark parameter first, through its role. Revit's built-in
Mark second -- that is a Revit concept rather than an office one, it is
the sanctioned general fallback (see knowledge.OPTIONAL_ROLES), and
nothing but a table cell depends on it.
"""
for name in K.param_names(know, MARK_ROLE):
v, _ = _sparam(el, name)
if v:
return v
try:
pr = el.get_Parameter(BuiltInParameter.ALL_MODEL_MARK)
if pr is not None:
v = pr.AsString()
if v and v.strip():
return v.strip()
except Exception:
pass
return None
def _category(el):
try:
return el.Category.Name if el.Category else None
except Exception:
return None
def _type_name(el):
"""The Revit type name -- a section LABEL, never section properties.
`.Name` on a Revit type is ambiguous under IronPython on some bindings
(it came back empty for every element in the 2026-08-30 measurement
pass), so the built-in type parameter is tried first and reflection
second. Both are Revit's own, not this office's.
"""
try:
pr = el.get_Parameter(BuiltInParameter.ELEM_TYPE_PARAM)
if pr is not None:
v = pr.AsValueString()
if v and v.strip():
return v.strip()
except Exception:
pass
for getter in (lambda: el.Symbol.Name,
lambda: el.WallType.Name,
lambda: el.Name):
try:
v = getter()
if v and v.strip():
return v.strip()
except Exception:
continue
return None
def _material(el):
try:
pr = el.get_Parameter(BuiltInParameter.STRUCTURAL_MATERIAL_PARAM)
if pr is None:
return None
mid = pr.AsElementId()
if mid is None or mid == ElementId.InvalidElementId:
return None
me = el.Document.GetElement(mid)
if me is None:
return None
return me.Name
except Exception:
return None
def _pt(xyz):
return (xyz.X, xyz.Y, xyz.Z)
def axis_of(el, know):
"""((i, j), source, None) or (None, None, why_not). Feet. Read-only.
Feet because that is Revit's internal unit and nothing here converts.
The tuple carries no unit of its own; `latmodel.LENGTH_UNIT` is the one
place the unit is stated, and it is stated once.
"""
try:
loc = el.Location
except Exception:
loc = None
if isinstance(loc, LocationCurve):
try:
crv = loc.Curve
except Exception:
return None, None, 'the location curve could not be read'
if not isinstance(crv, Line):
return (None, None,
'the location curve is a {0}, not a straight line. A '
'curved member is not a single analysis member and is '
'not split into one here.'.format(type(crv).__name__))
try:
return ((_pt(crv.GetEndPoint(0)), _pt(crv.GetEndPoint(1))),
'location_curve', None)
except Exception as ex:
return None, None, 'end points unreadable: {0}'.format(ex)
if isinstance(loc, LocationPoint):
mode = K.get(know, 'lateral.point_member_axis', None)
if not mode:
return (None, None,
'placed as a point-based family, so it has no location '
'curve and no member axis. Its bounding box is not used: '
'measured on this model, a 90 ft column has a 140 ft '
'bounding box because the family carries geometry beyond '
'the member, so an axis taken from it would be wrong by '
'tens of feet while looking entirely plausible. Set '
'lateral.point_member_axis in knowledge.yaml to opt in.')
if mode != 'bounding_box_longest':
return (None, None,
'lateral.point_member_axis is set to "{0}", which this '
'version does not implement.'.format(mode))
try:
bb = el.get_BoundingBox(None)
except Exception:
bb = None
if bb is None:
return None, None, 'point-based, and it has no bounding box either'
lo, hi = _pt(bb.Min), _pt(bb.Max)
spans = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]
k = spans.index(max(spans))
mid = [(lo[n] + hi[n]) / 2.0 for n in range(3)]
a, b = list(mid), list(mid)
a[k], b[k] = lo[k], hi[k]
return (tuple(a), tuple(b)), 'bounding_box', None
return (None, None,
'no Location of a kind that carries an axis (got {0})'
.format(type(loc).__name__ if loc is not None else 'None'))
def _wall_skip_reason(el):
"""Why a marked wall is not turned into a line member.
A shear wall is a panel. RISA models it as a wall panel or a mesh of
plates, and this tool emits no plates at all -- so turning the wall's
base curve into a horizontal line member would put a beam where the
engineer put a wall. The geometry is recorded in the skip row so that
nothing is lost, but no member is invented.
"""
bits = []
try:
loc = el.Location
if isinstance(loc, LocationCurve):
c = loc.Curve
a, b = _pt(c.GetEndPoint(0)), _pt(c.GetEndPoint(1))
bits.append('base line ({0:.3f},{1:.3f},{2:.3f}) to '
'({3:.3f},{4:.3f},{5:.3f}) ft'
.format(a[0], a[1], a[2], b[0], b[1], b[2]))
except Exception:
pass
try:
pr = el.get_Parameter(BuiltInParameter.WALL_USER_HEIGHT_PARAM)
if pr is not None:
bits.append('height {0:.3f} ft'.format(pr.AsDouble()))
except Exception:
pass
geom = '; '.join(bits) or 'geometry not readable'
return ('a wall is a shear PANEL, not a line member. This tool emits no '
'plates or wall panels, and will not put a beam where the '
'engineer put a wall. Recorded geometry: ' + geom)
def candidates(doc, selection=None, know=None):
"""The elements to inspect. Always scoped -- never a bare collector."""
if selection:
return list(selection)
cats = K.get(know, 'lateral.categories', None) or DEFAULT_CATEGORIES
out = []
for cn in cats:
bic = getattr(BuiltInCategory, cn, None)
if bic is None:
continue
try:
out.extend(FilteredElementCollector(doc).OfCategory(bic)
.WhereElementIsNotElementType().ToElements())
except Exception:
continue
return out
NOT_RUN_UNMAPPED = 'role_unmapped'
NOT_RUN_ABSENT = 'parameter_absent'
def scan(doc, selection=None, know=None):
"""Inspect candidates for the marker. Returns a dict, or (None, reason).
A tuple `(None, reason)` means COULD NOT JUDGE, and there are exactly
two ways to get there:
NOT_RUN_UNMAPPED the `member.identity_description` role has no
parameter name behind it, so this does not know
where the marker would be written.
NOT_RUN_ABSENT the role is mapped, but no candidate element
actually carries that parameter.
In both, the absence of marked members says nothing at all. A returned
dict with empty `marked` means the opposite: the question was asked
properly and the answer is that nothing is marked. The button reports
the two differently, which is the whole reason this is not just an
empty result.
"""
know = know or {}
params = K.param_names(know, DESC_ROLE)
if not params:
return None, NOT_RUN_UNMAPPED
tokens = K.get(know, 'lateral.marker_tokens', None) \
or latmodel.DEFAULT_MARKER_TOKENS
els = candidates(doc, selection=selection, know=know)
any_field = False
marked, ambiguous, unmarked = [], [], 0
for el in els:
text, source, present = read_description(el, params)
any_field = any_field or present
verdict, token = latmodel.match_marker(text, tokens)
if verdict == latmodel.MATCH_NO:
unmarked += 1
continue
row = {'element': el, 'element_id': _eid(el), 'desc': text,
'desc_param': source, 'token': token,
'category': _category(el), 'mark': _mark(el, know)}
(marked if verdict == latmodel.MATCH_YES else ambiguous).append(row)
if not any_field:
return None, NOT_RUN_ABSENT
return {'params_tried': params, 'tokens': tokens,
'candidates': len(els), 'marked': marked,
'ambiguous': ambiguous, 'unmarked': unmarked}, None
def gather(doc, selection=None, know=None):
"""(model, scan, None) -- or (None, None, reason) when it could not judge.
Read-only from start to finish. No Transaction is opened anywhere in
this module and none should ever be: the production model this runs on
is somebody's live work.
"""
found, why = scan(doc, selection=selection, know=know)
if found is None:
return None, None, why
raws = []
for row in found['marked']:
el = row['element']
cat = row['category'] or ''
if 'Wall' in cat:
raws.append({'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'],
'token': row['token'], 'i': None, 'j': None,
'axis_source': None, 'section': None,
'material': _material(el),
'note': _wall_skip_reason(el)})
continue
ends, source, why = axis_of(el, know)
tname = _type_name(el)
raws.append({
'element_id': row['element_id'], 'mark': row['mark'],
'category': cat, 'desc': row['desc'], 'token': row['token'],
'i': ends[0] if ends else None,
'j': ends[1] if ends else None,
'axis_source': source,
# A type NAME, flagged unverified. See latmodel.build for why no
# dimension from GetStructuralSection() is trusted on this model.
'section': ({'name': tname, 'source': 'revit_type_name',
'verified': False} if tname else None),
'material': _material(el),
'note': why,
})
tol = K.get(know, 'lateral.node_tolerance_ft', None)
model = latmodel.build(raws, node_tol_ft=tol, model_title=doc.Title)
# latmodel.build only knows "no axis". The REASON lives out here, where
# the Revit object was, so put it back on the skip row -- a skip without
# a reason is the thing this codebase keeps refusing to ship.
by_id = {}
for r in raws:
if r.get('note'):
by_id[r['element_id']] = r['note']
for s in model['skipped']:
reason = by_id.get(s.get('element_id'))
if reason:
s['reason'] = reason
return model, found, None
+414
View File
@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""The Revit-free half of lateral extraction: marker, model, node graph.
This file is the `ticketpage.py` of the lateral tool -- no Revit import at
module scope, no I/O, no `doc`. `vibe.latextract` is the `ticketchecks.py`
side: it walks the model and hands raw dicts in here. `vibe.latrisa` turns
what comes out into files.
WHY THE SHAPE IS THIS CAREFUL
-----------------------------
Every other tool in this repository produces something a human reads and
judges. This one produces an input file for structural analysis software.
The failure mode is therefore new: a file that OPENS and is subtly wrong is
much worse than no file at all, because the wrongness arrives wearing the
authority of a finished model.
So the rule throughout: **nothing this cannot determine is ever defaulted.**
A member whose section could not be read does not get a section -- it gets
`section = None`, its label goes in `member['unresolved']`, and the report
names it. The RISA draft writes an obviously-bogus shape label for it so
that RISA refuses the member rather than analysing a guess.
And the standing distinction this codebase already draws (see the NOT RUN
discussion in `ticketchecks.py`): "could not judge" is not "judged and found
nothing". `collect()` returns `None` for a run that could not happen at all
-- no marker configured, nothing selectable -- and an empty model for a run
that happened and found nothing marked. The button reports those two
differently, on purpose.
IronPython 2.7: no f-strings, no dataclasses, no typing syntax.
"""
# pylint: disable=invalid-name,broad-except
import math
import re
# --------------------------------------------------------------- the marker
#
# The user marks lateral-resisting members BY HAND, by typing into a
# free-text identity field. The owner's own spelling is "lateral-resistent";
# the conventional English is "lateral-resistant". Both are accepted, because
# the field is typed by a person and a tool that rejects the spelling its own
# owner uses is a tool nobody marks anything for.
#
# These are the DEFAULTS. The live values come from the plant layer under
# `lateral.marker_tokens` (declared in knowledge.DEFAULTS) so a plant that
# writes something else -- "LFRS", "seismic" -- changes a YAML line rather
# than this file.
DEFAULT_MARKER_TOKENS = ['lateral-resistent', 'lateral-resistant']
# Words that, immediately in front of the marker, invert it. "NOT
# LATERAL-RESISTENT" is a phrase a detailer plausibly types, and a plain
# substring match would read it as a mark. We neither include it nor drop it
# silently: it comes back as AMBIGUOUS and the report names the element and
# quotes the text, for a human to settle. Guessing either way here is the
# kind of quiet error this whole file exists to avoid.
NEGATORS = ('not', 'non', 'no', 'never', 'excl', 'exclude', 'excluded')
MATCH_NO = 'no'
MATCH_YES = 'yes'
MATCH_AMBIGUOUS = 'ambiguous'
_NONWORD = re.compile(r'[^a-z0-9]+')
def normalise(text):
"""Lowercase; every run of non-alphanumerics becomes one space.
This is what makes the match tolerant of how the token was typed:
'Lateral-Resistent', 'LATERAL RESISTANT', 'lateral_resistent' and
'lateral resistent' all normalise to the same thing.
It deliberately does NOT remove the separator entirely, so the run-together
'lateralresistent' does not match. That is a judgement call: accepting it
would mean 'lateral' followed by anything starting with 'resistent' also
matches, and the tool cannot tell a typo from a different phrase. An
unmatched element is visible in the report as "not marked"; a wrongly
matched one is invisible.
"""
if text is None:
return ''
try:
low = text.lower()
except Exception:
low = str(text).lower()
return _NONWORD.sub(' ', low).strip()
def match_marker(text, tokens=None):
"""(verdict, matched_token_or_None) for one free-text field.
verdict is MATCH_YES / MATCH_NO / MATCH_AMBIGUOUS.
ACCEPTS (verdict YES):
'lateral-resistent' exactly, any case
'lateral-resistant' the conventional spelling
'LATERAL RESISTENT' any separator run: - _ space . /
'shear wall, lateral-resistent' surrounded by other text
'lateral-resistent (see S2.1)' trailing text
REJECTS (verdict NO):
'lateral' on its own -- half a token
'resistant' on its own
'lateralresistent' no separator at all (see normalise)
'laterally resistant' 'laterally' is not 'lateral'
'' / None an empty field is not a mark
FLAGS (verdict AMBIGUOUS -- never counted either way, always reported):
'NOT lateral-resistent'
'non lateral resistant'
"""
hay = normalise(text)
if not hay:
return MATCH_NO, None
padded = ' ' + hay + ' '
for raw in (tokens or DEFAULT_MARKER_TOKENS):
needle = normalise(raw)
if not needle:
continue
# Whole-token match: the marker must sit on word boundaries, so
# 'laterally resistant' does not match 'lateral resistant'.
at = padded.find(' ' + needle + ' ')
if at < 0:
continue
before = padded[:at].strip().split(' ')
if before and before[-1] in NEGATORS:
return MATCH_AMBIGUOUS, raw
return MATCH_YES, raw
return MATCH_NO, None
# ------------------------------------------------------------------- units
#
# Revit's internal length unit is decimal FEET. Always. Handing inches to
# XYZ() is a 12x error that has already cost this project real time, so the
# unit travels WITH the number everywhere in this structure rather than
# being remembered. Nothing in this file converts anything: feet in, feet
# out. RISA's own default length unit is feet too, which is why the emitter
# has no conversion either -- but it states the unit in the file instead of
# relying on that.
LENGTH_UNIT = 'ft'
# How close two member endpoints must be to count as the same node.
# 1/8 inch. Not a modelling tolerance -- a "did the detailer snap these
# together" tolerance. Anything looser starts welding members that a
# structural engineer means to be separate.
DEFAULT_NODE_TOL_FT = 0.0104167 # 1/8"
# ----------------------------------------------------------- raw member in
#
# What `latextract` hands over per element. Every field may be None, and
# None means "the model did not tell us", never "zero" or "default":
#
# element_id int
# category 'OST_Walls'
# mark piece mark / control mark, or None
# desc the identity-description text the marker was found in
# token which marker spelling matched
# i, j (x, y, z) tuples in FEET, or None if no axis could be found
# axis_source 'location_curve' | 'bounding_box' | None
# section {'name':..,'source':..} or None
# material str or None
# note free text from the gatherer
def _key(pt, tol):
"""Grid key for endpoint welding, at the tolerance."""
return (int(round(pt[0] / tol)), int(round(pt[1] / tol)),
int(round(pt[2] / tol)))
def _dist(a, b):
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2
+ (a[2] - b[2]) ** 2)
def build(raw_members, node_tol_ft=None, model_title=None):
"""Raw gathered members -> the intermediate structure. Pure.
Returns a dict. Never raises on a bad member: a member that cannot be
turned into two nodes is moved into `skipped` with a reason, because a
silently dropped member is exactly the failure this tool must not have.
"""
tol = node_tol_ft or DEFAULT_NODE_TOL_FT
nodes = []
index = {}
members = []
skipped = []
def node_for(pt):
"""Weld to an existing node within tol, else make a new one.
Snapping is to a GRID at the tolerance, then confirmed by true
distance against the candidates in the 27 neighbouring cells -- a
plain grid alone would fail to weld two points a hair either side of
a cell boundary.
"""
k = _key(pt, tol)
best = None
best_d = None
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for dz in (-1, 0, 1):
for idx in index.get((k[0] + dx, k[1] + dy, k[2] + dz), ()):
d = _dist(pt, (nodes[idx]['x'], nodes[idx]['y'],
nodes[idx]['z']))
if d <= tol and (best_d is None or d < best_d):
best, best_d = idx, d
if best is not None:
return best
idx = len(nodes)
nodes.append({'label': 'N{0}'.format(idx + 1),
'x': pt[0], 'y': pt[1], 'z': pt[2],
'members': []})
index.setdefault(k, []).append(idx)
return idx
for n, raw in enumerate(raw_members):
i_pt, j_pt = raw.get('i'), raw.get('j')
label = 'M{0}'.format(n + 1)
if not i_pt or not j_pt:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'no member axis could be established '
'(no location curve, and no usable bounding box)'})
continue
if _dist(i_pt, j_pt) <= tol:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'the two ends are the same point to within the node '
'tolerance -- a zero-length member is not analysable'})
continue
ii, jj = node_for(i_pt), node_for(j_pt)
if ii == jj:
skipped.append({
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'reason': 'both ends welded to the same node at the '
'{0} ft tolerance'.format(tol)})
continue
unresolved = []
sec = raw.get('section')
if not sec:
unresolved.append('section')
elif not sec.get('verified'):
# A Revit type NAME is not a section property. It is the thing an
# engineer maps to a RISA shape by hand, and it is worth carrying
# -- but calling it a section would be the exact silent default
# this file exists to refuse. Measured on this model 2026-08-30:
# a type named 28" x 48" reports a parameterised rectangle 28"
# wide and 20 FEET deep. The API's own section object is wrong
# here, so no dimension read from it is trusted.
unresolved.append('verified section properties '
'(only a type name was read)')
if not raw.get('material'):
unresolved.append('material')
# End releases are NEVER extracted -- see `notes` below. Listing it
# per member keeps it in front of whoever reads one member's row,
# not only in the preamble.
unresolved.append('end releases')
mem = {
'label': label,
'element_id': raw.get('element_id'),
'mark': raw.get('mark'),
'category': raw.get('category'),
'desc': raw.get('desc'),
'token': raw.get('token'),
'i_node': nodes[ii]['label'],
'j_node': nodes[jj]['label'],
'length_ft': _dist(i_pt, j_pt),
'axis_source': raw.get('axis_source'),
'section': raw.get('section'),
'material': raw.get('material'),
'releases': None,
'unresolved': unresolved,
'note': raw.get('note'),
}
members.append(mem)
nodes[ii]['members'].append(label)
nodes[jj]['members'].append(label)
model = {
'schema': 'willtools.lateral/1',
'model_title': model_title,
'length_unit': LENGTH_UNIT,
'coordinate_note': 'Revit project internal coordinates, decimal feet. '
'Not shared coordinates, not a survey datum.',
'node_tol_ft': tol,
'nodes': nodes,
'members': members,
'skipped': skipped,
}
model['connectivity'] = connectivity(model)
model['notes'] = notes(model)
return model
def connectivity(model):
"""What the node graph does and does not say. Facts, not a verdict.
Two things a structural model needs that a Revit precast model does not
hand over, both reported here rather than fixed:
free_ends a node only one member reaches. Fine at a support, a
disconnected member anywhere else -- and this cannot
tell which, because supports are not extracted.
near_misses two endpoints close enough to be suspicious but further
apart than the weld tolerance. Almost always a detailer
gap the analysis model should close; NEVER closed here.
"""
free = [n['label'] for n in model['nodes'] if len(n['members']) == 1]
tol = model['node_tol_ft']
# "Suspicious" = up to 6 inches. Wide enough to catch a real gap, narrow
# enough that it is not just listing the whole model.
near = []
ns = model['nodes']
# The near-miss search is O(n^2). Above this it would freeze Revit's UI
# thread, which is a hard rule here. Skipping it is reported as skipped
# (near_miss_checked False) rather than as "no near misses found" --
# the same could-not-judge / judged-and-found-nothing distinction the
# rest of this file keeps.
checked = len(ns) <= 1200
if not checked:
return {'node_count': len(ns), 'member_count': len(model['members']),
'free_ends': free, 'near_misses': [], 'near_miss_total': None,
'near_miss_checked': False,
'interior_intersections_checked': False}
for a in range(len(ns)):
for b in range(a + 1, len(ns)):
d = _dist((ns[a]['x'], ns[a]['y'], ns[a]['z']),
(ns[b]['x'], ns[b]['y'], ns[b]['z']))
if tol < d <= 0.5:
near.append({'a': ns[a]['label'], 'b': ns[b]['label'],
'gap_ft': d})
near.sort(key=lambda r: r['gap_ft'])
return {
'node_count': len(ns),
'member_count': len(model['members']),
'free_ends': free,
'near_misses': near[:200],
'near_miss_total': len(near),
'near_miss_checked': True,
# A member end landing part-way along another member is NOT a
# connection here and is not searched for. Saying so is the point.
'interior_intersections_checked': False,
}
def notes(model):
"""The standing caveats, as sentences, carried inside the structure.
They travel with the data rather than living only in the button's report,
so that the JSON handed to somebody three months from now still says what
it does not contain.
"""
out = [
'Coordinates are Revit internal decimal FEET, in project coordinates.',
'END RELEASES ARE NOT EXTRACTED. Revit carries no reliable release '
'information for these precast families, so every member here is '
'released nowhere. Loaded as-is, an analysis program will treat every '
'joint as fully fixed, which for a precast lateral system is very '
'likely wrong. Set releases by hand.',
'SUPPORTS ARE NOT EXTRACTED. No boundary conditions are emitted at '
'all. The model as written is unrestrained and will not solve until '
'supports are added.',
'LOADS ARE NOT EXTRACTED. No load cases, combinations, diaphragms, '
'masses or seismic parameters are present.',
'Members are connected only where their END POINTS coincide within '
'{0} ft. A member ending part-way along another member is NOT '
'connected to it, and no node is inserted to make it so.'
.format(model['node_tol_ft']),
]
unres_sec = [m['label'] for m in model['members']
if any(u.startswith('section') or u.startswith('verified section')
for u in m['unresolved'])]
if unres_sec:
out.append('{0} member(s) have no verified section property. They are '
'not given one. See the unresolved list.'.format(len(unres_sec)))
unres_mat = [m['label'] for m in model['members'] if 'material' in m['unresolved']]
if unres_mat:
out.append('{0} member(s) have no material. They are not given one.'
.format(len(unres_mat)))
return out
def unresolved_rows(model):
"""One row per member per missing thing -- element by element, not counts."""
rows = []
for m in model['members']:
for what in m['unresolved']:
rows.append({'label': m['label'], 'element_id': m['element_id'],
'mark': m['mark'], 'missing': what})
return rows
def is_analysable(model):
"""False whenever loading this straight into analysis software would lie.
Never used to suppress the export -- the export is always written and
always labelled. Used to decide how loudly the report shouts.
"""
if not model or not model['members']:
return False
if any(m['unresolved'] for m in model['members']):
return False
if model['connectivity']['free_ends']:
return False
return True
+232
View File
@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""Intermediate lateral model -> files. Revit-free, no I/O at module scope.
THREE OUTPUTS, AND WHY THE RISA ONE IS A DRAFT
----------------------------------------------
`emit_json` the intermediate structure verbatim. This is the ground
truth and the only output that claims to be complete.
`emit_csv` nodes and members as two plain tables. Any engineer can open
these, and any script can read them. No format risk at all.
`emit_risa_draft`
RISA-3D .r3d text. **DRAFT. NOT VERIFIED.**
What was established about the RISA-3D format, and from where:
RISA's own Appendix D - File Format
https://help.risa.com/risahelp/risa3d/Content/Appendices/Appendix%20D%20(3D).htm
* .r3d is plain ASCII, keyword driven, editable in Notepad.
* Data is divided into segments delimited by bracketed keyword labels:
[NODES], [MEMBERS], [PLATES], [BOUNDARY_CONDITIONS], [BASIC_LOAD_CASES],
[UNITS], [VERSION_NO], [PROGRAM_INFO], and the file opens with
[RISA-3D_INPUT_DATA].
* The record count follows the keyword label in angle brackets: `<n>`.
* Every record ends with a semicolon, except the project-description
records between [.PROJECT_DESCRIPTION] and [.END_PROJECT_DESCRIPTION].
* Labels are quoted strings. A member label may be up to 20 characters.
* A node record is "LABEL" x y z ... ;
* A member record is "LABEL" "DESIGN_LIST" "SHAPE_LABEL" iNode jNode ... ;
with later fields covering K node, rotation, section-set offset,
tension/compression, design rules, material, end releases, end offsets.
What was NOT established, and this is the reason for the word DRAFT:
The documentation designates the fields of each record by letter (a, b, c,
...) and the table that maps those letters to meanings could not be
retrieved -- the mirror that carries it returns 403, and no complete
worked example of a [NODES] or [MEMBERS] record was found in public
sources. So the field COUNT, the field ORDER, and the encoding of the
[UNITS] line are not known with confidence. Nor is which RISA version's
schema a given install expects.
Guessing them would produce a file that very plausibly opens and is
quietly wrong about which column is which -- the single worst outcome
available to this tool. So the RISA output is written with a
`.r3d.DRAFT.txt` extension. That is deliberate and is not a naming
accident: it cannot be double-clicked into RISA, and anybody who wants to
try it has to rename it, which is the moment they read the header.
To finish this properly: open any existing .r3d from this office in
Notepad, paste its [UNITS], [NODES] and [MEMBERS] segments into the repo
issue, and the emitter becomes a real one in about an hour. Until then the
CSV and the JSON are the outputs to use.
IronPython 2.7: no f-strings.
"""
# pylint: disable=invalid-name,broad-except
import json
from vibe import latmodel
# A shape label RISA cannot possibly resolve. Used wherever a section is
# unknown. The point is that RISA fails the member rather than analysing it
# with whatever the current default shape happens to be -- an unanalysable
# file beats a wrong answer.
UNRESOLVED_SHAPE = 'WILLTOOLS_UNRESOLVED_SECTION'
UNRESOLVED_MATERIAL = 'WILLTOOLS_UNRESOLVED_MATERIAL'
def emit_json(model):
"""The intermediate structure. Complete, and the only output that is."""
return json.dumps(model, indent=2, sort_keys=True)
def _csv_cell(v):
if v is None:
return ''
s = v if isinstance(v, str) else str(v)
if any(c in s for c in ',"\n'):
return '"' + s.replace('"', '""') + '"'
return s
def _csv(header, rows):
lines = [','.join(header)]
for r in rows:
lines.append(','.join([_csv_cell(c) for c in r]))
return '\n'.join(lines) + '\n'
def emit_nodes_csv(model):
"""label, x, y, z, unit, how many members meet here."""
rows = [[n['label'], '{0:.6f}'.format(n['x']), '{0:.6f}'.format(n['y']),
'{0:.6f}'.format(n['z']), model['length_unit'],
len(n['members']), ' '.join(n['members'])]
for n in model['nodes']]
return _csv(['label', 'x', 'y', 'z', 'unit', 'member_count', 'members'],
rows)
def emit_members_csv(model):
"""One row per member, with the blanks LEFT BLANK.
An empty `section` cell is the honest representation of a section this
could not read. It is never filled with a plausible-looking default, and
`unresolved` on the same row says the same thing in words so that a
reader skimming for problems finds them without comparing columns.
"""
rows = []
for m in model['members']:
sec = m.get('section') or {}
rows.append([
m['label'], m['i_node'], m['j_node'],
'{0:.6f}'.format(m['length_ft']), model['length_unit'],
sec.get('name'), sec.get('source'),
m.get('material'),
'', # end releases: never extracted
m.get('axis_source'),
m.get('category'), m.get('mark'), m.get('element_id'),
m.get('desc'), m.get('token'),
'; '.join(m.get('unresolved') or []),
])
return _csv(['label', 'i_node', 'j_node', 'length', 'unit',
'section', 'section_source', 'material', 'end_releases',
'axis_source', 'revit_category', 'mark', 'revit_element_id',
'identity_description', 'marker_matched', 'unresolved'],
rows)
DRAFT_HEADER = """\
! =====================================================================
! DRAFT -- NOT A VERIFIED RISA-3D INPUT FILE. DO NOT LOAD AND TRUST.
! =====================================================================
!
! Written by WillTools lateral extraction from a Revit model.
!
! The section keywords, the <n> record counts, the semicolon terminators
! and the quoted labels below follow RISA's published Appendix D. The
! FIELD ORDER inside each record does NOT: Appendix D names those fields
! only by letter and the table mapping letters to meanings could not be
! obtained. The numbers below are in the right sections and are very
! likely in the wrong columns.
!
! This file is named .r3d.DRAFT.txt so that it cannot be opened into RISA
! by accident. Renaming it to .r3d is a decision, not a convenience.
!
! WHAT IS NOT IN THIS FILE, AT ALL:
! * end releases -- not extractable from these Revit families
! * boundary conditions / supports -- none. This model is unrestrained.
! * loads, load combinations, diaphragms, mass, seismic parameters
! * plates, walls as shells (walls appear as line members only)
! * member offsets, K-node orientation, unbraced lengths, design rules
!
! Use the .json and the two .csv files beside this one. They are complete
! statements of what was extracted; this file is not.
!
! Length unit: {unit} (Revit internal decimal feet, project coordinates)
! Node weld tolerance: {tol} ft
! ---------------------------------------------------------------------
"""
def emit_risa_draft(model):
"""RISA-3D-shaped text, clearly and repeatedly labelled as a draft."""
L = []
L.append(DRAFT_HEADER.format(unit=model['length_unit'],
tol=model['node_tol_ft']))
L.append('[RISA-3D_INPUT_DATA]')
L.append('')
L.append('[.PROJECT_DESCRIPTION]')
L.append('DRAFT lateral extraction -- field order unverified')
L.append('Model: {0}'.format(model.get('model_title') or '(unnamed)'))
L.append('[.END_PROJECT_DESCRIPTION]')
L.append('')
# [UNITS] is deliberately NOT emitted as data. Its field encoding is the
# part of the format least established, and a units line in the wrong
# order is the most dangerous single line the file could contain -- it
# rescales everything silently. A comment says what the numbers are in.
L.append('! [UNITS] omitted on purpose -- the encoding of this line is')
L.append('! not established, and a wrong units line rescales the whole')
L.append('! model without any visible sign. All lengths below are FEET.')
L.append('')
L.append('[NODES] <{0}>'.format(len(model['nodes'])))
for n in model['nodes']:
L.append(' "{0}" {1:.6f} {2:.6f} {3:.6f} ;'
.format(n['label'], n['x'], n['y'], n['z']))
L.append('[END_NODES]')
L.append('')
L.append('[MEMBERS] <{0}>'.format(len(model['members'])))
for m in model['members']:
sec = m.get('section') or {}
shape = sec.get('name') or UNRESOLVED_SHAPE
mat = m.get('material') or UNRESOLVED_MATERIAL
L.append(' "{0}" "" "{1}" "{2}" "{3}" "{4}" ; ! {5}'
.format(m['label'][:20], shape, mat,
m['i_node'], m['j_node'],
'unresolved: ' + ', '.join(m['unresolved'])
if m['unresolved'] else 'complete'))
L.append('[END_MEMBERS]')
L.append('')
L.append('! [BOUNDARY_CONDITIONS] intentionally absent -- no supports')
L.append('! were extracted. Nothing here is restrained.')
L.append('')
L.append('! ---- what was skipped, and why -----------------------------')
if model['skipped']:
for s in model['skipped']:
L.append('! skipped element {0} ({1}, mark {2}): {3}'
.format(s.get('element_id'), s.get('category'),
s.get('mark'), s.get('reason')))
else:
L.append('! nothing was skipped.')
L.append('')
for note in model['notes']:
L.append('! NOTE: ' + note)
return '\n'.join(L) + '\n'
def file_set(model, stem):
"""[(filename, text)] -- everything this tool writes, in one place.
The RISA file is last and its name carries the warning, so that a
directory listing sorted any way still shows the complete outputs first.
"""
return [
(stem + '-lateral.json', emit_json(model)),
(stem + '-lateral-nodes.csv', emit_nodes_csv(model)),
(stem + '-lateral-members.csv', emit_members_csv(model)),
(stem + '-lateral.r3d.DRAFT.txt', emit_risa_draft(model)),
]
File diff suppressed because it is too large Load Diff
+944
View File
@@ -0,0 +1,944 @@
# -*- coding: utf-8 -*-
u"""The Design Criteria section of the Panel Analysis input form.
WHY THIS FILE EXISTS: the owner rejected the old Panel Analysis form three
times running -- "太丑了" (too ugly), "你这么输入用户上哪儿能看懂" (input like
this, where would a user even understand it), "现在这个panel analysis做的也太丑
了" (even now it is still too ugly) -- and then gave the fix in one sentence:
"全部给我做成普通人能看懂的样子", make every part of it something an ordinary
person can understand. The form is being rebuilt to look like Eriksson Wall's
own printed pages, split one Eriksson section per file so eight agents can
build it at once without stepping on each other. This file owns exactly one
of those sections: **Design Criteria**, which on Eriksson's own printout is
four blocks stacked on one page -- Project Information, Material Properties,
Load Combinations, Settings -- not four separate sections. `page()` below
returns all four, in that order, as one flat block list.
`page()` NEVER RAISES. A form button that throws mid-render leaves the owner
looking at a stack trace instead of a form, which is worse than an ugly form
-- so every lookup here is defensive, and anything this module could not lay
out is named in the `problems` list it returns instead.
THE SHAPE OF THE OUTPUT is the block vocabulary the caller's generic WPF
renderer understands (`heading`, `columns`, `field`, `kv`, `sentence`,
`table`, `lc_table`, `rule`, `space`) and nothing else -- an unknown `kind` renders as
nothing, so a typo here is a silently missing block, not an error. There is
no test for "the renderer draws this correctly" (there is no renderer to run
against on host CPython, and Revit is not running for this module to check
against) -- only for "every block this function returns is a well-formed
member of that vocabulary."
WHERE THE FIELD TEXT COMES FROM. This file authors its own `label` / `symbol`
/ `hint` for every field, rather than importing them from
`vibe.panelanalysisform`. Two reasons, both load-bearing:
1. Another agent is restructuring `panelanalysisform.FIELDS` while this file
is being written, so importing text from it ties this module's stability
to a moving target -- exactly what "the block list is stable" (tested
below) is supposed to rule out.
2. Roughly half of what Eriksson's own Design Criteria page prints has NO
key in `panelanalysisform.FIELDS` at all yet (project identity, concrete
elastic modulus, deflection-limit checkboxes, stress-limit checkboxes,
resistance-factor splits, development multipliers by strand type, ...).
Those need a label and a hint regardless of whether the backing field
exists, so authoring locally is the only option that covers both cases the
same way.
`panelanalysisform` IS still consulted, but only for one yes/no fact per
field: does this key already exist in `FIELDS`. That answer decides EXISTS
vs. MISSING for the `problems` list, per the owner's instruction that a field
whose key does not exist yet degrades to an empty editor plus a named
problem, never to a vanished block. The import is wrapped in `try/except
Exception` (not just `ImportError`) because a sibling agent editing that file
right now can leave it syntactically broken for a moment, and this module
must keep rendering through that -- "could not judge whether the key exists"
degrades to "treat it as missing", the same discipline CLAUDE.md's
`same_face()` example applies to an unrecognised abbreviation.
WHY THE 3-COLUMN CONCRETE GRID AND THE REBAR/MESH/STRAND BLOCK ARE BUILT WITH
`columns`, NOT `table`. Eriksson prints both as grids, and the block
vocabulary's `table` kind was the first thing tried -- but a `table` cell is
only ever a plain string or a `{'editor': 'check', ...}` tick box; there is no
way to put a live numeric/text editor inside one. Every value in those two
grids (f′ci, f′c, E′ci, E′c, unit weight, concrete type, fy, Es, fpu, Ep) is
something the user can actually type, so they are laid out as `columns` of
`field` blocks that mirror Eriksson's rows and column groupings instead. The
Load Combinations tables used `table` for exactly that reason, back when the
only editable thing per row was the Include tick. They no longer do: the owner
ruled (2026-09-03) that the ACI factors themselves are editable and that a user
can add a combination of their own, so they emit `lc_table` -- the same grid,
but every factor cell carries a live numeric editor and the block names its
factor columns so the renderer can build an added row. The DEFAULT factors are
public ACI 318 code text (not this job's data) and now live in
`panelanalysisform.LOAD_COMBO_ROWS`, beside the keys they are stored under,
rather than in this file.
UPDATE 2026-09-04, RESISTANCE FACTORS IS THE ONE EXCEPTION TO ALL OF THAT. A
headless render of this page (WPA655) showed every phi editor squeezed to zero
width: that block was a `columns` of two sub-columns ("Developed" |
"Transferring"), each holding `field` blocks, nested inside `col_c` of the
Settings section's own three-way split -- so each phi row's `_two_col_row` (the
renderer's fixed-pixel `label* | symbol(66) | editor(138) | annot(150)` grid,
see `script.py`) needed >450px it never had once two levels of column-halving
were done to it. `_render_table`'s cell handling was generalised for `lc_table`
(any editor kind, not just a tick, keyed per cell) and that generalisation
reads for a plain `table` too -- its grid columns are ordinary (unconstrained)
`ColumnDefinition`s, not `_two_col_row`'s fixed pixels, so a cell's editor gets
real room regardless of how many columns the table has. Resistance Factors is
now a `table`: rows Bending/Compression/Shear, columns Developed/Transferring,
built by `_cell(F(key))` so the existing missing-field `problems` bookkeeping
in `_field()` still runs even though the field block itself is only used for
its `editor`/`key`/`choices`, never rendered directly -- which does mean these
five phi keys carry no Notes-panel hint any more (`_render_field` is what
files a field's hint into the notes; a bare table cell dict has no hook for
that). The Concrete / Rebar-Mesh-Strand grids above stay on `columns`
deliberately -- they are not being squeezed the way Resistance Factors was.
PSI VS. KSI. `panelanalysisform.FIELDS` keeps concrete/rebar/strand strength
keys in **psi** (`fc_psi`, `fci_psi`, `rebar_fy_ksi` is the one already in
ksi, `ps_fpu_ksi` likewise, `strand_ep_ksi` likewise -- the module is not
perfectly consistent about it, and this file does not try to fix that, only
to read it correctly field by field). Eriksson prints everything in **ksi**.
Per the assignment: keep the psi keys as the source of truth and put the ksi
reading in the field's `symbol` or `hint` instead of introducing a second,
parallel set of ksi keys nobody would keep in sync. Concretely: `fc_psi` and
`fci_psi` keep their psi keys and their `hint` says "Eriksson prints this
divided by 1000, in ksi" so the two reconcile by eye against a printed sheet;
the keys that are already ksi (`rebar_fy_ksi`, `rebar_es_ksi`, `ps_fpu_ksi`,
`strand_ep_ksi`) need no such note.
WHY EXTERIOR SOIL / INTERIOR SOIL ARE `sentence` BLOCKS, NOT `field` BLOCKS.
On Eriksson's own page these are not something the user types on THIS page at
all -- they are a computed caption ("Design contains no exterior soil loads")
that reads back whatever soil-load rows exist elsewhere in the model. Nothing
in this codebase collects soil-load rows anywhere yet, so there is no field
for this section to degrade to an "empty editor" for -- there is no editor to
have. Rendering it as a static `sentence` instead of inventing a text field
that would silently claim to collect something it cannot is the same
discipline as `knowledge.same_face()` returning `None` rather than `False`:
"cannot judge, and does not pretend to." One `problems` entry names this once
for both columns, so the gap is visible without repeating the same sentence
twice.
THE R-VALUE / MINIMUM-REINFORCEMENT PAIRS mirror Eriksson's own pattern: a
checkbox that turns a calculation on, printed beside the sentence that
applies when it is off ("R-Value not being calculated"). Both checkbox keys
are new (`design_criteria.rvalue_calc_enabled`,
`design_criteria.min_reinf_checked` -- see the naming note below) and both
degrade the same way every other missing key does: an empty `check` editor
plus a named `problems` entry, with the off-state sentence always shown
underneath since "unchecked" is this page's honest default until the field
exists to say otherwise.
NAMING STYLE FOR NEW KEYS. `panelanalysisform` already has a dotted
namespacing convention for one composite input (`stage.<stage_slug>.<opt>`,
see `stage_field_key`). New keys here that do not fit a single flat name
follow the same shape: `design_criteria.<thing>` for one-off Settings/Project
fields (e.g. `design_criteria.analysis_beta_d`), and
`load_combo.<strength|service>.<n>.include` /
`load_combo.<strength|service>.min_earth_factor` for the Load Combinations
grid, mirroring `stage_field_key`'s `<group>.<slug>.<opt>` shape exactly.
IRONPYTHON 2.7: no f-strings, no walrus, no typing syntax; string formatting
is `'{0}'.format(...)`; every literal that carries the prime character or a
non-ASCII letter is a `u'...'` literal with the coding cookie above declaring
the file UTF-8, the same convention `panelanalysisform.py` uses throughout.
Imports NOTHING beyond the standard library and, defensively,
`vibe.panelanalysisform` for key-existence checks only -- no WPF, no Revit,
no `panelsketch`, so this loads and runs on plain CPython the way
`tests/conftest.py` expects.
"""
# ------------------------------------------------------------- key existence
def _existing_keys():
"""The set of keys `panelanalysisform.FIELDS` already declares, or None.
None means "could not judge" -- the import failed, or the module's shape
changed out from under us mid-refactor -- and every caller below treats
None the same as "key not found": every field on this page degrades to
an empty editor plus a `problems` entry rather than silently assuming
the other agent's in-flight edit added the key. Broad `except Exception`
on purpose: a sibling agent saving a half-written `panelanalysisform.py`
can raise a SyntaxError, not just ImportError, and this page must keep
rendering through that.
"""
try:
from vibe import panelanalysisform as pf
return set(f['key'] for f in pf.FIELDS)
except Exception:
return None
# ------------------------------------------------------------------- fields
#
# One entry per field this page can lay out: key -> (label, symbol, hint,
# required, editor, choices). Authored locally -- see the module docstring's
# "WHERE THE FIELD TEXT COMES FROM" for why. `required` is True only for the
# two keys `panelanalysisform` itself already gates the Run button on
# (`fc_psi`, `unit_weight_pcf`); every other field here is optional, matching
# the rest of Design Criteria in that module.
_F = {
# --------------------------------------------------- Project Information
'project_name': (
'Project name', '',
'The name of the project this panel belongs to, for the record on '
'this printout. Leave blank if the project has no name yet.',
False, 'text', None),
'client_name': (
'Client name', '',
"The client this panel's project is for. Leave blank if unknown.",
False, 'text', None),
'project_description': (
'Project description', '',
'A short free-text description of the project. Leave blank if '
'there is nothing to add.',
False, 'text', None),
'project_location': (
'Project location', '',
'Where the project is built -- city, state, or site name. Leave '
'blank if unknown.',
False, 'text', None),
'engineer_initials': (
'Engineer of record', '',
'Who is responsible for this design. Leave blank if not yet '
'assigned.',
False, 'text', None),
'project_date': (
'Design date', '',
'The date this design applies to. Leave blank to leave it unset.',
False, 'text', None),
'job_number': (
'Job number', '',
"This job's internal tracking number, if it has one. Leave blank "
'if none has been assigned yet.',
False, 'text', None),
'design_spec': (
'Governing design code', '',
'Which code edition this design is checked against. Leave blank '
'to leave it unstated.',
False, 'choice', ('ACI 318-19', 'ACI 318-14', 'ACI 318-11')),
# ------------------------------------------- Material Properties/Concrete
'fci_psi': (
'Concrete strength at release', u"f′ci (psi)",
u"How strong the concrete must be when the strand is cut or the "
u"panel is stripped from the bed -- usually lower than the 28-day "
u"strength. Eriksson prints this divided by 1000, in ksi. Leave "
u"blank if this panel is not prestressed.",
False, 'num', None),
'fc_psi': (
'Concrete strength at 28 days', u"f′c (psi)",
u"The concrete's design strength once fully cured; every stress "
u"limit on this page is a multiple of this number. Eriksson prints "
u"this divided by 1000, in ksi. Required -- the solve cannot run "
u"without it.",
True, 'num', None),
# Both moduli are DERIVED (`panelanalysisform.DERIVED_FIELDS`, ACI 318
# 19.2.2, Ec = 33 wc^1.5 sqrt(f'c)) and the page keeps them live as
# f'c / f'ci / unit weight are edited until the user types into one
# (issue #22, item 1). The old text here said a rule was still to be
# agreed; it had been in the field model since 2026-09-03. The solve
# still does not read either -- `panellift` computes its own -- and the
# hint says so, so the page mutes them honestly.
'conc_eci_psi': (
'Concrete elastic modulus at release', u"E′ci (psi)",
u"How stiff the concrete is at release strength. Computed by ACI "
u"318 from f′ci and the unit weight (Ec = 33 wc^1.5 sqrt(f′ci)) and "
u"kept up to date as you edit those; type a value to override it. "
u"Not yet used by the solve.",
False, 'num', None),
'conc_ec_psi': (
'Concrete elastic modulus, in-place', u"E′c (psi)",
u"How stiff the concrete is once fully cured. Computed by ACI 318 "
u"from f′c and the unit weight (Ec = 33 wc^1.5 sqrt(f′c)) and kept "
u"up to date as you edit those; type a value to override it. Not "
u"yet used by the solve.",
False, 'num', None),
'unit_weight_pcf': (
'Concrete unit weight', 'pcf',
"How much the concrete weighs per cubic foot; the panel's own "
'weight is computed from this. Required.',
True, 'num', None),
'concrete_type': (
'Concrete type', '',
'What kind of concrete this panel is cast from (normal weight, '
'lightweight, ...). Reference only -- nothing computes from it '
'yet.',
False, 'choice', ('Normalweight', 'Lightweight', 'Sand-Lightweight')),
# ----------------------------------------------- Material Properties/Bar
'rebar_fy_ksi': (
'Rebar yield strength', 'fy (ksi)',
'The stress at which ordinary (non-prestressed) reinforcing bar '
'starts to yield. Shown for the record; not yet used by the '
'solve.',
False, 'num', None),
'rebar_es_ksi': (
'Rebar elastic modulus', 'Es (ksi)',
'How stiff ordinary reinforcing steel is. Shown for the record; '
'not yet used by the solve.',
False, 'num', None),
'ps_fpu_ksi': (
u'Strand ultimate strength', u'fₚᵤ (ksi)',
'The breaking strength of the prestressing strand. Leave blank if '
'this panel is not prestressed.',
False, 'num', None),
'strand_ep_ksi': (
'Strand elastic modulus', 'Ep (ksi)',
'How stiff the prestressing strand is. Shown for the record; not '
'yet used by the solve.',
False, 'num', None),
# ----------------------------------------------- Material Properties/Mesh
'mesh_fy_ksi': (
'Mesh yield strength', 'fy (ksi)',
'The yield strength of the welded wire mesh. Defaults to the '
'common 80 ksi. Shown for the record; not yet used by the solve.',
False, 'num', None),
'mesh_es_ksi': (
'Mesh elastic modulus', 'Es (ksi)',
'How stiff the welded wire mesh is. Defaults to 29000 ksi. Shown '
'for the record; not yet used by the solve.',
False, 'num', None),
# -------------------------------------------------------- Load combos
'design_criteria.min_earth_factor_strength': (
'Minimum earth factor, strength combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'strength combinations below. Leave blank to use none.',
False, 'num', None),
'design_criteria.min_earth_factor_service': (
'Minimum earth factor, service combinations', '',
'The smallest lateral-earth-pressure factor applied across the '
'service combinations below. Leave blank to use none.',
False, 'num', None),
# --------------------------------------------------- Settings/Deflection
'deflection_limit': (
'Typical deflection limit', 'L / n',
'How much the panel is allowed to bow or sag, as a fraction of its '
'span, e.g. "150" for L/150. Read into the in-place service check; '
'blank leaves that check NOT JUDGED.',
False, 'text', None),
'design_criteria.deflection_add_bow': (
'Add an initial bow to reported deflections?', '',
'Whether the panel is assumed to already be slightly bowed before '
'any load is applied, and that starting bow is added to every '
'reported deflection. Leave unchecked to assume none.',
False, 'check', None),
'design_criteria.deflection_additional_bow_in': (
'Additional bow assumed', 'in',
'How much starting bow to add, if the box above is checked. Leave '
'blank for none.',
False, 'num', None),
'design_criteria.deflection_check_cantilever': (
'Check deflections on cantilevers?', '',
'Whether cantilevered portions of the panel get their own '
'deflection check, separate from the typical limit above. Leave '
'unchecked to skip it.',
False, 'check', None),
'design_criteria.deflection_cantilever_limit': (
'Cantilever deflection limit', 'L / n',
'The separate deflection limit for cantilevered portions, if the '
'box above is checked.',
False, 'choice', ('L/75', 'L/120', 'L/150')),
# ---------------------------------------------- Settings/Structural Anal.
'second_order': (
'2nd-order effects considered?', '',
'Whether P-delta / second-order bending is considered for this '
'panel. Not yet used by the solve.',
False, 'text', None),
'cracking_assumption': (
'Cracked or uncracked section assumed', '',
'Whether the stress check assumes the concrete section is cracked '
'or uncracked. Read into the in-place service check as the '
'cracking class.',
False, 'choice', ('Cracked', 'Uncracked')),
'design_criteria.analysis_beta_d': (
u'Sway condition for 2nd-order analysis', u'βd',
'Whether the 2nd-order analysis above treats this panel as braced '
'(non-sway) or unbraced (sway). Leave blank if not applicable.',
False, 'choice', ('Non-Sway', 'Sway')),
'design_criteria.cracking_method': (
'Cracking method', '',
'Which published method decides whether the section is treated as '
'cracked. Leave blank if not applicable.',
False, 'choice', ('Mast Method', 'Uncracked', 'Cracked')),
'design_criteria.cracking_limit_sqrt_fc': (
u'Cracking stress limit', u"n·√f′c",
u"The stress, as a multiple of √f′c, at which the "
u"section is assumed to crack. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'design_criteria.rvalue_calc_enabled': (
'Calculate an R-value for this panel?', '',
'Whether an insulation R-value is calculated for this panel. Leave '
'unchecked if it is not being calculated -- that is the default.',
False, 'check', None),
'design_criteria.min_reinf_checked': (
'Check minimum reinforcement?', '',
'Whether the ACI minimum-steel requirement is checked for this '
'panel. Leave unchecked if it is not being checked -- that is the '
'default.',
False, 'check', None),
# -------------------------------------------------- Settings/Stress opts
'design_criteria.stress_failure_if_cracked': (
'Fail the panel if it cracks?', '',
'Whether a cracked section is treated as an outright failure '
'rather than just a flag. Leave unchecked to only flag it.',
False, 'check', None),
'design_criteria.stress_ignore_lateral_ecc': (
'Ignore lateral strand eccentricity?', '',
'Whether side-to-side strand placement is ignored when computing '
'stress. Leave unchecked to include it.',
False, 'check', None),
'design_criteria.stress_use_principal_axis': (
'Use the principal axis for stress?', '',
'Whether stress is computed about the section\'s principal axis '
'rather than its geometric one. Leave unchecked to use the '
'geometric axis.',
False, 'check', None),
# Eriksson prints these as three rows under one "In-Place Stress Limits"
# heading -- Tension (uncracked), Tension (transition / modulus of
# rupture), Compression -- and the review on 2026-09-04 (WPA655) found
# this page missing the first of the three (`inplace_tension_sqrt` is a
# real `panelanalysisform.FIELDS` key, just never surfaced here) and
# labelling the other two by what they compute rather than by Eriksson's
# own row names. All three keep the naming scheme now.
'inplace_tension_sqrt': (
u'Tension, uncracked', u"n·√f′c",
u"The concrete's tensile stress limit once erected, while the "
u"section is still assumed uncracked, as a multiple of √f′c. "
u"Leave blank to use the built-in default.",
False, 'num', None),
'design_criteria.inplace_modulus_rupture_sqrt_fc': (
u'Tension, transition (modulus of rupture)', u"n·√f′c",
u"The concrete's tensile cracking stress once erected, as a "
u"multiple of √f′c. Leave blank to use the built-in "
u"default.",
False, 'num', None),
'inplace_comp_ratio': (
u'Compression', u"n·f′c",
u"The compression limit for the panel once it is standing in "
u"place, as a fraction of f′c. Not yet used by the solve.",
False, 'num', None),
'handling_tension_sqrt': (
'Tension allowed while handling', u"n·√f′ci",
u"How much the concrete may be pulled apart during stripping / "
u"handling, as a multiple of √f′ci. Blank uses the "
u"built-in default.",
False, 'num', None),
'handling_comp_ratio': (
'Compression allowed while handling', u"n·f′ci",
u"How much the concrete may be squeezed during handling, as a "
u"fraction of f′ci. Blank uses the built-in default.",
False, 'num', None),
'design_criteria.flexure_method': (
'Flexure calculation method', '',
'Which method computes bending capacity. Leave blank to use the '
'built-in default.',
False, 'choice', ('Whitney Stress Block', 'Strain Compatibility')),
'design_criteria.flexure_ignored_end_distance_in': (
'Ignored end distance', 'in',
'A length at each end of the panel excluded from the flexure '
'check (strand not yet developed there). Leave blank for none.',
False, 'num', None),
'design_criteria.flexure_cracking_check': (
'Flexural cracking check', '',
'Which cracking check applies to the flexure calculation, if any. '
'Leave blank for none.',
False, 'choice', ('None', '1.2 Mcr', '1.33 Mu')),
# ------------------------------------------------ Settings/Resist. Fact.
'phi_flexure': (
'Bending strength reduction factor, developed', u'φ, tension',
"The ACI safety factor applied to the panel's bending strength "
'once the strand is fully developed. Blank uses the built-in '
'default (0.90).',
False, 'num', None),
'design_criteria.phi_compression': (
'Compression strength reduction factor, developed',
u'φ, compression',
"The ACI safety factor applied to the panel's compression "
'strength once the strand is fully developed. Leave blank to use '
'the built-in default.',
False, 'num', None),
'phi_shear': (
'Shear strength reduction factor, developed', u'φ, shear',
"The ACI safety factor applied to the panel's shear strength once "
'the strand is fully developed. Blank uses the built-in default '
'(0.75).',
False, 'num', None),
'design_criteria.phi_tension_transfer': (
'Bending strength reduction factor, transferring',
u'φ, tension',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'design_criteria.phi_shear_transfer': (
'Shear strength reduction factor, transferring', u'φ, shear',
'The same safety factor as above, but while the strand is still '
'transferring its force into the concrete. Leave blank if not '
'applicable.',
False, 'num', None),
'dev_mult': (
'Strand development multiplier, normal', '',
'A multiplier on the strand development length for ordinary '
'(non-debonded, uncut) strand. Blank uses the built-in default '
'(1.0).',
False, 'num', None),
'design_criteria.dev_mult_debonded': (
'Strand development multiplier, debonded', '',
'The same multiplier, for strand that is deliberately debonded '
'over part of its length. Leave blank to use the built-in '
'default.',
False, 'num', None),
'design_criteria.dev_mult_cut': (
'Strand development multiplier, cut', '',
'The same multiplier, for strand that is field-cut rather than '
'released normally. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.dev_mult_mild': (
'Development multiplier, mild reinforcement', '',
'The same idea applied to ordinary (non-prestressed) reinforcing '
'bar. Leave blank to use the built-in default.',
False, 'num', None),
'design_criteria.shear_treat_as_column': (
'Treat this panel as a column for shear?', '',
'Whether the shear check uses column rules rather than wall '
'rules. Leave unchecked to use wall rules.',
False, 'check', None),
'design_criteria.axial_ignore_tensile_failures': (
'Ignore axial tensile-strength failures?', '',
'Whether the axial check is allowed to pass even when the '
"concrete's tensile strength alone is exceeded. Leave unchecked "
'to flag them.',
False, 'check', None),
'design_criteria.axial_ignore_buckling_failures': (
'Ignore buckling failures?', '',
'Whether the axial check is allowed to pass even when the panel '
'would buckle. Leave unchecked to flag them.',
False, 'check', None),
}
# Prestress-loss group: shown as a sentence when every one of these is blank
# (see `_group_or_sentence`), matching Eriksson's own "nothing entered here"
# style for Exterior/Interior Soil, applied to the one other Design Criteria
# group that is entirely optional and entirely empty on a fresh panel.
_PRESTRESS_LOSS_FIELDS = (
'ps_loss_method', 'ps_loss_humidity_pct',
'ps_loss_release_hours', 'ps_loss_erection_days',
)
_F['ps_loss_method'] = (
'Prestress loss method', '',
'Which published method (PCI, AASHTO, ...) the assumed loss below '
'comes from. Reference only.',
False, 'text', None)
_F['ps_loss_humidity_pct'] = (
'Ambient humidity assumed', '%',
'The relative humidity the loss method uses -- losses are larger in '
'drier air. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_release_hours'] = (
'Hours from casting to release', '',
'How many hours after casting the strand is cut / the panel is '
'stripped. Not yet used by the solve.',
False, 'num', None)
_F['ps_loss_erection_days'] = (
'Days from casting to erection', '',
'How many days after casting the panel is erected in place. Not yet '
'used by the solve.',
False, 'num', None)
def _field(key, problems, seen, existing):
"""One `field` block for `key`, plus a `problems` entry if it is missing.
`seen` de-dupes: several groups below reuse a couple of keys (none do
today, but the guard costs nothing and keeps a future edit from quietly
filing the same problem twice). `existing` is `_existing_keys()`'s
result, threaded through rather than re-read per field so one broken
import during a render is reported once, not dozens of times.
"""
spec = _F.get(key)
if spec is None:
# Programming error inside this file, not a missing form key --
# still degrade rather than raise, and say so plainly.
problems.append(
"Design Criteria has no text authored for the key '{0}' -- "
'this is a bug in pa_criteria.py, not a missing form field.'
.format(key))
return {'kind': 'field', 'key': key, 'label': key, 'symbol': None,
'hint': None, 'required': False, 'editor': 'text',
'choices': None}
label, symbol, hint, required, editor, choices = spec
if existing is None or key not in existing:
if key not in seen:
seen.add(key)
problems.append(
"'{0}' ({1}) has no backing field in panelanalysisform.FIELDS "
'yet -- shown as an empty editor until it is added.'
.format(label, key))
return {'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint, 'required': required,
'editor': editor, 'choices': list(choices) if choices else None}
def _heading(text, level):
return {'kind': 'heading', 'text': text, 'level': level}
def _cols(columns):
return {'kind': 'columns', 'columns': columns}
def _sentence(text):
return {'kind': 'sentence', 'text': text}
def _rule():
return {'kind': 'rule'}
def _space(h=1):
return {'kind': 'space', 'h': h}
def _table(headers, rows, header_groups=None):
return {'kind': 'table', 'headers': list(headers),
'header_groups': header_groups, 'rows': [list(r) for r in rows]}
def _cell(field_block):
"""A `table` cell dict for one `_field()` result -- the Resistance
Factors table (see the module docstring's 2026-09-04 update) reuses
`_field()` only for its `editor` / `key` / `choices` and the
missing-backing-field `problems` entry it already files, never for the
`field` block itself (a bare table cell has nowhere to put a label or a
hint, so those are simply not repeated here)."""
return {'editor': field_block['editor'], 'key': field_block['key'],
'choices': field_block['choices']}
def _group_or_sentence(values, keys, empty_text, build_filled):
"""[blocks] -- a `sentence` when every key in `keys` is blank, else
whatever `build_filled()` returns.
This is the "nothing here vs. never asked" split applied at group
granularity: `keys` all being blank in `values` is a real, filled-in
answer ("nothing entered"), not the absence of a form -- so it is shown
as a plain sentence instead of a wall of empty boxes, the same way
Eriksson's own printout shows a caption instead of blank inputs when
there is nothing to report. `values` is read with `.get(...)`, never
indexed, and never mutated.
`build_filled` is a zero-argument CALLABLE, not a plain list -- the
filled-in branch typically calls `F(key)`, which files a `problems`
entry for any key still missing from `panelanalysisform.FIELDS`, and
that side effect must only happen for fields actually being shown. A
plain list argument would be built eagerly by the caller before this
function ever looks at `keys`, filing a phantom "missing field" problem
for a field the empty-sentence branch never renders.
"""
for k in keys:
v = values.get(k)
if v is not None and str(v).strip():
return build_filled()
return [_sentence(empty_text)]
# --------------------------------------------------------- load combinations
#
# Standard ACI 318-14 basic strength/service combinations -- public code
# text, the same eight/ten rows any ACI-compliant tool would print, not this
# job's data.
#
# THESE ARE NOW A FALLBACK, not the source. The live copy is
# `panelanalysisform.LOAD_COMBO_ROWS`, because the factors became editable form
# VALUES and a value needs a key, which is that module's business. These stay
# because this file's whole discipline is that a sibling module mid-edit must
# not take the page down: if the import fails, the table still prints the ACI
# defaults (as plain text, with a named problem saying it cannot be edited)
# instead of vanishing. Keep the two in step -- a host test compares them.
_STRENGTH_COMBOS = (
('1', '1.4', '0', '0', '0', '0', '1', '1.6'),
('2', '1.2', '1.6', '0.5', '0', '0', '1', '1.6'),
('3', '1.2', '1', '1.6', '0', '0', '1', '1.6'),
('4', '1.2', '0', '1.6', '0.5', '0', '1', '1.6'),
('5', '1.2', '1', '0.5', '1', '0', '1', '1.6'),
('6', '1.2', '1', '0.2', '0', '1', '1', '1.6'),
('7', '0.9', '0', '0', '1', '0', '1', '1.6'),
('8', '0.9', '0', '0', '0', '1', '1', '1.6'),
)
_SERVICE_COMBOS = (
('1', '1', '0', '0', '0', '0', '0.75', '1'),
('2', '1', '1', '0', '0', '0', '0.75', '1'),
('3', '1', '0', '1', '0', '0', '0.75', '1'),
('4', '1', '0.75', '0.75', '0', '0', '0.75', '1'),
('5', '1', '0', '0', '0.6', '0', '0.75', '1'),
('6', '1', '0', '0', '0', '0.7', '0.75', '1'),
('7', '1', '0.75', '0.75', '0.45', '0', '0.75', '1'),
('8', '1', '0.75', '0.75', '0', '0.525', '0.75', '1'),
('9', '0.6', '0', '0', '0.6', '0', '0.75', '1'),
('10', '0.6', '0', '0', '0', '0.7', '0.75', '1'),
)
_COMBO_HEADERS = ['#', 'D', 'L', 'R', 'W', 'E', 'T', 'H', 'Include']
def _combo_table(name, values, problems):
"""One EDITABLE load-combination table, as an `lc_table` block.
Not a plain `table` any more. The owner ruled (2026-09-03) that the load
factors are the user's to change and that a row can be added, and a `table`
cell is only ever a string or a tick box -- so this emits `lc_table`, whose
factor cells each carry an `{'editor': 'num', 'key': ...}` the renderer
turns into a live box, plus the `factors` list the renderer's "Add LC"
button needs to build a new row's cells.
The FACTORS themselves come from `panelanalysisform.LOAD_COMBO_ROWS`, which
is the one place that knows both the ACI default and the key it is stored
under. The local tuples stay as a fallback for the case this file already
handles everywhere else -- the sibling module mid-edit and unimportable --
so a broken import degrades this page to the code defaults with no live
keys rather than to no table at all.
`values` is accepted (and read only for the user's own added rows) rather
than the row tuples the old signature took: the rows are no longer this
file's to decide.
"""
values = values if isinstance(values, dict) else {}
fallback = {'strength': _STRENGTH_COMBOS, 'service': _SERVICE_COMBOS}
factors = list(_COMBO_HEADERS[1:-1])
try:
from vibe import panelanalysisform as paf
factors = list(paf.LOAD_COMBO_FACTORS)
row_defaults = dict((r[0], r) for r in paf.LOAD_COMBO_ROWS.get(name, ()))
row_ids = paf.load_combo_row_ids(name, values)
factor_key = paf.load_combo_factor_key
include_key = paf.load_combo_include_key
except Exception:
problems.append(
'could not read panelanalysisform for the {0} load-combination '
'factors; the table below shows the ACI defaults as plain text '
'and cannot be edited until that module loads.'.format(name))
row_defaults = dict((r[0], r) for r in fallback.get(name, ()))
row_ids = [r[0] for r in fallback.get(name, ())]
def factor_key(nm, rid, col):
return 'load_combo.{0}.{1}.{2}'.format(nm, rid, col)
def include_key(nm, rid):
return 'load_combo.{0}.{1}.include'.format(nm, rid)
table_rows = []
for rid in row_ids:
cells = [str(rid)]
default = row_defaults.get(rid)
for i, col in enumerate(factors):
cells.append({'editor': 'num', 'width': 44,
'key': factor_key(name, rid, col),
'default': (default[i + 1] if default and
i + 1 < len(default) else None)})
cells.append({'editor': 'check', 'key': include_key(name, rid)})
table_rows.append(cells)
return {'kind': 'lc_table', 'name': name,
'headers': list(_COMBO_HEADERS), 'header_groups': None,
'rows': table_rows, 'factors': factors, 'row_key': None}
# --------------------------------------------------------------------- page
def page(values, reads=None):
"""(blocks, problems) for the Design Criteria section.
`values` is the form's current string-valued dict, read defensively --
every lookup goes through `.get(...)` and a key this module expects but
does not find is treated as blank, never as an error, because another
agent is restructuring `panelanalysisform.FIELDS` at the same time this
is being written. `values` is never mutated. `reads` is optional and, on
this section, only used for the one-line "Panel <mark>" context caption
at the top -- everything else on Design Criteria is job-level or
material data, not something read off the framing element.
Never raises: every lookup that could fail (the `panelanalysisform`
import, a missing dict key) degrades to a `problems` entry instead.
"""
values = values if isinstance(values, dict) else {}
reads = reads if isinstance(reads, dict) else {}
problems = []
seen = set()
existing = _existing_keys()
if existing is None:
problems.append(
'could not read panelanalysisform.FIELDS to check which keys '
'already exist (the module may be mid-edit); every field on '
'this page is shown, and is reported as missing until this is '
'confirmed.')
def F(key):
return _field(key, problems, seen, existing)
blocks = []
blocks.append(_heading('Design Criteria', 1))
mark = (reads.get('mark') or '').strip()
design_number = (reads.get('design_number') or '').strip()
if mark or design_number:
blocks.append({'kind': 'kv', 'rows': [
('Panel', mark or '(mark not read)'),
('Design number', design_number or '(not read)'),
]})
# ----------------------------------------------------- Project Information
blocks.append(_heading('Project Information', 2))
blocks.append(_cols([
[F('project_name'), F('client_name'), F('project_description')],
[F('project_location'), F('engineer_initials')],
[F('project_date'), F('job_number'), F('design_spec')],
]))
blocks.append(_rule())
# ------------------------------------------------------ Material Properties
blocks.append(_heading('Material Properties', 2))
blocks.append(_heading('Concrete', 3))
blocks.append(_cols([
[F('fci_psi'), F('fc_psi')],
[F('conc_eci_psi'), F('conc_ec_psi')],
[F('unit_weight_pcf'), F('concrete_type')],
]))
blocks.append(_space(1))
blocks.append(_cols([
[_heading('Rebar', 3), F('rebar_fy_ksi'), F('rebar_es_ksi')],
[_heading('Mesh', 3), F('mesh_fy_ksi'), F('mesh_es_ksi')],
[_heading('Strand', 3), F('ps_fpu_ksi'), F('strand_ep_ksi')],
]))
blocks.append(_rule())
# ---------------------------------------------------------- Prestress Loss
#
# Two columns, not the one wide column this used to be laid out in: a
# single-column `_cols` hands `_two_col_row`'s star label a full page
# width to stretch into, which is what the 2026-09-04 review (WPA655)
# reported as the '%' symbol on `ps_loss_humidity_pct` "floating mid-row
# far from its editor" -- the symbol and editor are still adjacent (see
# `script.py`'s `_two_col_row`), but a page-wide row puts that pair a
# long way from the label a reader's eye is anchored to. Splitting the
# four fields into two narrower columns, the same way every other group
# on this page already does, keeps the whole row -- label, symbol, and
# editor together -- inside one eye span.
blocks.append(_heading('Prestress Losses', 2))
blocks.extend(_group_or_sentence(
values, _PRESTRESS_LOSS_FIELDS,
'No prestress loss assumptions entered for this panel.',
lambda: [_cols([
[F('ps_loss_method'), F('ps_loss_humidity_pct')],
[F('ps_loss_release_hours'), F('ps_loss_erection_days')],
])]))
blocks.append(_rule())
# -------------------------------------------------------------- Soil loads
#
# `sentence`, not `field` -- see the module docstring's "WHY EXTERIOR
# SOIL / INTERIOR SOIL ARE `sentence` BLOCKS" for why there is nothing
# to degrade to an editor for here.
problems.append(
'soil loads are not collected anywhere in this form yet; Exterior '
'Soil and Interior Soil always read as "no loads" until a later '
'stage adds that input.')
blocks.append(_cols([
[_heading('Exterior Soil', 2),
_sentence('Design contains no exterior soil loads.')],
[_heading('Interior Soil', 2),
_sentence('Design contains no interior soil loads.')],
]))
blocks.append(_rule())
# ----------------------------------------------------------- Load Combos
blocks.append(_heading('Load Combinations', 1))
blocks.append(_cols([
[_heading('Strength Combinations', 2),
_combo_table('strength', values, problems),
F('design_criteria.min_earth_factor_strength')],
[_heading('Service Combinations', 2),
_combo_table('service', values, problems),
F('design_criteria.min_earth_factor_service')],
]))
blocks.append(_rule())
# --------------------------------------------------------------- Settings
blocks.append(_heading('Settings', 1))
col_a = [
_heading('Deflection Limits', 2),
F('design_criteria.deflection_add_bow'),
F('design_criteria.deflection_additional_bow_in'),
F('deflection_limit'),
F('design_criteria.deflection_check_cantilever'),
F('design_criteria.deflection_cantilever_limit'),
_heading('Structural Analysis', 2),
F('second_order'),
F('cracking_assumption'),
F('design_criteria.analysis_beta_d'),
F('design_criteria.cracking_method'),
F('design_criteria.cracking_limit_sqrt_fc'),
F('design_criteria.rvalue_calc_enabled'),
] + _group_or_sentence(
values, ('design_criteria.rvalue_calc_enabled',),
'R-value is not being calculated for this panel.', lambda: []) + [
F('design_criteria.min_reinf_checked'),
] + _group_or_sentence(
values, ('design_criteria.min_reinf_checked',),
'Minimum steel is not being checked for this panel.', lambda: [])
col_b = [
_heading('Stress Options', 2),
F('design_criteria.stress_failure_if_cracked'),
F('design_criteria.stress_ignore_lateral_ecc'),
F('design_criteria.stress_use_principal_axis'),
_heading('In-Place Stress Limits', 2),
F('inplace_tension_sqrt'),
F('design_criteria.inplace_modulus_rupture_sqrt_fc'),
F('inplace_comp_ratio'),
_heading('Handling Stress Limits', 2),
F('handling_tension_sqrt'),
F('handling_comp_ratio'),
_heading('Flexure Options', 2),
F('design_criteria.flexure_method'),
F('design_criteria.flexure_ignored_end_distance_in'),
F('design_criteria.flexure_cracking_check'),
]
col_c = [
_heading('Resistance Factors', 2),
# A `table`, not the `columns`-of-`field`s this used to be -- the
# 2026-09-04 review (WPA655) found every phi editor rendered with
# zero width. That old layout was two `columns` sub-columns
# ("Developed" | "Transferring") of `field` blocks, nested inside
# `col_c` itself one of three columns -- two levels of halving, each
# `field` needing the renderer's fixed ~450px `_two_col_row` it never
# had a fraction of. `_render_table` puts a cell's editor in an
# ordinary (unconstrained) grid column instead, so there is always
# room. See the module docstring's 2026-09-04 update for why `_cell`
# still routes every key through `F()` first (it is what keeps the
# existing "no backing field yet" `problems` bookkeeping honest).
_table(
[u'φ', 'Developed', 'Transferring'],
[
['Bending', _cell(F('phi_flexure')),
_cell(F('design_criteria.phi_tension_transfer'))],
['Compression', _cell(F('design_criteria.phi_compression')),
'Not applicable once the strand has fully transferred.'],
['Shear', _cell(F('phi_shear')),
_cell(F('design_criteria.phi_shear_transfer'))],
],
),
_heading('Development Multipliers', 2),
F('dev_mult'),
F('design_criteria.dev_mult_debonded'),
F('design_criteria.dev_mult_cut'),
F('design_criteria.dev_mult_mild'),
_heading('Shear Options', 2),
F('design_criteria.shear_treat_as_column'),
_heading('Axial Options', 2),
F('design_criteria.axial_ignore_tensile_failures'),
F('design_criteria.axial_ignore_buckling_failures'),
]
blocks.append(_cols([col_a, col_b, col_c]))
return blocks, problems
+807
View File
@@ -0,0 +1,807 @@
# -*- coding: utf-8 -*-
"""Aggressive Panel Analysis defaults: fill every blank the tool can defend.
WHY THIS EXISTS. Owner, 2026-09-04: "目标是我按一下 WPA655 的 panel analysis,
然后手动输入 loading, 剩下的它就能跑了" -- press the button, type the loading,
everything else already has a number. The form opened that day with 141 of
315 fields blank. His instruction was explicit that BLANK is the failure mode
here, not a wrong-looking guess -- every field with a defensible default gets
one, and every one of them names where it came from. Only true LOADING (floor
loads, wind speed / pressure, seismic Cs, soil) is left for the user, because
those are the numbers this tool cannot know and must not invent.
WHAT THIS MODULE OWNS. `panelanalysisform.prefill()` already fills what the
model can read plus a small `PREFILL_DEFAULTS` table of generic precast
constants (concrete type, rebar grade, mesh grade, the PCI loss-method
seeds). This module is a SECOND, later pass over the same `values` dict --
covering the much larger set of Design Criteria / Structural Model / Handling
fields that table does not touch, most of which do not even have a home in
`panelanalysisform.FIELDS` yet (they live in `pa_criteria._F` and inline in
`pa_structural.page()` instead, under the `design_criteria.*` / `load_opt_*`
/ `wind_*` / `temp_*` / `restraint_*` / `stage.<slug>.<opt>` naming this
codebase already uses). `apply_defaults()` is meant to run AFTER `prefill()`
in the button script, so the ordinary precedence rule holds without this
module doing anything special for it: a value already sitting in `values` --
a model read, a plant `panelanalysis.settings.*` pin, or a
`PREFILL_DEFAULTS` seed -- always wins, and this module only ever fills what
is STILL blank.
SOURCES CITED IN `why`. Three kinds, and every filled entry names one:
* "Eriksson Wall default (<page> page)" -- the value the owner's own
Eriksson Wall runs show for this field on a fresh panel. This is the
fallback for most of the Design Criteria / Handling settings below --
generic precast practice, not one job's data.
* an ACI 318-19 / ASCE 7-22 section number -- code text, not a guess.
* "plant knowledge <dotted.path>" -- a real value read off the merged
knowledge layers (`know`), which always outranks the generic fallback
for the handful of fields a plant is expected to pin (the handling
stage multipliers and strength, the sling angle, the strand rail
datum, `top_in_form`, `stress_ignore_lateral_ecc`, the engineer's own
initials). Nothing here reads the Revit API or a live document --
`know` is just the already-merged dict the button hands in.
WHAT NEVER GETS A DEFAULT. `ps_loss_pct` (the method computes it),
`ext_soil_loads` / `int_soil_loads` (loading), every raw `seismic_cs` /
`seismic_zone_location` field (loading -- the owner ruled on this
explicitly, 2026-09-02: "风的计算还没有你就留着空着,让用户输入"), and
`engineer_initials` when no plant value names one -- a person's identity is
not a default, the same rule CLAUDE.md states for client and job names.
`conc_loads` (the old free-text field) is gone from the form entirely --
concentrated loads are a row table now (`panelanalysisform.CONC_LOAD_TABLE`),
never defaulted, same rule. ASCE wind (`wind_speed_vult`, `wind_risk_
category`, `wind_exposure`, `wind_enclosure`, `wind_asce_version`,
`wind_cc_chapter`, `wind_mwfrs_chapter`, `wind_mean_roof_height_ft`,
`wind_effective_wind_area_ft2`, `wind_pressure_psf`, `wind_suction_psf`) is
gone from this module too -- 2026-09-04 owner ruling moved ASCE site data
out of this form into project config entirely (see
`panelanalysisform`'s module docstring, Wind section); this module never
defaulted the two real-loading fields among them anyway, and now has
nothing left to default for the rest either.
NEVER RAISES. `defaults_for` / `apply_defaults` / `why_of` / `known_keys` /
`unknown_keys` all degrade to an empty result rather than an exception --
a malformed `values` (`None`, not a dict), a malformed `know` ('garbage',
not a dict), or a sibling module mid-edit (an import failure in
`panelanalysisform` / `pa_criteria` / `pa_structural`, all still being
written the same day this file was) each narrow what gets filled instead of
stopping the whole pass. `known_keys()` follows the same "could not judge
narrows, never guesses" discipline CLAUDE.md's `knowledge.same_face()`
example sets: a module that fails to import contributes no keys, not a
guessed set.
`known_keys()` / `unknown_keys()` READ THE LIVE FORM, not a copy of its key
list. `pa_structural.page()` has no flat field table the way
`panelanalysisform.FIELDS` and `pa_criteria._F` do -- its field keys are
scattered through one long `page()` function as inline `_field(...)` calls.
Rather than hand-copy that list here (and drift the day someone edits it),
`_pa_structural_blocks()` calls `pa_structural.page({}, {})` -- documented as
never raising -- and walks the returned block tree for every `kind: 'field'`
entry, the same shape the WPF renderer itself would see. That also recovers
each field's `choices`, feeding the choice-membership check below.
CHOICE VOCABULARY. Where a key has a `choices` list anywhere in the form
(`panelanalysisform.FIELD_CHOICES`, `pa_criteria._F`'s own choices column,
`pa_structural.page()`'s inline `choices=[...]`, or the Handling stage
matrix's per-column `choices` hint in `STAGE_OPTIONS`), the default picked
below is one of those choices -- verified by
`tests/test_pa_defaults.py`, not just asserted here. Two of the owner's
literal example values did not survive that check against the real editors
and were changed; see the module-level `_DECISIONS` note near the bottom of
this file for both.
IRONPYTHON 2.7: no f-strings, `'{0}'.format(...)` throughout, this coding
cookie, no `pathlib` / `dataclasses` / typing syntax. Imports nothing but the
standard library at module scope; every `vibe.*` import is inside a function,
wrapped in `try/except Exception`, exactly so a sibling module mid-edit can
never take this one down.
"""
import datetime
# --------------------------------------------------------------- key sources
def _panelanalysisform():
try:
from vibe import panelanalysisform as paf
return paf
except Exception:
return None
def _pa_criteria():
try:
from vibe import pa_criteria as pc
return pc
except Exception:
return None
def _pa_structural_blocks():
"""[(key, editor, choices), ...] read off a live `pa_structural.page()`
call -- see the module docstring's "READ THE LIVE FORM" note for why
this walks the block tree instead of keeping a second copy of the field
list. Never raises: an import failure or a `page()` that cannot run
(both plausible while a sibling agent edits either file) returns [].
"""
try:
from vibe import pa_structural as ps
except Exception:
return []
try:
blocks, _problems = ps.page({}, {})
except Exception:
return []
out = []
def walk(items):
for b in items or []:
if not isinstance(b, dict):
continue
kind = b.get('kind')
if kind == 'field' and b.get('key'):
out.append((b.get('key'), b.get('editor'), b.get('choices')))
elif kind == 'columns':
for col in (b.get('columns') or []):
walk(col)
elif kind == 'section':
# A collapsed section (seismic / temperature / soil since
# 2026-09-04) carries its fields in `blocks`; they are still
# the form's keys.
walk(b.get('blocks'))
walk(blocks)
return out
def known_keys():
"""Every key the Panel Analysis form is known to carry today, gathered
defensively from every section module this file knows how to ask:
`panelanalysisform.FIELDS`, the Handling stage matrix
(`stage_field_keys()`), `pa_criteria._F`, and a live read of
`pa_structural.page()`'s field blocks. A module that fails to import
only narrows this set -- see the module docstring.
"""
keys = set()
paf = _panelanalysisform()
if paf is not None:
try:
keys.update(f['key'] for f in paf.FIELDS)
except Exception:
pass
try:
keys.update(paf.stage_field_keys())
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
keys.update(pc._F.keys())
except Exception:
pass
for key, _editor, _choices in _pa_structural_blocks():
keys.add(key)
return keys
def unknown_keys():
"""The keys this module WOULD default that `known_keys()` does not (yet)
recognise -- empty once the field-model agents have caught up. Never
raises; a lookup failure inside `known_keys()` already degrades to a
smaller set, so this can only ever over-report, never under-report, a
drift.
"""
known = known_keys()
out = []
for key in _all_default_keys():
if key not in known and key not in out:
out.append(key)
return out
# ------------------------------------------------------------- choice tables
def _choice_registry():
"""key -> tuple(choices), unioned across every source that names one.
A key with no entry here is free text (or a key nothing declares a
choice list for yet) -- `choices_for` returns None for it, and the
default picked for it is not checked against anything.
"""
out = {}
def add(key, choices):
if not choices:
return
cur = out.get(key, ())
merged = tuple(cur) + tuple(c for c in choices if c not in cur)
out[key] = merged
paf = _panelanalysisform()
if paf is not None:
try:
for key, choices in paf.FIELD_CHOICES.items():
add(key, choices)
except Exception:
pass
try:
for stage in paf.STAGES:
for opt_key, _label, _sym, _help, _kind, _drives, choices \
in paf.STAGE_OPTIONS:
add(paf.stage_field_key(stage, opt_key), choices)
except Exception:
pass
pc = _pa_criteria()
if pc is not None:
try:
for key, spec in pc._F.items():
choices = spec[5] if len(spec) > 5 else None
add(key, choices)
except Exception:
pass
for key, _editor, choices in _pa_structural_blocks():
add(key, choices)
return out
def choices_for(key):
"""The unioned choice vocabulary for `key`, or None for free text."""
return _choice_registry().get(key) or None
# ------------------------------------------------------------------ helpers
def _is_blank(value):
if value is None:
return True
try:
return value.strip() == ''
except AttributeError:
return False
def _num(text):
"""A best-effort float from whatever sits in `values`, or None."""
if text is None:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A number as the shortest text the form shows -- '2' not '2.0'."""
if value is None:
return ''
if isinstance(value, bool):
return 'Yes' if value else 'No'
try:
if isinstance(value, int):
return str(value)
except Exception:
pass
try:
f = float(value)
except (TypeError, ValueError):
return str(value)
if f == int(f):
return str(int(f))
text = ('%.10f' % f).rstrip('0').rstrip('.')
return text
def _today_iso():
return datetime.date.today().isoformat()
def _as_dict(value):
return value if isinstance(value, dict) else {}
# ---------------------------------------------------------- static defaults
#
# key -> (value_text, why). Fixed values -- no plant lookup, no computation
# from another field -- for every Design Criteria / Structural Model /
# Handling field the owner named that does not depend on plant knowledge or
# on another field's value. See `_DYNAMIC_DEFAULTS` and `_stage_dynamic_*`
# below for the ones that do.
_ERIKSSON = 'Eriksson Wall default'
def _e(page):
return '{0} ({1} page)'.format(_ERIKSSON, page)
_STATIC_DEFAULTS = (
('design_spec', 'ACI 318-19',
'current governing code edition (ASCE 7-22 companion); the sample '
'Eriksson runs printed ACI 318-14 because they were older files'),
# ---------------------------------------------------- Design Criteria
('design_criteria.deflection_add_bow', 'No', _e('Settings')),
('design_criteria.deflection_additional_bow_in', '0', _e('Settings')),
('design_criteria.deflection_check_cantilever', 'No', _e('Settings')),
('design_criteria.deflection_cantilever_limit', 'L/75', _e('Settings')),
('design_criteria.analysis_beta_d', 'Non-Sway',
"matches the field's own choice vocabulary (Non-Sway / Sway) -- "
"Non-Sway is " + _e('Settings')),
('design_criteria.cracking_method', 'Mast Method', _e('Settings')),
('design_criteria.cracking_limit_sqrt_fc', '7.5', _e('Settings')),
('design_criteria.rvalue_calc_enabled', 'No', _e('Settings')),
('design_criteria.min_reinf_checked', 'No', _e('Settings')),
('design_criteria.stress_failure_if_cracked', 'No', _e('Settings')),
('design_criteria.stress_use_principal_axis', 'No', _e('Settings')),
('design_criteria.inplace_modulus_rupture_sqrt_fc', '12',
u"Eriksson's own \"transition\" tension limit, 12 x sqrt(f'c)"),
('design_criteria.flexure_method', 'Whitney Stress Block',
_e('Settings')),
('design_criteria.flexure_ignored_end_distance_in', '0',
_e('Settings')),
('design_criteria.flexure_cracking_check', 'None', _e('Settings')),
('design_criteria.phi_compression', '0.65', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_tension_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.phi_shear_transfer', '0.75', "ACI 318-19 Sec. 21.2"),
('design_criteria.dev_mult_debonded', '2', _e('Settings')),
('design_criteria.dev_mult_cut', '1.3', _e('Settings')),
('design_criteria.dev_mult_mild', '1.3', _e('Settings')),
('dev_mult', '1.3', _e('Settings') + ' -- strand, normal'),
('design_criteria.shear_treat_as_column', 'No', _e('Settings')),
('design_criteria.axial_ignore_tensile_failures', 'No', _e('Settings')),
('design_criteria.axial_ignore_buckling_failures', 'No', _e('Settings')),
('design_criteria.min_earth_factor_strength', '0', _e('Settings')),
('design_criteria.min_earth_factor_service', '0', _e('Settings')),
('second_order', 'Yes', _e('Settings') + ' -- P-Delta'),
('deflection_limit', 'L/150', _e('Settings')),
# ------------------------------------------------------- Load Options
('self_weight_factor', '1', _e('Settings')),
('load_opt_selfweight_ecc_in', '0', _e('Settings')),
('load_opt_offset_from', 'Deflection Section',
'Eriksson calls this the Loading Datum; the sample runs measure '
'point-load offsets from the Deflection Section'),
('load_opt_pattern_live', 'No',
_e('Settings') + ' -- Pattern Live Loads for Flexure is off in the '
'sample runs'),
# -------------------------------------------------------- Temperature
('temp_ext_f', '75', _e('Settings')),
('temp_int_f', '75', _e('Settings')),
('temp_elasticity_mult', '0.5', _e('Settings')),
('temp_alpha_t', '5.5',
u'5.5 × 10⁻⁶ /°F (ACI 209 / PCI), entered as the '
u'×10⁻⁶ mantissa the field asks for'),
# Wind is GONE from this module -- 2026-09-04 owner ruling moved every
# ASCE site-data field (Vult, risk/exposure/enclosure, code version,
# C&C/MWFRS chapter, mean roof height, effective wind area) out of this
# form into project config; see `panelanalysisform`'s module docstring.
# `wind_mode` (the one wind field left) defaults on the form side, in
# `panelanalysisform.prefill` itself, not here.
# ---------------------------------------------------------- Restraints
# ------------------------------------------------------------ Seismic
# 'seismic_apply_to' picks WHAT the (still-blank, user-typed) seismic
# coefficient multiplies -- not a magnitude itself, so it is not the
# loading the owner ruled must stay blank. Its editor carries an
# explicit 'None' choice now, matched here rather than left blank.
('seismic_apply_to', 'None',
"matches the field's own choice vocabulary (None / Out-of-plane / "
'In-plane / Both); ' + _e('Structural Model') + ' -- no seismic '
'multiplier applied in the sample runs'),
# ------------------------------------------------- Prestressing Strands
('strand_rail_offset_in', '0', _e('Reinforcement')),
# -------------------------------------------------------------- Extents
('left_miter', 'No', _e('Concrete Extents')),
('right_miter', 'No', _e('Concrete Extents')),
)
def _all_static_keys():
return [key for key, _v, _w in _STATIC_DEFAULTS]
# ---------------------------------------------------------- dynamic values
#
# key -> function(values, know) -> (value_text, why) | None. None means
# "this module cannot defend a default here right now" -- the key stays
# blank, never a fabricated fallback. See the module docstring for the
# plant-knowledge paths each of these reads.
def _dyn_project_date(values, know):
return _today_iso(), "today's date, the same convention design_date uses"
def _dyn_engineer_initials(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('engineer_initials')
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text, 'plant knowledge panelanalysis.engineer_initials'
def _dyn_stress_ignore_lateral_ecc(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
settings = _as_dict(pa.get('settings'))
value = settings.get('stress_ignore_lateral_ecc')
if value is not None:
text = str(value).strip().lower()
yn = 'No' if text in ('', 'no', 'none', 'false', '0', 'off') \
else 'Yes'
return yn, ('plant knowledge '
'panelanalysis.settings.stress_ignore_lateral_ecc')
return 'No', _e('Settings')
def _dyn_top_in_form(values, know):
pa = _as_dict(_as_dict(know).get('panelanalysis'))
value = pa.get('top_in_form')
if value is not None:
text = str(value).strip()
if text:
for choice in ('Interior', 'Exterior'):
if text.lower() == choice.lower():
text = choice
break
return text, 'plant knowledge panelanalysis.top_in_form'
return 'Interior', _e('Concrete Extents')
_RAIL_LABELS = {
'left_rail': 'Left edge',
'right_rail': 'Right edge',
'centerline': 'Centerline',
}
def _dyn_strand_rail_location(values, know):
rails = _as_dict(_as_dict(know).get('rails'))
datum = rails.get('strand_datum')
if datum is not None:
label = _RAIL_LABELS.get(str(datum).strip().lower())
if label:
return label, 'plant knowledge rails.strand_datum'
return 'Left edge', _e('Reinforcement')
_DYNAMIC_DEFAULTS = (
('project_date', _dyn_project_date),
('engineer_initials', _dyn_engineer_initials),
('design_criteria.stress_ignore_lateral_ecc',
_dyn_stress_ignore_lateral_ecc),
('top_in_form', _dyn_top_in_form),
('strand_rail_location', _dyn_strand_rail_location),
# `wind_effective_wind_area_ft2` / `wind_mean_roof_height_ft` are GONE --
# see the module docstring's Wind note; both fields left the form.
)
# --------------------------------------------------------- handling stages
#
# `panelanalysisform.stage_field_key(stage, opt)` builds
# 'stage.<slug>.<opt>' from the stage NAME, not the slug -- but this module
# hardcodes the slugs too (matching `_stage_slug`'s
# `name.lower().replace(' ', '_')` exactly) rather than importing the form
# at module scope, so a sibling agent mid-edit on `panelanalysisform.py`
# cannot take the whole default table down with it. `known_keys()` still
# checks these against the live `stage_field_keys()` output, so any drift
# between the hardcoded slug and the real one shows up as an `unknown_keys()`
# entry rather than silently mis-keying the form.
_STAGES = ('Stripping', 'Transportation', 'Primary Erection',
'Secondary Erection')
_STAGE_SLUGS = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'primary_erection',
'Secondary Erection': 'secondary_erection',
}
_STAGE_PLANT_NAME = {
'Stripping': 'stripping',
'Transportation': 'transportation',
'Primary Erection': 'erection',
'Secondary Erection': None,
}
_STAGE_FALLBACK_MULTIPLIER = {
'Stripping': 1.4,
'Transportation': 1.5,
'Primary Erection': 1.4,
}
_STAGE_FALLBACK_STRENGTH_LABEL = {
'Stripping': 'Initial',
'Transportation': 'In-Place',
'Primary Erection': 'In-Place',
}
_STAGE_FALLBACK_MAX_TILT = {
'Stripping': 0,
'Transportation': 0,
'Primary Erection': 90,
}
_STRENGTH_LABELS = {'fci': 'Initial', 'fc': 'In-Place'}
def _stage_key(stage, opt):
return 'stage.{0}.{1}'.format(_STAGE_SLUGS[stage], opt)
def _plant_stage(know, plant_name):
if plant_name is None:
return None
handling = _as_dict(_as_dict(know).get('handling'))
stages = handling.get('stages')
if not isinstance(stages, list):
return None
for entry in stages:
if isinstance(entry, dict) and entry.get('name') == plant_name:
return entry
return None
def _plant_sling_angle(know):
handling = _as_dict(_as_dict(know).get('handling'))
value = handling.get('sling_angle_deg')
return value if value is not None else None
# Static (non-plant-dependent) cells, one set per active stage -- generated
# once into `_STATIC_DEFAULTS`-shaped tuples so they go through the exact
# same blank-check / why_of machinery as everything else. Only
# `load_multiplier`, `concrete_strength` and `sling_angle` are plant-aware
# (see `_stage_dynamic_defaults` below); everything else about a stage is
# the same Eriksson-sample answer regardless of plant.
def _stage_static_defaults():
out = []
for stage in _STAGES:
if stage == 'Secondary Erection':
out.append((
_stage_key(stage, 'include'), 'No',
_e('Handling') + ' -- secondary erection is not run for '
'this panel unless entered'))
continue
out.append((_stage_key(stage, 'include'), 'Yes', _e('Handling')))
out.append((_stage_key(stage, 'lift_type'), 'Static Lift',
_e('Handling')))
stress_limits = 'Handling' if stage == 'Stripping' else 'In-Place'
out.append((_stage_key(stage, 'stress_limits'), stress_limits,
_e('Handling')))
out.append((_stage_key(stage, 'rigging'), 'Auto', _e('Handling')))
out.append((_stage_key(stage, 'ground_reaction'), 'No',
_e('Handling')))
out.append((_stage_key(stage, 'min_tilt_deg'), '0', _e('Handling')))
out.append((_stage_key(stage, 'max_tilt_deg'),
_fmt_num(_STAGE_FALLBACK_MAX_TILT[stage]),
_e('Handling')))
out.append((_stage_key(stage, 'top_crane_carries'), 'No',
_e('Handling')))
return tuple(out)
_STAGE_STATIC_DEFAULTS = _stage_static_defaults()
def _stage_dynamic_defaults(values, know):
out = []
sling = _plant_sling_angle(know)
if sling is not None:
sling_text = _fmt_num(sling)
sling_why = 'plant knowledge handling.sling_angle_deg'
else:
sling_text = '60'
sling_why = _e('Handling')
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
plant_name = _STAGE_PLANT_NAME.get(stage)
pstage = _plant_stage(know, plant_name)
multiplier = None
mult_why = _e('Handling')
if pstage is not None and pstage.get('multiplier') is not None:
multiplier = pstage.get('multiplier')
mult_why = 'plant knowledge handling.stages'
if multiplier is None:
multiplier = _STAGE_FALLBACK_MULTIPLIER[stage]
out.append((_stage_key(stage, 'load_multiplier'),
_fmt_num(multiplier), mult_why))
strength_label = _STAGE_FALLBACK_STRENGTH_LABEL[stage]
strength_why = _e('Handling')
if pstage is not None and pstage.get('strength') in \
_STRENGTH_LABELS:
strength_label = _STRENGTH_LABELS[pstage.get('strength')]
strength_why = 'plant knowledge handling.stages'
out.append((_stage_key(stage, 'concrete_strength'), strength_label,
strength_why))
out.append((_stage_key(stage, 'sling_angle'), sling_text,
sling_why))
return out
def _stage_dynamic_keys():
keys = []
for stage in _STAGES:
if stage == 'Secondary Erection':
continue
keys.append(_stage_key(stage, 'load_multiplier'))
keys.append(_stage_key(stage, 'concrete_strength'))
keys.append(_stage_key(stage, 'sling_angle'))
return keys
# --------------------------------------------------------------- the WHY map
_WHY = {}
for _key, _value, _why in _STATIC_DEFAULTS:
_WHY[_key] = _why
for _key, _value, _why in _STAGE_STATIC_DEFAULTS:
_WHY[_key] = _why
del _key, _value, _why
_WHY['project_date'] = ("today's date, the same convention design_date "
'uses')
_WHY['engineer_initials'] = (
'plant knowledge panelanalysis.engineer_initials, when the plant names '
'one -- a person\'s identity is never guessed, so this key is left '
'blank without a plant value')
_WHY['design_criteria.stress_ignore_lateral_ecc'] = (
'plant knowledge panelanalysis.settings.stress_ignore_lateral_ecc when '
'set, else ' + _e('Settings'))
_WHY['top_in_form'] = ('plant knowledge panelanalysis.top_in_form when '
'set, else ' + _e('Concrete Extents'))
_WHY['strand_rail_location'] = ('plant knowledge rails.strand_datum when '
'set, else ' + _e('Reinforcement'))
for _stage in _STAGES:
if _stage == 'Secondary Erection':
continue
_WHY[_stage_key(_stage, 'load_multiplier')] = (
'plant knowledge handling.stages when a matching stage names a '
'multiplier, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'concrete_strength')] = (
"plant knowledge handling.stages' strength field (fci -> Initial, "
'fc -> In-Place) when present, else ' + _e('Handling'))
_WHY[_stage_key(_stage, 'sling_angle')] = (
'plant knowledge handling.sling_angle_deg when set, else ' +
_e('Handling'))
del _stage
def why_of(key):
"""The source sentence for `key`'s default, or None if this module does
not know how to default that key. Independent of any particular
`values` / `know` -- see the module docstring for why: for a
plant-aware key this names the RULE ("plant knowledge X when set, else
Y"), not necessarily which branch a specific call took.
"""
try:
return _WHY.get(key)
except Exception:
return None
def _all_default_keys():
keys = list(_all_static_keys())
keys.extend(key for key, _v, _w in _STAGE_STATIC_DEFAULTS)
keys.extend(key for key, _fn in _DYNAMIC_DEFAULTS)
keys.extend(_stage_dynamic_keys())
return keys
# ------------------------------------------------------------------- the API
def _compute(values, know):
out = []
seen = set()
def emit(key, value_text, why):
if key in seen:
return
seen.add(key)
if not _is_blank(values.get(key)):
return
out.append((key, value_text, why))
for key, value_text, why in _STATIC_DEFAULTS:
emit(key, value_text, why)
for key, value_text, why in _STAGE_STATIC_DEFAULTS:
emit(key, value_text, why)
for key, fn in _DYNAMIC_DEFAULTS:
try:
result = fn(values, know)
except Exception:
result = None
if result is None:
continue
value_text, why = result
emit(key, value_text, why)
try:
stage_dyn = _stage_dynamic_defaults(values, know)
except Exception:
stage_dyn = []
for key, value_text, why in stage_dyn:
emit(key, value_text, why)
return out
def defaults_for(values, know=None):
"""[(key, value_text, why), ...] for every key whose current value in
`values` is blank ('' / None / missing) and that this module knows a
default for. `values` and `know` are read only, never mutated. Never
raises -- a `values` that is not a dict, or a `know` that is not a
dict, both degrade to []/an empty knowledge tree rather than an error.
"""
if not isinstance(values, dict):
return []
try:
return _compute(values, know if isinstance(know, dict) else {})
except Exception:
return []
def apply_defaults(values, know=None):
"""Fills the blanks `defaults_for` finds, IN PLACE on `values`, and
returns the same [(key, value_text, why), ...] it applied. A value
that is no longer blank by the time this writes it (nothing here
should cause that, but the check costs nothing) is left untouched --
this never overwrites a real value. Never raises: `values` that is not
a (mutable) dict returns [] and nothing is written.
"""
if not isinstance(values, dict):
return []
try:
found = defaults_for(values, know)
except Exception:
return []
applied = []
for key, value_text, why in found:
try:
if _is_blank(values.get(key)):
values[key] = value_text
applied.append((key, value_text, why))
except Exception:
continue
return applied
# --------------------------------------------------------------- _DECISIONS
#
# `design_criteria.analysis_beta_d` and `load_opt_offset_from` were checked
# against `pa_criteria`'s and `pa_structural`'s real editors while this file
# was being written and, at that point, neither editor's choice list
# actually contained the owner's own example value (a numeric-only editor
# for the first, `['Panel CG', 'Other']` for the second). Both editors were
# given real choice lists by another agent, concurrently, before this file's
# tests were finalised -- `design_criteria.analysis_beta_d` now offers
# `('Non-Sway', 'Sway')` and `load_opt_offset_from` now offers
# `('Deflection Section', 'Top in Form', 'Bottom in Form')` -- and both
# match the owner's original values exactly, so both are encoded as he
# specified. Left as a note because `choices_for()` reads the LIVE editors
# on every call: if either drifts again, `tests/test_pa_defaults.py::
# test_every_default_with_choices_is_a_valid_choice` fails and names the
# mismatch rather than this comment going stale silently.
#
# dev_mult / second_order Both already carry a DIFFERENT generic default
# in `panelanalysisform.PREFILL_DEFAULTS`
# ('1' and 'No', committed 2026-09-03) that runs
# unconditionally in `prefill()`, before this
# module's `apply_defaults()` would ever see the
# key -- so under the current call order neither
# of this module's defaults (1.3, 'Yes') can ever
# actually fire; `values[key]` is already
# non-blank by the time `apply_defaults` looks at
# it. Encoded as the owner specified anyway
# (2026-09-04 postdates the 2026-09-03 seed), but
# this is a live policy conflict between the two
# tables, not a bug in either -- flagged for
# whoever wires `apply_defaults()` into `prefill()`
# to resolve by call order or by retiring the
# older seed.
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""Panel Analysis: Top/Bottom Elevation and in-place Restraints, PURE.
Owner, 2026-09-04 (WPA655's morning run came up blank on `top_elevation_ft`,
`bottom_elevation_ft` and `restraints` -- see runlog): Eriksson's Concrete
Extents page carries Top Elevation / Bottom Elevation, and the Structural
Model page's in-place restraints are typically the same embed positions
`supports_ft` already found, just reported as elevations instead of a
distance up from the lower end. Both are arithmetic over numbers the caller
already measured -- no Revit API here, so this module is host-tested like
`pa_supports` / `pa_lifters` / `pa_reinf`, and the live half (reading a
framing element's world-space bounding box, in `script.py`'s `_read_
elevations`) stays a thin Revit-touching wrapper around it.
WHERE THE ELEVATION NUMBER COMES FROM. `elevations_from_bbox` takes the
framing element's AXIS-ALIGNED WORLD bounding box Z extent
(`el.get_BoundingBox(None)`), already in decimal feet the way every Revit Z
coordinate is -- the same "project elevation" a schedule or a section view
shows for anything placed in the model, because Revit's internal origin
already IS the datum those numbers are measured from. This is the piece AS
MODELLED, not a hypothetical erected attitude the way `pa_supports` /
`liftexport.erected_orientation` derive one for a piece that is still lying
flat on paper -- a wall panel placed in a real building model, the way this
tool's precast jobs use Revit, is modelled standing up in place, so its own
bounding box IS its in-place elevation. A piece not actually modelled
in-place (still on the casting-bed sheet, or a horizontal working view) gives
a bounding box that is NOT an elevation, and this module cannot tell the
difference -- callers name that limitation in the basis text they build
around this, never silently here.
WHERE RESTRAINTS COME FROM. `restraints_from_supports` re-expresses the same
`supports_ft` positions `pa_supports.read_supports` already measured (feet
from the erected-lower end) as elevations, adding the panel's own
`bottom_elevation_ft`. It is NOT a second measurement -- a plant that names
no support families leaves `supports_ft` blank, and this function then has
nothing to convert and returns `(None, why)` rather than fabricating an
elevation from zero.
Written for IronPython 2.7: no f-strings, no pathlib, `'{0}'.format(...)`
throughout.
"""
# pylint: disable=invalid-name
def elevations_from_bbox(min_z_ft, max_z_ft, length_ft=None, tol_ft=0.1):
"""(top_ft, bottom_ft, height_ft, basis) off a world-space bounding box.
`min_z_ft` / `max_z_ft` are the two Z extents of the element's own
bounding box (`el.get_BoundingBox(None)`), in any order -- the smaller
becomes `bottom_ft`, the larger `top_ft`. Either missing (`None`) is the
could-not-read answer: `(None, None, None, why)`, never a fabricated
elevation.
`length_ft`, when given, is compared against the measured height
(`top_ft - bottom_ft`): for a wall panel modelled standing up, the two
should agree, and a real gap larger than `tol_ft` is named in the basis
rather than silently absorbed -- it usually means the piece is not
modelled fully upright, or `length_ft` was measured along a different
axis than the one that is actually vertical here.
"""
if min_z_ft is None or max_z_ft is None:
return None, None, None, ('could not read a world-space bounding '
'box on this piece')
bottom = round(min(min_z_ft, max_z_ft), 4)
top = round(max(min_z_ft, max_z_ft), 4)
height = round(top - bottom, 4)
basis = ('the framing element\'s own world-space bounding box, Z {0:.2f}'
' to {1:.2f} ft -- the piece AS MODELLED, not a hypothetical '
'erected attitude'.format(bottom, top))
if length_ft is not None:
try:
gap = abs(height - float(length_ft))
except (TypeError, ValueError):
gap = None
if gap is not None and gap > tol_ft:
basis += ('; this differs from the measured length ({0:.2f} ft) '
'by {1:.2f} ft -- this piece may not be modelled '
'fully upright'.format(float(length_ft), gap))
return top, bottom, height, basis
def restraints_from_supports(supports_ft_text, bottom_elevation_ft,
height_ft=None, length_ft=None,
stand_tol_ft=1.0):
"""(text, basis) turning `supports_ft` positions into elevations, or
`(None, why)` when there is nothing to convert.
`supports_ft_text` is the SAME comma-separated feet string `pa_supports.
read_supports` writes into `reads['supports_ft']` (feet from the
erected-lower end); `bottom_elevation_ft` is this panel's own bottom
elevation (`elevations_from_bbox`'s third return). Either blank/None, or
a token that will not parse as a number, comes back as `(None, why)` --
never a partial or guessed conversion. The output is comma-separated,
the same grammar `panelanalysisform.parse_supports` already reads back
for both `supports_ft` and `restraints`.
`height_ft` / `length_ft`, when both given, guard against a piece that
does NOT stand with its length vertical -- a flat slab's world-space
bounding-box height is its THICKNESS, not its length, and adding a
`supports_ft` position (measured along the length) to that height would
not be an elevation at all. A gap bigger than `stand_tol_ft` between the
two skips the conversion and says why, rather than emitting a number
that looks like an elevation but mixes two different axes.
"""
if height_ft is not None and length_ft is not None:
try:
gap = abs(float(height_ft) - float(length_ft))
except (TypeError, ValueError):
gap = None
if gap is not None and gap > stand_tol_ft:
return None, (
'this piece\'s bounding-box height ({0:.2f} ft) does not '
'match its length ({1:.2f} ft) -- it does not appear to '
'stand with its length vertical, so supports_ft is not a '
'vertical offset here; restraints left blank rather than '
'converting a horizontal position into an elevation'
.format(float(height_ft), float(length_ft)))
if not supports_ft_text or bottom_elevation_ft is None:
missing = []
if not supports_ft_text:
missing.append('supports_ft')
if bottom_elevation_ft is None:
missing.append('bottom_elevation_ft')
return None, ('nothing to convert -- {0} not read'.format(
' and '.join(missing)))
positions = []
for tok in str(supports_ft_text).replace(',', ' ').split():
try:
positions.append(float(tok))
except (TypeError, ValueError):
return None, ('supports_ft carried a value that is not a '
'number ({0!r}); restraints could not be '
'converted'.format(tok))
if not positions:
return None, 'supports_ft parsed to no positions'
try:
bottom = float(bottom_elevation_ft)
except (TypeError, ValueError):
return None, 'bottom_elevation_ft is not a number'
elevations = sorted(round(bottom + p, 4) for p in positions)
text = ', '.join('{0:.2f}'.format(e) for e in elevations)
basis = ('the support embed positions ({0}) converted to elevations off '
'this panel\'s own bottom_elevation_ft ({1:.2f} ft) -- the '
'same embeds, not a second measurement'.format(
supports_ft_text, bottom))
return text, basis
+674
View File
@@ -0,0 +1,674 @@
# -*- coding: utf-8 -*-
"""Panel Analysis input form -- the "Concrete Extents" section, laid out.
The owner has said three times the Panel Analysis form is too ugly to read
("太丑了", "你这么输入用户上哪儿能看懂") and ruled, 2026-09-02: "全部给我做成
普通人能看懂的样子" -- every part of it has to be something an ordinary
person, not just an engineer, can understand. The fix agreed on is to make
the form look like Eriksson Wall's own printed page for this section (a solid
wall panel's "Concrete Extents" page: Panel Geometry, an Openings / Reveals /
Solid Zones table, and a drawings row) but with a plain-English `label`
beside every engineering `symbol` and a one-sentence `hint` on every field --
never the symbol alone, standing in as if it were the explanation.
`page(values, reads=None)` returns `(blocks, problems)`: a flat list of block
dicts from a fixed vocabulary a generic WPF renderer (built separately, not
here) consumes, plus a list of plain-English sentences for anything that
could not be laid out. This module never raises -- a field it cannot place
becomes a `problems` entry, never a traceback the owner has to read a
Python stack to understand.
WHY THIS FILE OWNS NO Revit, NO WPF AND NO `panelsketch` IMPORT: it has to
load and run on plain CPython under `tests/conftest.py`'s trick (see that
file's docstring) the same way the rest of Revit-free `vibe` does, and it has
to run under IronPython 2.7 inside the button. `panelsketch` is named by
STRING in a `drawing` block's `source` field -- the renderer calls it, not
this module -- because a sibling agent is extending `panelsketch` at the same
time this file is being written, and importing a module mid-rewrite is how
two agents' work collides.
WHY `values['openings_rows']` IS READ DEFENSIVELY: another agent owns
`vibe.panelanalysisform`, the source of the form's key names, and is
restructuring it at the same time this file is being written. A key this
module expects that is not actually there is "not present", never treated as
a bug -- `_paf_field` returns `None` and the caller falls back rather than
raising. The same discipline applies to the structured opening rows a third
agent is meant to be adding at `values['openings_rows']` (shaped
`{'type', 'pos_along', 'pos_along_from', 'length', 'pos_cross',
'pos_cross_from', 'width', 'pos_vert', 'height', 'label'}`, per that agent's
own contract): when it is not there yet, or is not a list, this module falls
back to the raw `values['openings']` text field and SAYS SO in `problems`,
rather than guessing at a shape that has not landed.
WHY THE DRAWING AREA NO LONGER PRINTS ERIKSSON'S OWN SECTION PROPERTIES ROWS
(Area, Width, Height, Cx, Cy, Izz, Iyy, Izy, Sb, St) OR A SIDE/FRONT
ELEVATION SPLIT: the owner ruled, 2026-09-03, that Concrete Extents "does
not need all those parameters -- Area, Width and so on. In that section
just draw the member's concrete edge. If there are openings, say so, and
add a few dimension lines that locate the openings on the drawing." That
replaced the old three-column drawings row (Side Elevation / Front
Elevation / Section Properties, all built on `panelsketch.elevation()`,
whose own docstring says it draws the front and side views together as ONE
picture -- so "Side Elevation" and "Front Elevation" used to be the same
combined image under two captions, a real limitation this module used to
paper over with a `problems` sentence rather than a fix) with exactly two
drawings and one sentence: `panelsketch.extents_outline()` (the panel's own
length x width edge, two overall dimensions, and any openings drawn to
scale with a locating dimension chain) and `panelsketch.extents_section()`
(the width x thickness edge, a thickness dimension, and the Sandwich wythe
layers when the section is one), plus `_openings_summary_sentence()`'s one
line: "N opening(s), M reveal(s)" or "No openings or reveals." Both new
`panelsketch` functions omit what `elevation()` / `section()` draw that this
page has no data of its own for -- supports, a side view, section
properties, and (the concrete reason the drawing "looked like garbage") the
strand dots and labels `section()` always draws when `values['strand_rows']`
happens to be present, which it can be even here since `values` is the
WHOLE form's dict, not a slice scoped to this page.
WHY THE TWO DRAWINGS ARE NOW STACKED, FULL WIDTH, WITH A DRAGGABLE CUT LINE
(owner ruling, 2026-09-04): "concrete extent 那个地方,panel outline 的图放
的更大一些。然后 cross section 跟它做成上下并列,而不是左右并列。panel
outline 上面做一个 cut-line,用户可以拖动,然后 cross section 动态加载,
显示当前的 cross-section,跟做核磁一样" -- the outline drawing gets bigger,
the section moves BELOW it (not beside it) so the outline is never squeezed
to make room for a companion, and a draggable cut line on the outline picks
which along-length station the section below it shows -- "like an MRI",
scrubbing through slices. And generally: "画图的时候,panel 的长度总要占满
整个显示窗口的宽度,因为不管什么板,你总是横着画" -- the panel's length
always fills the full width of whatever window it is drawn in, because a
panel is always drawn lying on its side, whatever its actual proportions.
The two `columns`-wrapped, fixed-pixel (`w`/`h`) `drawing` blocks this page
used to emit are gone; `_extents_drawing_blocks()` below emits two
`full_width: True` blocks instead, each carrying `aspect` (the drawing's own
height/width ratio, so the renderer can give it exactly the full column
width and no more) rather than a baked-in pixel size, plus `module:
'pa_sketch_extents'` naming the sibling module that owns the actual drawing
functions (`outline()` / `section()`) -- NOT `panelsketch` any more, because
`panelsketch.extents_outline()` / `extents_section()` do not know about a
cut line or a station, and teaching them would mean growing the OLD
Structural Model drawings' shared module for a Concrete-Extents-only idea.
`pa_sketch_extents` calls straight into `panelsketch`'s existing helpers
(`extents_outline()` for the base outline picture, `_parse_opening_row()` for
the section's opening/reveal geometry) rather than re-deriving them -- see
that module's own docstring. The outline block also carries `cutline: {'key':
'_cut_frac', 'default': 0.5}`, telling the renderer which form value the
draggable line reads and writes (a plain 0..1 fraction of the length, stored
as text like every other field this form has), and both blocks carry
`depends_on`, the list of `values` keys a redraw should watch -- the section
block's list starts with `'_cut_frac'` itself, because dragging the line is
the whole point of the section being drawn separately from the outline in
the first place.
Miters get the same "print a sentence, never leave a blank" discipline
Eriksson uses on its own page (an unchecked "Left Miter" box there is not
just left bare -- it prints "Left miter not included in design" underneath).
`_miter_sentence` reproduces exactly that: a miter left BLANK (unanswered) is
silent, because blank means "not yet decided", never "no" (same rule
`panelanalysisform._parse_bool` documents for its own checkboxes); a miter
answered explicitly "no" prints the sentence, in words, the way Eriksson
does.
"""
# `panelanalysisform` carries no Revit import at module scope (see its own
# docstring), so this succeeds on plain CPython under `tests/conftest.py`'s
# trick and under IronPython 2.7 alike. If the sibling agent restructuring it
# has, at some moment, left it unimportable, this module still has to load --
# so the import itself is guarded, and every reader of `paf` below is guarded
# again on top of that (see `_paf_field`).
try:
from vibe import panelanalysisform as paf
except ImportError:
paf = None
# `feet_str` (decimal feet -> '8\'-4 1/2"') is pure standard-library, no
# Revit -- shared with `vibe.pa_handling`, which needed the identical
# rounding for its own kv/table cells (2026-09-05); kept as `_feet_str` here
# so every existing caller in this file needs no rewrite.
from vibe.pa_units import feet_str as _feet_str # noqa: E402
# --------------------------------------------------------------- utilities
def _num(value):
"""`value` (a form string, or already a number) as a float, or None.
None covers "blank", "not a number", and "not present" alike -- the
caller only ever needs to know whether there is a usable number, and
`validate()`-style error text is not this module's job (the button's
completeness gate already owns that; this module only lays out what it
is given).
"""
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _fmt_num(value):
"""A float as the plain string this page prints -- '8', not '8.0'."""
if value is None:
return ''
if float(value) == int(value):
return str(int(value))
return ('%.4f' % value).rstrip('0').rstrip('.')
def _dim_or_dash(value):
"""A table cell for one dimension: Eriksson's own '-' for blank."""
if value is None:
return '-'
if isinstance(value, str):
value = value.strip()
return value if value else '-'
text = _feet_str(value)
return text if text is not None else '-'
def _from_word(word):
"""'start' -> 'Start'; blank/None -> ''. Never invents a datum word."""
if not word:
return ''
word = str(word).strip()
return word[:1].upper() + word[1:].lower() if word else ''
def _pos_cell(pos, frm):
"""A 'Position' / 'Horizontal Position' cell: '8" from Start', or '-'."""
dim = _dim_or_dash(pos)
if dim == '-':
return '-'
frm_text = _from_word(frm)
return '{0} from {1}'.format(dim, frm_text) if frm_text else dim
_TYPE_LABELS = {'opening': 'Opening', 'reveal': 'Reveal',
'solid zone': 'Solid Zone', 'solid_zone': 'Solid Zone'}
def _type_label(value):
"""The Type column's text -- a known word capitalised Eriksson's way,
or, for anything this table does not recognise, the entered text back
in title case rather than a guessed category -- echoing what was typed
is honest; picking the "closest" known type is not.
"""
if not value:
return '-'
key = str(value).strip().lower().replace('_', ' ')
if key in _TYPE_LABELS:
return _TYPE_LABELS[key]
return str(value).strip().title() if str(value).strip() else '-'
def _tri_bool(text):
"""(True/False/None) for a checkbox's raw text. None = "not yet said".
Mirrors `panelanalysisform._parse_bool`'s own rule (blank is
"unspecified", never "no") without importing that private helper across
a module boundary neither module owns the other's internals of.
"""
if text is None:
return None
t = str(text).strip().lower()
if not t:
return None
if t in ('y', 'yes', 'true', '1', 'on', 'include', 'included', 'checked'):
return True
if t in ('n', 'no', 'false', '0', 'off', 'exclude', 'excluded',
'unchecked'):
return False
return None
# --------------------------------------------------------- panelanalysisform
def _paf_field(key):
"""The raw field dict `panelanalysisform.FIELDS` has for `key`, or None.
None covers every way `paf` can fail to have the answer: the module
failed to import, it has no `FIELDS` (mid-restructure), or `key` is
simply not one it declares yet. All three are "not present", never an
error -- see the module docstring.
"""
if paf is None:
return None
try:
fields = paf.FIELDS
except AttributeError:
return None
try:
for f in fields:
if f.get('key') == key:
return f
except (TypeError, AttributeError):
return None
return None
def _field(key, editor, fallback_label, fallback_symbol, fallback_hint,
fallback_required, problems, choices=None, visible_when=None):
"""One `field` block, sourced from `panelanalysisform` when it has the
key and from the fallback text when it does not.
A missing key still gets a field on the page -- the Eriksson layout this
section is copying has a slot there -- but `problems` names it, because
a value typed into a field `panelanalysisform` does not declare cannot
be saved into the deck yet. That is the honest state of a form being
restructured by another agent at the same time, not a bug in this file.
"""
spec = _paf_field(key)
if spec is not None and paf is not None:
try:
label = paf.label_of(key)
symbol = paf.symbol_of(key) or None
hint = paf.help_of(key) or fallback_hint
required = bool(spec.get('required'))
except AttributeError:
spec = None
if spec is None:
label = fallback_label
symbol = fallback_symbol or None
hint = fallback_hint
required = fallback_required
problems.append(
"{0!r} is not a field vibe.panelanalysisform declares yet; "
"shown here to match Eriksson's own Concrete Extents page, but "
"a value typed into it will not be saved until that module "
"adds this key.".format(key))
block = {'kind': 'field', 'key': key, 'label': label, 'symbol': symbol,
'hint': hint, 'required': required, 'editor': editor,
'choices': choices}
if visible_when:
# `{'key': <values key>, 'in': [<value>, ...]}` -- the page shows
# this field only while `values[key]` is one of those (see
# `docs/pa-web-spec.md`, "visible_when"); absent = always shown.
block['visible_when'] = dict(visible_when)
return block
def _miter_sentence(values, key, side):
"""Eriksson's own discipline: an OFF miter prints a sentence, not a gap.
Blank (unanswered) prints nothing here, same as Eriksson's page shows
nothing extra for a miter nobody has touched yet -- only an explicit
"no" earns the sentence, because blank means "not yet decided", not
"excluded" (see `_tri_bool`).
`'quiet': True` (owner ruling, 2026-09-04): this is a footnote beside the
miter field it explains, not a finding -- it stays a `sentence` block (the
renderer already draws every `sentence` in grey italic, see `script.py`'s
`_render_sentence`) with the extra flag set so a renderer that later wants
to tell "a footnote beside a field" apart from "the openings summary
line" (the other `sentence` this page emits, deliberately left un-quiet)
can do so without guessing from the text.
"""
if _tri_bool(values.get(key)) is False:
return {'kind': 'sentence', 'quiet': True,
'text': '{0} miter not included in this panel\'s design.'
.format(side)}
return None
# ------------------------------------------------------------- sub-sections
def _panel_geometry_columns(values, problems):
"""The three columns Eriksson's own Panel Geometry block prints:
(Top Elevation, Bottom Elevation, Height, Section Type, Top in Form) |
(Width, Left Miter, Right Miter, Top In Form, Width, Height) |
(Thickness).
`length_ft` (this codebase's name for the panel's long dimension) is
what Eriksson calls "Height" on this page -- the same field, read
through `panelanalysisform`'s own label rather than renamed here, so a
reviewer sees the one name every section of the form uses for it.
"""
col1 = [
_field('top_elevation_ft', 'num', 'Top elevation', 'ft',
'How high the top edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('bottom_elevation_ft', 'num', 'Bottom elevation', 'ft',
'How high the bottom edge of the panel sits above the '
'project reference elevation. Leave blank if not yet '
'known.', False, problems),
_field('length_ft', 'num', 'Panel length', 'ft',
"The panel's long dimension, end to end. Required -- "
'everything downstream is measured against this span.',
True, problems),
_field('section_type', 'text', 'Section type', '',
'A short description of the cross-section (solid, '
'sandwich, ribbed, ...). Reference only.', False, problems),
_field('top_in_form', 'text', 'Which face is up in the casting '
'bed', 'Interior / Exterior',
'Which face of the panel faces up while it is cast. '
'Reference only.', False, problems,
choices=['Interior', 'Exterior'],
# Owner, 2026-09-07 (issue #22, item 3): "section type =
# solid 的时候,which face is up the casting bed 不应该存在"
# -- a Solid panel has no face that matters here, so the
# field is shown only while the section is Sandwich. The
# page hides/shows it live off `section_type` (see
# `docs/pa-web-spec.md`, `visible_when`).
visible_when={'key': 'section_type', 'in': ['Sandwich']}),
]
col2 = [
_field('width_ft', 'num', 'Panel width', 'ft',
"The panel's dimension across the span -- how wide it is. "
'Required.', True, problems),
_field('left_miter', 'choice', 'Left miter', '',
'Whether the left end of the panel is cut at an angle '
'(mitered) instead of square. Leave blank if this has '
'not been decided yet.', False, problems,
choices=['Yes', 'No']),
]
sentence = _miter_sentence(values, 'left_miter', 'Left')
if sentence:
col2.append(sentence)
col2.append(
_field('right_miter', 'choice', 'Right miter', '',
'Whether the right end of the panel is cut at an angle '
'(mitered) instead of square. Leave blank if this has '
'not been decided yet.', False, problems,
choices=['Yes', 'No']))
sentence = _miter_sentence(values, 'right_miter', 'Right')
if sentence:
col2.append(sentence)
# `top_in_form_check` / `top_in_form_width_in` / `top_in_form_height_in`
# (Eriksson's "top in form" STEP -- a change in formed thickness at
# the top edge) used to render here and are deliberately NOT rendered
# since 2026-09-07 (issue #22, item 4): nothing reads them -- not
# `pa_sketch_extents`, not the weight/centroid summary, not
# `panellift` -- and the owner's complaint was exactly a checkbox and
# two boxes that took input and changed nothing. They stay declared in
# `panelanalysisform.FIELDS` so a saved value survives, and return to
# this column the day the step is folded into the drawn section, the
# weight and the centroid. See `test_pa_extents.py`'s tombstone.
col3 = [
_field('thickness_in', 'num', 'Panel thickness', 'in',
'How thick the panel is. Required.', True, problems),
]
return [{'kind': 'heading', 'text': 'Panel Geometry', 'level': 2},
{'kind': 'columns', 'columns': [col1, col2, col3]}]
# --------------------------------------------------------- extents drawings
#
# The block dict shape below is a CONTRACT with the renderer sibling (owner
# ruling, 2026-09-04 -- see the module docstring's "WHY THE TWO DRAWINGS ARE
# NOW STACKED" note): every key here is read by name on the other side of
# that contract, so none of them is decorative and none may be renamed
# without breaking the renderer's half. `full_width: True` and `aspect`
# together replace the old fixed `w` / `h` pixel pair -- the renderer gives
# the drawing the whole column width and asks THIS module for a height via
# `aspect` (height/width), rather than this module baking in a pixel size
# that only happened to fit one window.
_OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX, _OUTLINE_ASPECT_DEFAULT = 0.12, 0.5, 0.3
_SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX, _SECTION_ASPECT_DEFAULT = 0.12, 0.35, 0.2
# Live-render finding, 2026-09-04: the outline's container was sized purely
# off `width_ft / length_ft`, with no allowance for the fixed pixel rows
# `pa_sketch_extents.outline()` draws OUTSIDE the panel rectangle itself --
# the title / cut-station label above it, the L label and its dimension
# chain below. With no extra room the panel's own bottom edge was pinned to
# the canvas bottom and the L label was clipped away entirely. This is a
# flat ADDITION to the clamped ratio (not folded into the clamp bounds
# above, which stay the panel's own true proportion) -- `outline_geometry()`
# is what actually reserves the matching top/bottom pixel margins inside
# whatever height this produces; see that function's own docstring.
_OUTLINE_LABEL_ROWS_ASPECT = 0.10
def _ratio_clamp(numerator, denominator, lo, hi, default):
"""`numerator / denominator`, clamped to `[lo, hi]` -- `default` when
either side is missing or the division is not a sane positive ratio
(zero/negative denominator, a numerator that is not a real number yet).
Never raises: an aspect ratio this module cannot compute yet still has
to be SOME number, because the renderer sizes the drawing's canvas off
it before this module ever gets a chance to draw anything into that
canvas -- there is no "wait for real geometry" option here the way
`sketch()` / `extents_outline()` have one (an empty `primitives` list)."""
if numerator is None or denominator is None or denominator <= 0.0 \
or numerator <= 0.0:
return default
ratio = numerator / denominator
if ratio < lo:
return lo
if ratio > hi:
return hi
return ratio
def _extents_drawing_blocks(values):
"""The outline + section `drawing` block pair, stacked (owner ruling,
2026-09-04): outline first, full width; section BELOW it, also full
width -- never side by side any more (see the module docstring). Both
name `module: 'pa_sketch_extents'`, the sibling module that owns
`outline()` / `section()`, the actual drawing functions -- this function
only builds the two block dicts the renderer reads to decide how big to
make each canvas and what to redraw it on, never draws anything itself.
"""
length_ft = _num(values.get('length_ft'))
width_ft = _num(values.get('width_ft'))
thickness_in = _num(values.get('thickness_in'))
thickness_ft = thickness_in / 12.0 if thickness_in and thickness_in > 0 \
else None
outline_aspect = _ratio_clamp(
width_ft, length_ft, _OUTLINE_ASPECT_MIN, _OUTLINE_ASPECT_MAX,
_OUTLINE_ASPECT_DEFAULT) + _OUTLINE_LABEL_ROWS_ASPECT
section_aspect = _ratio_clamp(
thickness_ft, width_ft, _SECTION_ASPECT_MIN, _SECTION_ASPECT_MAX,
_SECTION_ASPECT_DEFAULT)
outline_block = {
'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'outline',
'full_width': True, 'aspect': outline_aspect,
'caption': 'Panel outline -- drag the red cut line to move the '
'section',
'cutline': {'key': '_cut_frac', 'default': 0.5},
# Names `pa_sketch_extents.outline_geometry` so the renderer's
# `_add_cutline()` can place the draggable line's x at
# `x0 + frac*(x1-x0)` -- the same box `outline()`'s own cut marker
# uses -- instead of the bare canvas width. See that module's
# "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note.
'geometry': 'outline_geometry',
# 'opening' is the ROW TABLE prefix (`opening.<n>.<col>`) -- the
# renderer's `redrawDependents` treats a `depends_on` entry as
# either an exact key match or a `key + '.'` PREFIX match, so this
# one entry watches every cell of every opening row (add/remove
# included) the same way 'openings_rows' used to watch the old
# passthrough list. 'openings_rows' itself stays listed too, for a
# saved panel still carrying the legacy passthrough with no table
# rows at all.
'depends_on': ['opening', 'openings_rows', 'length_ft', 'width_ft'],
}
section_block = {
'kind': 'drawing', 'module': 'pa_sketch_extents', 'source': 'section',
'full_width': True, 'aspect': section_aspect,
'caption': 'Cross-section at the cut line',
'depends_on': ['_cut_frac', 'opening', 'openings_rows',
'thickness_in', 'width_ft', 'section_type',
'wythe_top_in', 'insulation_in', 'wythe_bottom_in'],
}
return [outline_block, section_block]
# Owner ruling, 2026-09-05: the Openings / Reveals / Solid Zones table is an
# EDITABLE ROW TABLE (`kind: 'editable_table'`, `table: 'opening'`), the same
# mechanism `pa_structural.py` uses for `restraint` / `conc_load` /
# `wind_zone` -- not a read-only dump of whatever `values['openings_rows']`
# happened to carry. Columns are the table's own simplified, ABSOLUTE-
# position vocabulary (`vibe.panelanalysisform.OPENING_COLS`): x_ft/length_ft
# along the panel's length (from the start), y_ft/width_ft across its width
# (from the bottom), depth_in for a Reveal's through-thickness cut only.
_OPENING_COLUMNS = [
{'col': 'type', 'label': 'Type', 'editor': 'choice',
'choices': ['Opening', 'Reveal', 'Solid zone']},
{'col': 'x_ft', 'label': 'Position along length (ft)', 'editor': 'num'},
{'col': 'length_ft', 'label': 'Length (ft)', 'editor': 'num'},
{'col': 'y_ft', 'label': 'Position across width (ft)', 'editor': 'num'},
{'col': 'width_ft', 'label': 'Width (ft)', 'editor': 'num'},
{'col': 'depth_in', 'label': 'Depth, reveals only (in)', 'editor': 'num'},
]
def _openings_blocks(values, problems):
"""The Openings, Reveals, and Solid Zones heading + editable table.
Always builds the table -- an editable row table has somewhere to add a
first row even when `values` carries none yet, unlike the old read-only
dump this replaced, which had nothing to show and no way to add
anything when `openings_rows` came up empty or absent. `problems` is
accepted for the same signature every other block-builder in this
module uses; nothing in this function raises one today.
"""
blocks = [{'kind': 'heading', 'text': 'Openings, Reveals, and Solid '
'Zones', 'level': 2}]
blocks.append({'kind': 'sentence', 'quiet': True,
'text': 'One row per opening, reveal or solid zone. '
'Position and length are measured from the '
"panel's own start (length) and bottom (width); "
'Depth applies to a Reveal only -- leave it '
'blank for an Opening (always full depth) or a '
'Solid Zone.'})
blocks.append({'kind': 'editable_table', 'table': 'opening',
'columns': _OPENING_COLUMNS, 'add_label': 'Add row'})
return blocks
# `_TYPE_LABELS` above already names the three row types this page's own
# Openings/Reveals/Solid Zones TABLE recognises; the one-line drawing-area
# summary counts only the first two by name, per the owner's own wording
# (2026-09-03): "say so" means "N opening(s), M reveal(s)", not a count of
# every row type the table happens to carry.
def _opening_table_type_rows(values):
"""[{'type': text}, ...] read LIVE off the `opening` row table (owner
ruling, 2026-09-05), one dict per present row -- or `None` when
`panelanalysisform` cannot be reached (import failure) or the table
carries no present rows at all, so the caller can fall back to whatever
legacy `openings_rows` / `openings` text this `values` still carries
(a panel saved before this table existed). Reading the table live here,
rather than the `openings_rows` PASSTHROUGH_KEYS snapshot `prefill` took
at spec-build time, is what keeps this summary sentence agreeing with
the table after an edit + save + reload -- the passthrough key is never
updated by a row edit, only the table cells are.
"""
if paf is None:
return None
try:
rows = paf.rows_of(paf.OPENING_TABLE, values, paf.OPENING_COLS)
except AttributeError:
return None
if not rows:
return None
return [{'type': r.get('type') or ''} for r in rows]
def _openings_summary_sentence(values):
"""The ONE line of text the drawing area prints in place of the old
parameter dump: 'N opening(s), M reveal(s)', or 'No openings or
reveals.' when there are none. Counts only rows whose `type` is
'Opening' or 'Reveal' (case-insensitive) -- a Solid Zone is not an
opening and the owner's own phrasing names only the two.
Prefers the LIVE `opening` row table (`_opening_table_type_rows`); when
that table carries no rows (or `panelanalysisform` cannot be reached),
falls back to the legacy `values['openings_rows']` list, and then to the
raw `openings` text field -- the same three-deep fallback `validate()`
itself uses, so this line and the Run button never disagree about which
source is live.
"""
values = values or {}
rows = _opening_table_type_rows(values)
if rows is None:
rows = values.get('openings_rows')
if not isinstance(rows, list):
raw = values.get('openings')
raw = raw.strip() if isinstance(raw, str) else \
(str(raw).strip() if raw else '')
if raw:
return ('Openings/reveals were entered as plain text, not the '
'structured rows this line needs to count: '
'{0!r}'.format(raw))
return 'No openings or reveals.'
n_open = 0
n_reveal = 0
for row in rows:
if not isinstance(row, dict):
continue
key = str(row.get('type') or '').strip().lower().replace('_', ' ')
if key == 'opening':
n_open += 1
elif key == 'reveal':
n_reveal += 1
if n_open == 0 and n_reveal == 0:
return 'No openings or reveals.'
def _plural(n, word):
return '{0} {1}{2}'.format(n, word, '' if n == 1 else 's')
return '{0}, {1}.'.format(_plural(n_open, 'opening'),
_plural(n_reveal, 'reveal'))
# ------------------------------------------------------------------ page()
def page(values, reads=None):
"""(blocks, problems) for the Concrete Extents section.
`values` is the form's current string-valued dict (plus, where a
sibling agent has landed it, the structured `openings_rows` list -- see
the module docstring). `reads` is what the button read straight off the
live framing element, the same shape `panelanalysisform.prefill` takes;
it is accepted for symmetry with the other section builders but not
read here -- everything this section needs is already folded into
`values` by the time `page` sees it, and reading `reads` again here
would risk showing a MODEL value beside a form value the user has since
typed over it, exactly the kind of stale-looking page the owner has
complained about.
Never raises. Every field this module cannot place is a `problems`
sentence, not a traceback -- see the module docstring for the two
running examples (a `panelanalysisform` key that is not there yet, and
an `openings_rows` shape that has not landed yet).
"""
values = values if values is not None else {}
problems = []
blocks = [{'kind': 'heading', 'text': 'Concrete Extents', 'level': 1}]
blocks.extend(_panel_geometry_columns(values, problems))
blocks.append({'kind': 'space', 'h': 8})
blocks.extend(_openings_blocks(values, problems))
blocks.append({'kind': 'space', 'h': 8})
# Just the member's own concrete edge, plus its openings -- not the
# Area/Width/Height parameter dump the owner ruled out, 2026-09-03:
# "Concrete Extents does not need all those parameters ... In that
# section just draw the member's concrete edge. If there are openings,
# say so, and add a few dimension lines that locate the openings." And,
# 2026-09-04: the outline drawing bigger, full width; the section BELOW
# it (not beside it), full width too, reading a draggable cut line off
# the outline -- see `_extents_drawing_blocks()` and the module
# docstring's "WHY THE TWO DRAWINGS ARE NOW STACKED" note for why this
# is two `full_width` blocks naming `pa_sketch_extents`, not the old
# side-by-side `columns` pair naming `panelsketch.extents_outline` /
# `extents_section` directly.
blocks.extend(_extents_drawing_blocks(values))
blocks.append({'kind': 'sentence', 'text': _openings_summary_sentence(values)})
return blocks, problems
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
# -*- coding: utf-8 -*-
"""Panel Analysis: pre-fill the Handling page's Face / Edge lifter fields off
what Panel Lift already measured on the assembly.
`liftexport.export()` already reads every lifter on the piece into
`job['panel']['lifters']` -- family, mark, `x` (ft along the panel LENGTH,
from the end that ends up lower), `y` (ft across the WIDTH), `on_edge`, and
more. The Handling page's `face_lifters` / `edge_lifters` text fields
(`panelanalysisform.FIELDS`) were never pre-filled from that even though the
data was sitting right there -- this module is the missing hop.
FACE VS EDGE is the plant's own grouping, read out of `knowledge` the same
way the rest of the tool reads it:
handling.lifter_groups.face.families -- `knowledge._is_face_lifter`
handling.lifter_groups.edge_top.families -- substring match, same way
A lifter is FACE when `knowledge._is_face_lifter` says its family is one.
Otherwise it is EDGE when either the model already measured it `on_edge`, or
its family matches `edge_top.families`. A family that matches neither list
and was not measured on an edge is a family the plant has not grouped --
`liftexport._group_of` calls the same case out as `ungrouped` -- and this
module treats it the same way: it is reported in `why`, and written to
neither field, rather than guessed at.
Nothing here touches Revit or the live document -- it is pure formatting
over data `liftexport.export()` already produced, exactly like
`panelanalysisform.parse_strand_rows` is pure over strand text.
"""
from vibe import knowledge
def _frac(y, width_ft):
"""y / width_ft, or None when either is missing or width is zero."""
if y is None or not width_ft:
return None
try:
return float(y) / float(width_ft)
except (TypeError, ValueError, ZeroDivisionError):
return None
def _is_edge_top_family(know, family_name):
"""Same substring match as `knowledge._is_face_lifter`, against
`handling.lifter_groups.edge_top.families` instead of `.face.families`.
"""
up = (family_name or '').upper()
families = knowledge.get(
know, 'handling.lifter_groups.edge_top.families', []) or []
for fam in families:
token = knowledge.as_text(fam).strip().upper()
if token and token in up:
return True
return False
def _classify(know, lifter):
"""'face' / 'edge' / None for one lifter dict, per the module docstring."""
family = lifter.get('family')
if knowledge._is_face_lifter(know, family):
return 'face'
if lifter.get('on_edge') or _is_edge_top_family(know, family):
return 'edge'
return None
def _fmt_rows(rows):
"""rows: list of (x_ft, frac) -> one '{x:.2f} @ {frac:.2f}' per line,
sorted by x then frac. Empty input -> ''."""
ordered = sorted(rows, key=lambda r: (r[0], r[1]))
return '\n'.join('{0:.2f} @ {1:.2f}'.format(x, frac)
for x, frac in ordered)
def lifter_lines(lifters, width_ft, know):
"""(face_text, edge_text, why) off a list of Panel Lift lifter dicts.
One line per lifter -- '{x:.2f} @ {frac:.2f}' where frac = y / width_ft,
lines sorted by (x, frac). Several lifters sharing an x is a row (a face
pair, an edge group) and is expected -- it produces one line per lifter,
not one per row.
A lifter whose x or y did not measure (either is None), or whose frac
could not be computed (no width), is skipped -- not placed on a guess --
and counted in `why` rather than silently dropped. A lifter whose family
matches neither the face nor the edge_top group is also left off both
texts and counted in `why`, by family name, the same way
`liftexport.export`'s own `ungrouped` problem names one.
`why` is one sentence naming how many face / edge lifters came from which
families, plus what was skipped.
"""
lifters = lifters or []
face_rows = []
edge_rows = []
face_families = set()
edge_families = set()
unclassified = set()
skipped = 0
for lifter in lifters:
x = lifter.get('x')
y = lifter.get('y')
frac = _frac(y, width_ft)
if x is None or frac is None:
skipped += 1
continue
group = _classify(know, lifter)
family = lifter.get('family')
if group == 'face':
face_rows.append((x, frac))
face_families.add(family)
elif group == 'edge':
edge_rows.append((x, frac))
edge_families.add(family)
else:
unclassified.add(family)
face_text = _fmt_rows(face_rows)
edge_text = _fmt_rows(edge_rows)
def _plural(n):
return '' if n == 1 else 's'
sentence = (
'{0} face lifter{1}{2} and {3} edge lifter{4}{5} read off the '
'assembly'.format(
len(face_rows), _plural(len(face_rows)),
' ({0})'.format(', '.join(sorted(f for f in face_families if f)))
if face_families else '',
len(edge_rows), _plural(len(edge_rows)),
' ({0})'.format(', '.join(sorted(f for f in edge_families if f)))
if edge_families else ''))
extras = []
if unclassified:
extras.append(
'{0} lifter{1} matched neither the face nor the edge_top group '
'({2})'.format(len(unclassified), _plural(len(unclassified)),
', '.join(sorted(f for f in unclassified if f))))
if skipped:
extras.append(
'{0} lifter{1} skipped (no measured position)'.format(
skipped, _plural(skipped)))
why = sentence
if extras:
why += '; ' + '; '.join(extras)
why += '.'
return face_text, edge_text, why
# How close to an END (x near 0 or near length_ft) still counts as 'top' /
# 'bottom' rather than a mid-length edge lifter -- half a foot, the same
# order of magnitude `liftexport.rows_of`'s own row-grouping tolerance uses
# for "close enough to be the same station".
_END_TOL_FT = 0.5
# How close to a SIDE (frac near 0 or 1) still counts as 'left' / 'right'.
_SIDE_TOL_FRAC = 0.05
def _edge_direction(x, frac, length_ft):
"""'top' / 'bottom' / 'left' / 'right' for one EDGE lifter, or None when
it sits at neither an end nor a side closely enough to call.
END wins over SIDE when both could apply (a true corner lifter): the
piece's own two ends are the more specific fact, and this module's
whole reason for existing is a wall whose edge lifters sat at the END
(x = length) and were being reported as generic 'edge' with no end/side
distinction at all.
"""
if length_ft:
try:
if x is not None and abs(x - 0.0) <= _END_TOL_FT:
return 'bottom'
if x is not None and abs(x - length_ft) <= _END_TOL_FT:
return 'top'
except TypeError:
pass
if frac is not None:
if abs(frac - 0.0) <= _SIDE_TOL_FRAC:
return 'left'
if abs(frac - 1.0) <= _SIDE_TOL_FRAC:
return 'right'
return None
def edge_lifter_lines(lifters, width_ft, length_ft, know):
"""(top_text, side_text, why) -- the same EDGE lifters `lifter_lines`
already finds, split again by which edge they are really on.
Same 'x @ frac' grammar and (x, frac) sort as `lifter_lines`; `top_text`
carries the 'top' + 'bottom' lifters, `side_text` the 'left' + 'right'
ones. An edge lifter whose x/frac reads but calls neither direction
(`_edge_direction` returns None -- it sits at neither an end nor a side
within tolerance) is left off BOTH texts and counted in `why`, never
placed on a guess; it still appears in `lifter_lines`' own `edge_text`.
"""
lifters = lifters or []
top_rows = []
side_rows = []
top_dirs = set()
side_dirs = set()
uncalled = 0
for lifter in lifters:
x = lifter.get('x')
y = lifter.get('y')
frac = _frac(y, width_ft)
if x is None or frac is None:
continue
if _classify(know, lifter) != 'edge':
continue
direction = _edge_direction(x, frac, length_ft)
if direction in ('top', 'bottom'):
top_rows.append((x, frac))
top_dirs.add(direction)
elif direction in ('left', 'right'):
side_rows.append((x, frac))
side_dirs.add(direction)
else:
uncalled += 1
top_text = _fmt_rows(top_rows)
side_text = _fmt_rows(side_rows)
def _plural(n):
return '' if n == 1 else 's'
why = (
'{0} end (top/bottom) edge lifter{1}{2} and {3} side (left/right) '
'edge lifter{4}{5}, split from the edge lifters above by x within '
'{6:g} ft of a panel end or frac within {7:g} of 0/1'.format(
len(top_rows), _plural(len(top_rows)),
' ({0})'.format(', '.join(sorted(top_dirs))) if top_dirs else '',
len(side_rows), _plural(len(side_rows)),
' ({0})'.format(', '.join(sorted(side_dirs))) if side_dirs
else '',
_END_TOL_FT, _SIDE_TOL_FRAC))
if uncalled:
why += ('; {0} edge lifter{1} matched neither an end nor a side '
'closely enough to call'.format(uncalled, _plural(uncalled)))
why += '.'
return top_text, side_text, why
def read_lifters(job, know):
"""{'face_lifters', 'edge_lifters', 'top_edge_lifters',
'side_edge_lifters', 'lifters_basis', 'edge_direction_basis'} for the
Handling page, straight off `job['panel']['lifters']` -- Panel Lift
already measured every lifter on the assembly; this only reformats it
for the text fields.
`top_edge_lifters` / `side_edge_lifters` are the SAME edge lifters
`edge_lifters` already carries, split again by which edge they are
really on (see `edge_lifter_lines`) -- kept ALONGSIDE `edge_lifters`,
never replacing it, for whatever still reads the older undivided field.
Every one of the four text keys is omitted when its own text is empty;
`lifters_basis` / `edge_direction_basis` are always present alongside
them so a blank field still says why. `{}` when `job` is None or carries
no lifters at all, and defensively `{}` on any exception -- a pre-fill
that guesses wrong is worse than a blank field the owner fills by hand.
"""
try:
if not job:
return {}
panel = job.get('panel') or {}
lifters = panel.get('lifters')
if not lifters:
return {}
width_ft = panel.get('width_ft')
length_ft = panel.get('length_ft')
face_text, edge_text, why = lifter_lines(lifters, width_ft, know)
out = {'lifters_basis': why}
if face_text:
out['face_lifters'] = face_text
if edge_text:
out['edge_lifters'] = edge_text
top_text, side_text, edge_why = edge_lifter_lines(
lifters, width_ft, length_ft, know)
out['edge_direction_basis'] = edge_why
if top_text:
out['top_edge_lifters'] = top_text
if side_text:
out['side_edge_lifters'] = side_text
return out
except Exception:
return {}
def read_dunnage(job):
"""{'dunnage_positions_ft', 'dunnage', 'dunnage_basis'} off Panel Lift's
own job, or {}.
The support LINE positions `liftexport.dunnage_supports` already worked
out for whichever handling stage sits the piece down on dunnage instead
of hanging it from its lifters (`stage['supports']`, written only for
the stage(s) whose `pci_case` is a sit-down case -- today
'transportation' -- and only when the plant has configured
`handling.dunnage`; see that function's own docstring). Nothing here
re-derives a support position: it is read straight off the job Panel
Lift already built, the first stage that carries one.
`dunnage_positions_ft` is the raw list, feet from the panel's own x = 0,
ascending. `dunnage` is the SAME list formatted as the one-number-per-
line text `pa_sketch_handling._dunnage_positions` (and the Handling
page's own `dunnage` field) already parses -- so a plant that has
configured dunnage gets the field pre-filled instead of blank, and one
that has not gets neither key at all (not a guessed pair of quarter
points).
`{}` when `job` is None, no stage carries a `supports` list, or on any
exception.
"""
try:
if not job:
return {}
stages = job.get('stages') or []
xs = None
stage_name = None
for stage in stages:
supports = stage.get('supports')
if supports:
xs = sorted(round(float(x), 4) for x in supports)
stage_name = stage.get('name') or stage.get('pci_case') \
or 'stage'
break
if not xs:
return {}
return {
'dunnage_positions_ft': xs,
'dunnage': '\n'.join('{0:.2f}'.format(x) for x in xs),
'dunnage_basis': (
'{0} dunnage support position(s), feet from the panel\'s '
'own x = 0, read off Panel Lift\'s own job (handling.'
'dunnage, stage {1!r}) -- not re-measured here.'.format(
len(xs), stage_name)),
}
except Exception:
return {}
def parse_lifter_lines(text):
"""(rows, problems) -- one lifter per LINE, 'x_ft @ frac'.
All-or-nothing by line, the same discipline as
`panelanalysisform.parse_strand_rows`: any line that fails to parse
refuses the WHOLE field (`rows` comes back `[]`) rather than keeping the
lines that did parse -- a lifter schedule half-read is not a lifter
schedule. Blank text -> ([], []).
"""
rows = []
problems = []
if text is None:
text = ''
for i, raw in enumerate(text.splitlines(), 1):
line = raw.strip()
if not line:
continue
parts = [p.strip() for p in line.split('@')]
if len(parts) != 2:
problems.append(
"line {0}: expected 'x_ft @ frac', got {1!r}".format(
i, line))
continue
x_txt, frac_txt = parts
try:
x_ft = float(x_txt)
except (TypeError, ValueError):
problems.append(
'line {0}: x_ft is not a number ({1!r})'.format(i, x_txt))
continue
try:
frac = float(frac_txt)
except (TypeError, ValueError):
problems.append(
'line {0}: frac is not a number ({1!r})'.format(i, frac_txt))
continue
rows.append({'x_ft': x_ft, 'frac': frac})
if problems:
return [], problems
return rows, []
+747
View File
@@ -0,0 +1,747 @@
# -*- coding: utf-8 -*-
u"""Panel Analysis's WPF input form (前端), collapsed into ONE JSON document.
WHY THIS FILE EXISTS: the WPF renderer in `PanelAnalysis.pushbutton/script.py`
is being replaced by an HTML page rendered in WebView2 by another agent. That
agent does not write Python and does not read the five section modules
(`pa_criteria` / `pa_extents` / `pa_structural` / `pa_handling` /
`pa_reinforcement`) or the field model (`panelanalysisform`) directly -- this
module is the seam: it calls every `page(values, reads)` the way `script.py`
does, walks the block trees the way `script.py`'s `_render_block` dispatch
does, and turns the result into one `dict` that survives `json.dumps` with no
further Python involved. See `docs/pa-web-spec.md` for the exact JSON shape,
one example per block kind, the value/row-key contract, and the host<->page
message protocol built on top of this.
WHAT THIS MODULE IS NOT: it does not decide what a field means, what a
default is, or how a drawing is laid out -- all of that stays owned by
`panelanalysisform`, `pa_defaults`, and the four `pa_sketch_*` modules
respectively. `build_pagespec()` only serialises what those modules already
compute, the same "one seam, no new policy" discipline `script.py`'s own
renderer follows for the WPF side.
NO REVIT, EITHER PYTHON. Like every module it imports, this loads and runs
on plain CPython (`tests/conftest.py`'s trick, and `tools/pa_pagespec.py`'s
standalone CLI) and on IronPython 2.7 inside the button. No f-strings, no
`pathlib`, no `dataclasses`, no typing syntax; string formatting is
`'{0}'.format(...)`; every import is wrapped so a sibling module mid-edit (a
SyntaxError, not just an ImportError) degrades that one page to a named
`problems` entry rather than taking the whole spec down -- the same
discipline `script.py`'s `_load_section_module` already applies on the WPF
side, borrowed here rather than re-derived.
NEVER RAISES. `build_pagespec()` is meant to run with NO Revit and
sometimes with NOTHING typed in yet (the owner's scope note: the page must
open and run before Revit ever supplies a value) -- every lookup below is
defensive, and anything this module could not resolve becomes a `problems`
sentence, never a traceback.
THE TWO CALLING SHAPES:
build_pagespec(values, know) -- `values` already built (the
button's own `F.prefill()`
result, live from Revit).
build_pagespec(know=know, reads=reads) -- `values` is `None`; this
module builds it itself via
`panelanalysisform.prefill(
reads or {}, know)`, which
already applies every
`PREFILL_DEFAULTS` /
`DERIVED_FIELDS` / row-table
seed / `pa_defaults.
apply_defaults()` rule on an
EMPTY `reads` exactly as it
would on a real one -- so a
page opened with no Revit at
all still gets a complete,
defaulted form, not a blank
one.
Both shapes end up with a real `values` dict before anything else runs, so
every other rule below (row-table padding to `min_rows`, geometry
degrading to a placeholder rectangle, `choices` upgraded for a known
enumeration) applies identically whether Revit was ever in the loop.
"""
import json
# --------------------------------------------------------------- the pages
#
# (page id, display title, sibling module name) -- mirrors `script.py`'s own
# `SECTION_MODULES`, in the same order, so a reviewer can tell this is the
# same five sections, not a sixth invention.
PAGE_MODULES = (
('design_criteria', 'Design Criteria', 'pa_criteria'),
('concrete_extents', 'Concrete Extents', 'pa_extents'),
('structural_model', 'Structural Model', 'pa_structural'),
('handling', 'Handling', 'pa_handling'),
('reinforcement', 'Reinforcement', 'pa_reinforcement'),
)
# (drawing 'module', drawing 'source') -> the geometry function that module
# now exposes (see each `pa_sketch_*` module's own "real-unit geometry (ft)"
# section, added alongside this file). `pa_sketch_extents`'s 'outline' and
# 'section' sources share ONE geometry function: `section()`'s own picture is
# just the SAME outline+openings data cut at the current station, and
# `pa_sketch_extents.section_at()` is the general algorithm a page runs
# against that one geometry blob for any station -- there is no second,
# separate "section geometry" to compute up front. Extending this dict is
# the whole of what a sixth drawing needs on this side.
_GEOMETRY_FUNCS = {
('pa_sketch_extents', 'outline'): 'outline_geometry_ft',
('pa_sketch_extents', 'section'): 'outline_geometry_ft',
('pa_sketch_freebody', 'freebody'): 'freebody_geometry',
('pa_sketch_handling', 'lifter_dunnage'): 'lifter_dunnage_geometry',
('pa_sketch_reinf', 'section'): 'section_geometry',
('pa_sketch_reinf', 'outline'): 'outline_geometry',
}
_UNIT_RE = None # set lazily -- see `_split_unit`, kept off module import
# so a `re` compile failure (never seen, but this module's
# own rule is "no lookup may raise") cannot break import.
# ------------------------------------------------------------- basic safety
def _import_module(modname):
"""(module, None) or (None, exception) -- never raises.
Mirrors `script.py`'s `_load_section_module`: a sibling module mid-edit
(a SyntaxError, not just an ImportError while it does not exist yet) is
"not available", never a crash that takes every OTHER page down with it.
"""
try:
mod = __import__('vibe.' + modname, fromlist=[str(modname)])
return mod, None
except Exception as exc:
return None, exc
def _text_type():
try:
return (str, unicode) # Python 2
except NameError:
return (str,) # Python 3
_TEXT_TYPES = _text_type()
def _json_safe(obj):
"""`obj`, recursively coerced into something `json.dumps` accepts and a
JS reader gets back exactly what it expects: no tuples (-> lists), no
sets (-> sorted lists), no `NaN`/`Infinity` floats (-> `None`, the
honest "could not compute" answer this codebase uses everywhere else),
no non-string dict keys (-> `str(key)`). Never raises -- an object this
cannot recognise is returned as-is and left to `json.dumps` itself,
which raises a much clearer error than anything guessed here would.
"""
if obj is None or isinstance(obj, bool):
return obj
if isinstance(obj, _TEXT_TYPES):
return obj
if isinstance(obj, float):
try:
if obj != obj or obj in (float('inf'), float('-inf')):
return None
except Exception:
return None
return obj
if isinstance(obj, int):
return obj
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if not isinstance(k, _TEXT_TYPES):
k = str(k)
out[k] = _json_safe(v)
return out
if isinstance(obj, (list, tuple, set, frozenset)):
return [_json_safe(v) for v in obj]
return obj
def _split_unit(label):
"""('Elevation', 'ft') from 'Elevation (ft)'; (label, None) when there
is no trailing parenthetical. A display convenience only -- the label
text itself is never changed anywhere else in this module, this only
powers the `tables[*]['columns'][*]['unit']` field `docs/pa-web-spec.md`
documents. Never raises on a non-string label."""
if not label or not isinstance(label, _TEXT_TYPES):
return label, None
text = label.strip()
if text.endswith(')') and '(' in text:
i = text.rfind('(')
unit = text[i + 1:-1].strip()
base = text[:i].strip()
if unit and base:
return base, unit
return label, None
# --------------------------------------------------------- field vocabulary
def _choices_for(key):
"""`panelanalysisform.choices_for(key)`, or `None` on any failure --
same could-not-judge discipline every lookup in this module follows."""
try:
from vibe import panelanalysisform as F
return F.choices_for(key)
except Exception:
return None
def _field_spec(key):
try:
from vibe import panelanalysisform as F
return F._spec(key)
except Exception:
return None
_MULTI_VALUE_KINDS = ('supports', 'openings')
# Phrases a field's or matrix row's own `hint` uses to say the solve does
# not read it (the section modules already write one of these on every
# such control). A block whose hint carries one is stamped
# `unused_by_solve: true` so the page can mute it visibly -- the owner's
# question, 2026-09-07 (issue #22, item 2): "我现在选择 code,有意义吗?" A
# control that looks live and changes nothing is the thing this exists to
# stop; the hint text alone reaches only the collapsed Notes panel.
_UNUSED_HINT_PHRASES = ('not yet used by the solve', 'not used by the solve')
# Keys the solve does not read whose hint does not say so in those words.
# `design_spec`: no formula in `panellift` or `tools/panelanalysis.py`
# branches on the ACI edition (grep, 2026-09-07). The two miter flags
# only print a footnote sentence (`pa_extents._miter_sentence`);
# `top_in_form` (which face is up) is reference only. `section_type` is
# NOT here although its hint says "reference only": it drives the
# Sandwich-only fields' visibility and the wythe drawing, so muting it
# would misdescribe a control the page itself reacts to.
_UNUSED_BY_SOLVE_KEYS = frozenset(('design_spec', 'left_miter',
'right_miter', 'top_in_form'))
def _hint_says_unused(hint):
try:
text = (hint or '').lower()
except Exception:
return False
return any(phrase in text for phrase in _UNUSED_HINT_PHRASES)
def _sandwich_only_keys():
try:
from vibe import panelanalysisform as F
return tuple(getattr(F, 'SANDWICH_ONLY_KEYS', ()) or ())
except Exception:
return ()
def _normalize_field(block, ctx):
"""Mutates one `field` block dict in place: upgrades a plain text/num
editor to `choice` when `panelanalysisform` already knows a fixed list
for that key (mirrors `script.py`'s `_render_field`, so a page built
from this JSON offers the exact same dropdowns the WPF form does),
tags `multi_value` for a `supports`/`openings`-kind field (the field
model's OWN 'kind', which can disagree with the block's `editor` --
see `script.py`'s comment on `F._spec(key)['kind']` winning), and files
the key's choices (if any) into the shared `choices` dict."""
key = block.get('key')
editor = block.get('editor')
choices = block.get('choices')
if editor in (None, 'text', 'num') and key:
registry_choices = _choices_for(key)
if registry_choices:
editor = 'choice'
if not choices:
choices = list(registry_choices)
block['editor'] = editor
block['choices'] = list(choices) if choices else None
resolved = editor
if resolved not in _MULTI_VALUE_KINDS:
spec = _field_spec(key) if key else None
canonical = (spec or {}).get('kind')
if canonical in _MULTI_VALUE_KINDS:
resolved = canonical
block['multi_value'] = resolved in _MULTI_VALUE_KINDS
# Dependent visibility (issue #22, item 3): a Sandwich-only material /
# wythe field (`panelanalysisform.SANDWICH_ONLY_KEYS`, the field
# model's own declaration, which the retired WPF renderer honoured and
# the HTML page never did) is shown only while `section_type` is
# Sandwich. A block that already carries its own `visible_when` (the
# section module's ruling) keeps it.
if key and not block.get('visible_when') and key in _sandwich_only_keys():
block['visible_when'] = {'key': 'section_type', 'in': ['Sandwich']}
# "Not used by the solve" cue (issue #22, item 2) -- see
# `_UNUSED_HINT_PHRASES`. Never overrides a module's explicit False.
if 'unused_by_solve' not in block:
if (key in _UNUSED_BY_SOLVE_KEYS) or _hint_says_unused(
block.get('hint')):
block['unused_by_solve'] = True
if key:
ctx['field_keys'].add(key)
if block['choices']:
ctx['choices'][key] = list(block['choices'])
def _register_cell_choices(cell, ctx):
"""A `table` / `lc_table` / `matrix` cell dict -- files `choices[key]`
when the cell carries both, same as a `field` block does. Cells are
plain strings far more often than dicts; a non-dict cell is a no-op."""
if isinstance(cell, dict) and cell.get('key') and cell.get('choices'):
ctx['choices'][cell['key']] = list(cell['choices'])
def _register_table(block, ctx):
"""Files one `editable_table` block's shape into the shared `tables`
dict (`docs/pa-web-spec.md`'s `{name: {columns, min_rows, add_label}}`)
and each column's own `choices`, if any, under `'<table>.<col>'` --
a column's choices apply to every row of that column, not to one row's
key (which does not exist yet until a row is added), so this is the one
place `choices` is keyed by something other than a `values` key."""
name = block.get('table')
if not name:
return
colspecs = block.get('columns') or []
columns = []
for c in colspecs:
col = c.get('col')
label, unit = _split_unit(c.get('label'))
entry = {'key': col, 'label': c.get('label') or col,
'unit': unit, 'editor': c.get('editor')}
if c.get('choices'):
entry['choices'] = list(c['choices'])
ctx['choices']['{0}.{1}'.format(name, col)] = list(c['choices'])
columns.append(entry)
entry = {
'columns': columns, 'min_rows': block.get('min_rows') or 0,
'add_label': block.get('add_label') or 'Add row',
}
# `empty_state` (issue #22, items 5/11): what the page prints under a
# table with no rows -- `{'basis_keys': [...], 'text': ...}`, the first
# `basis[<key>]` sentence found wins, else `text`. Mirrored here so a
# page can find it without walking every block, same as `columns`.
if isinstance(block.get('empty_state'), dict):
entry['empty_state'] = dict(block['empty_state'])
ctx['tables'][name] = entry
# ------------------------------------------------------------- geometry
def _drawing_geometry(block, ctx):
"""Computes and files `ctx['geometry'][drawing_key]` for one `drawing`
block, and returns `drawing_key` so the caller can stamp it onto the
block. Never raises: a missing mapping, an unimportable module, a
missing function, or the geometry function itself raising all degrade
to a `{'problems': [...]}` stand-in -- a page must always find SOME dict
at `geometry[drawing_key]`, never a missing key."""
modname = block.get('module')
source = block.get('source')
drawing_key = '{0}.{1}'.format(ctx['page_id'], source or 'drawing')
if drawing_key in ctx['geometry']:
return drawing_key
fn_name = _GEOMETRY_FUNCS.get((modname, source))
if not fn_name:
ctx['geometry'][drawing_key] = {'problems': [
'no geometry function is registered for {0}.{1} yet; this '
'drawing cannot be built from data alone.'.format(
modname, source)]}
return drawing_key
mod, err = _import_module(modname)
if mod is None:
ctx['geometry'][drawing_key] = {'problems': [
"vibe.{0} could not be loaded ({1}).".format(modname, err)]}
return drawing_key
fn = getattr(mod, fn_name, None)
if fn is None:
ctx['geometry'][drawing_key] = {'problems': [
"'{0}' is not available in vibe.{1}.".format(fn_name, modname)]}
return drawing_key
try:
geo = fn(ctx['values'])
except Exception as exc:
geo = {'problems': ['{0} raised while computing geometry: {1!r}'
.format(fn_name, exc)]}
if not isinstance(geo, dict):
geo = {'problems': ['{0} returned something that was not a dict; '
'no geometry available.'.format(fn_name)]}
ctx['geometry'][drawing_key] = _json_safe(geo)
return drawing_key
# --------------------------------------------------------------- the walk
def _walk(block, ctx):
"""One block (or a list of blocks) -> the same shape, JSON-safe, with
every nested container recursed into and every field/table/drawing
registered into `ctx`. Mirrors `script.py`'s `_render_block` dispatch
one kind at a time, but SERIALISES instead of rendering -- nothing here
ever builds a WPF object, and nothing here ever raises: an exception
from a nested call is caught per-block, the same 'a bad block never
takes the page down' rule `script.py` follows, and becomes a `sentence`
block naming the failure in its place.
"""
if isinstance(block, list):
return [_walk(b, ctx) for b in block]
if not isinstance(block, dict):
return block
try:
out = dict(block)
kind = out.get('kind')
if kind == 'columns':
out['columns'] = [[_walk(b, ctx) for b in (col or [])]
for col in (out.get('columns') or [])]
elif kind == 'section':
out['blocks'] = [_walk(b, ctx) for b in (out.get('blocks') or [])]
elif kind == 'field':
_normalize_field(out, ctx)
elif kind in ('table', 'lc_table'):
rows = []
for row in (out.get('rows') or []):
new_row = []
for cell in row:
_register_cell_choices(cell, ctx)
new_row.append(cell)
rows.append(new_row)
out['rows'] = rows
elif kind == 'matrix':
rows = []
for row in (out.get('rows') or []):
row = dict(row) if isinstance(row, dict) else {'cells': []}
if 'unused_by_solve' not in row and _hint_says_unused(
row.get('hint')):
row['unused_by_solve'] = True
cells = []
for cell in (row.get('cells') or []):
_register_cell_choices(cell, ctx)
cells.append(cell)
row['cells'] = cells
rows.append(row)
out['rows'] = rows
elif kind == 'editable_table':
_register_table(out, ctx)
elif kind == 'drawing':
out['drawing_key'] = _drawing_geometry(out, ctx)
return _json_safe(out)
except Exception as exc:
return {'kind': 'sentence', 'quiet': False,
'text': '[block failed to serialise: {0!r}]'.format(exc)}
def _page_accepts_know(fn):
"""Whether `fn` (a page module's `page`) declares a `know` parameter --
checked by INTROSPECTION, not by calling it and catching `TypeError`,
because a `TypeError` raised from INSIDE a working `page()` call would
otherwise be silently reinterpreted as 'this module's signature is
older' and retried, masking a real bug as a compatibility fallback.
Both Pythons: `inspect.signature` (3.3+) first, `inspect.getargspec`
(still present on IronPython 2.7's CPython-2-shaped `inspect`, though
removed from CPython 3.11+) as the fallback. Any introspection failure
-- neither exists, or `fn` is not introspectable at all -- answers
`False`, the safe "call it the old way" direction.
"""
import inspect
try:
return 'know' in inspect.signature(fn).parameters
except (AttributeError, ValueError, TypeError):
pass
try:
spec = inspect.getargspec(fn) # noqa: E501 (py2/IronPython path)
return 'know' in spec.args
except Exception:
return False
def _load_page(page_id, title, modname, values, reads, know=None):
mod, err = _import_module(modname)
if mod is None:
return {'id': page_id, 'title': title, 'blocks': [],
'problems': ["vibe.{0} could not be loaded ({1}).".format(
modname, err)]}
try:
# `know` (the merged knowledge tree) is a NEWER, optional third
# argument -- today only `pa_handling.page` reads it (the Lifting
# Hardware table's Tension/Shear columns, `vibe.knowledge.
# lifter_capacity`). Every other page module still only accepts
# `(values, reads)`; `_page_accepts_know` decides which call shape
# to use so an older module is never handed an argument it does
# not declare.
if _page_accepts_know(mod.page):
blocks, page_problems = mod.page(values, reads, know=know)
else:
blocks, page_problems = mod.page(values, reads)
except Exception as exc:
return {'id': page_id, 'title': title, 'blocks': [],
'problems': ['page() raised: {0!r}'.format(exc)]}
return {'id': page_id, 'title': title, 'blocks': blocks or [],
'problems': list(page_problems or [])}
# ------------------------------------------------------------- provenance
def _basis_dict(values):
"""`values['_basis']` (keys like `'mark_basis'`, one sentence each,
written by `panelanalysisform.prefill()`) -> `{key: sentence}` with the
`_basis` suffix stripped, so the page can look a field's provenance up
by the SAME key it already uses for that field's value. Never raises on
a malformed `_basis`."""
raw = values.get('_basis') if isinstance(values, dict) else None
out = {}
if not isinstance(raw, dict):
return out
for key, sentence in raw.items():
if not isinstance(key, _TEXT_TYPES):
continue
clean = key[:-len('_basis')] if key.endswith('_basis') else key
out[clean] = sentence
return out
def _derived_dict():
"""`panelanalysisform.DERIVED_FIELDS` as `{key: {'sources': [...],
'formula': <name>}}` -- the page recomputes each derived field
client-side as a source is edited (issue #22, item 1), mirroring the
named formula (`concrete_modulus_psi`: Ec = 33 * wc^1.5 * sqrt(f'c),
rounded to whole psi, exactly as `prefill` does) until the user types
into the derived field itself. Empty on any failure: the page then
simply never recomputes, which is what it did before."""
out = {}
try:
from vibe import panelanalysisform as F
for key, spec in (getattr(F, 'DERIVED_FIELDS', None) or {}).items():
sources, fn = spec
out[key] = {'sources': list(sources),
'formula': getattr(fn, '__name__', str(fn))}
except Exception:
return {}
return out
def _problem_targets():
"""`panelanalysisform.LEGACY_TABLE_KEYS` -- legacy field key -> the row
table that replaced it, so a `validate()` problem still keyed by the
legacy name (`supports_ft`) lands on the table the user actually sees
(issue #22, item 11). Empty on any failure."""
try:
from vibe import panelanalysisform as F
return dict(getattr(F, 'LEGACY_TABLE_KEYS', None) or {})
except Exception:
return {}
def _defaults_why_dict(values, field_keys):
"""`{key: why}` from two sources, neither dropped:
1. `values['_defaults_applied']` -- the `[(key, value_text, why), ...]`
list `panelanalysisform.prefill()` records for defaults it actually
applied THIS run (a real value read off the model always wins, so a
key here means the default is what is actually showing).
2. `pa_defaults.why_of(key)`, for every field key this spec renders that
(1) did not already cover -- the RULE ("plant knowledge X when set,
else Y"), not necessarily the run's own choice, but still the honest
answer to "why would this ever be filled in" for a field the model
supplied a real value for and so never went through `apply_defaults`
at all.
Never raises: a malformed `_defaults_applied` entry is skipped, and a
`pa_defaults` that fails to import leaves (2) empty rather than losing
(1).
"""
out = {}
applied = values.get('_defaults_applied') if isinstance(values, dict) \
else None
for entry in (applied or []):
try:
key, _value_text, why = entry
except (TypeError, ValueError):
continue
if isinstance(key, _TEXT_TYPES):
out[key] = why
try:
from vibe import pa_defaults
for key in field_keys:
if key in out:
continue
why = pa_defaults.why_of(key)
if why:
out[key] = why
except Exception:
pass
return out
def _pad_table_rows(values, tables):
"""Adds blank rows, in place on `values`, until every table in `tables`
has at least its own `min_rows` present rows -- the CLI / no-Revit path
the owner asked for: a page opened with nothing typed yet still shows
each table's minimum shape, not zero rows. A `min_rows` of 0 (every
table today) makes this a no-op; it exists so a future page module that
DOES set `min_rows` gets the row padding for free, on Revit or off it,
the same way `script.py`'s `editable_table_widget` already pads on the
WPF side. Never raises -- a `panelanalysisform` that will not import
leaves every table exactly as `prefill()` (or the caller) built it.
"""
try:
from vibe import panelanalysisform as F
except Exception:
return
for name, meta in tables.items():
min_rows = meta.get('min_rows') or 0
if min_rows <= 0:
continue
cols = [c['key'] for c in meta.get('columns') or [] if c.get('key')]
if not cols:
continue
try:
ids = F.row_ids(name, values)
while len(ids) < min_rows:
F.add_row(name, values, cols, {})
ids = F.row_ids(name, values)
except Exception:
continue
# ------------------------------------------------------------------ public
def build_pagespec(values=None, know=None, reads=None):
u"""The whole Panel Analysis form as one JSON-serialisable `dict`.
`values` is `panelanalysisform.prefill()`'s own string-valued dict (plus
its structured passthrough keys, e.g. `openings_rows`) -- pass it when
the caller already built one (the live button always will). Pass `None`
to have this module build it itself, from `reads` (a live-model read
dict, or `{}`/`None` for "no Revit at all") and `know` (the merged
knowledge tree, or `None`/`{}` for "no plant layer") -- see the module
docstring's "THE TWO CALLING SHAPES". Either way the result is a COPY;
the caller's own `values` dict (if it passed one) is never mutated.
Returns:
{'schema': 1, 'title': 'Panel Analysis', 'mark': str,
'pages': [{'id', 'title', 'blocks': [...], 'problems': [...]}, ...],
'values': {key: str, ...}, # every table row key included
'basis': {key: str, ...},
'defaults_why': {key: str, ...},
'choices': {key: [str, ...], ...},
'tables': {name: {'columns': [...], 'min_rows', 'add_label'}, ...},
'geometry': {'<page_id>.<source>': {...}, ...},
'problems': [str, ...]}
See `docs/pa-web-spec.md` for the full schema, one example per block
kind, and the value/row-key contract. Never raises: every stage below is
wrapped so a caller gets a usable (if degraded, and honestly labelled)
spec rather than an exception, on Revit or off it.
"""
top_problems = []
know = know if isinstance(know, dict) else {}
reads = reads if isinstance(reads, dict) else {}
if values is None:
try:
from vibe import panelanalysisform as F
values = F.prefill(reads, know=know)
except Exception as exc:
top_problems.append(
'panelanalysisform.prefill() raised while building values '
'from reads/know: {0!r}; the spec below has no fields '
'pre-filled.'.format(exc))
values = {}
values = dict(values) if isinstance(values, dict) else {}
# Owner ruling, 2026-09-05: `openings_rows` (what the outline/section
# drawings and `panelanalysisform._openings_for_deck` read) is DERIVED
# from the `opening` row table whenever that table carries any present
# row, so an edit made through the page's own Add/Remove-row UI is what
# the drawings redraw from on the NEXT `/api/spec` -- not the frozen
# model-read snapshot `prefill`'s PASSTHROUGH_KEYS copy took at
# spec-build time. A table with no present rows (a fresh blank form, or
# a panel saved before this table existed) leaves `values['openings_
# rows']` exactly as `prefill` set it -- the same legacy fallback
# `validate()` and `pa_extents._openings_summary_sentence` also use.
try:
from vibe import panelanalysisform as _F_opening
_table_opening_rows = _F_opening._opening_rows_from_table(values)
if _table_opening_rows:
values['openings_rows'] = _table_opening_rows
except Exception:
pass
ctx = {'tables': {}, 'choices': {}, 'geometry': {},
'field_keys': set(), 'values': values, 'page_id': None}
pages = []
for page_id, title, modname in PAGE_MODULES:
raw_page = _load_page(page_id, title, modname, values, reads,
know=know)
ctx['page_id'] = page_id
raw_page['blocks'] = [_walk(b, ctx) for b in raw_page['blocks']]
pages.append(raw_page)
_pad_table_rows(values, ctx['tables'])
basis = _basis_dict(values)
defaults_why = _defaults_why_dict(values, ctx['field_keys'])
values_out = {}
for key, v in values.items():
if key in ('_basis', '_defaults_applied'):
continue
values_out[key] = v
values_out = _json_safe(values_out)
mark = (reads.get('mark') or values.get('mark') or '')
try:
mark = mark.strip()
except Exception:
mark = str(mark) if mark else ''
return {
'schema': 1,
'title': 'Panel Analysis',
'mark': mark,
'pages': pages,
'values': values_out,
'basis': _json_safe(basis),
'defaults_why': _json_safe(defaults_why),
'choices': _json_safe(ctx['choices']),
'tables': _json_safe(ctx['tables']),
'geometry': _json_safe(ctx['geometry']),
'derived': _json_safe(_derived_dict()),
'problem_targets': _json_safe(_problem_targets()),
'problems': top_problems,
}
def dumps(spec, **kwargs):
"""`json.dumps(spec)`, with `sort_keys=True` and `ensure_ascii=False`
unless the caller overrides either -- a thin convenience so
`tools/pa_pagespec.py` and any test do not each pick their own
formatting.
`ensure_ascii=False`, not json's own `True` default: IronPython 2.7's
`json` module has no C-accelerated ASCII escaper, and its pure-Python
fallback (`py_encode_basestring_ascii`) raises `UnicodeEncodeError` on
an ordinary BMP character (measured live, 2026-09-05, on one of this
form's own superscript/prime symbols, e.g. U+207B in a field's
`symbol`) instead of escaping it -- so the ASCII-safe path is the one
that actually crashes inside Revit. `ensure_ascii=False` sidesteps that
encoder entirely and writes real UTF-8 (matching every other UTF-8 file
this codebase writes, coding-cookie included); the caller's `io.open`
with `encoding='utf-8'` is what actually gets the bytes right, the same
as every other JSON file this codebase writes off Revit.
"""
kwargs.setdefault('sort_keys', True)
kwargs.setdefault('indent', 1)
kwargs.setdefault('ensure_ascii', False)
return json.dumps(spec, **kwargs)
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""Panel Analysis: the pure merge rule for the model-measured pre-fill.
`script.py`'s `_reads_for` reads three separate things off one panel --
`pa_supports.read_supports`, `pa_lifters.read_lifters`, `pa_reinf.read_
reinforcement_prefill` -- each returning its own small dict of form-field
values (or {}, defensively, when it could not measure anything). This module
holds the one rule for folding all three into the button's running `reads`
dict, kept apart from `script.py` because that file imports the Revit API at
module scope and cannot be imported under `pytest` on host CPython (see
`tests/conftest.py`'s docstring) -- so the part that is pure logic, and
therefore worth a fast host test, lives here instead.
THE RULE, in one sentence: everywhere else in `_reads_for` a value already
read off the piece wins over a model guess (`setdefault` -- fill only what is
still blank); `OVERRIDE_KEYS` are the exception, because for those keys the
NEW read *is* the measurement and anything already sitting in `reads` is a
weaker guess from an earlier, cruder pass -- `mild_as` is the clearest case:
`_assembly_reinforcement`'s old marks-list (a bare comma list of piece marks)
is worth less than `pa_reinf.rebar_prefill`'s `(bar size, type name)` summary
with a real total area, so the new value has to win even though `reads`
already carries something under that key.
`None` never overwrites anything, for any key, override or not -- a module
that could not measure something says so by leaving the key out (or handing
back `None`), never by asserting a blank.
"""
# The new measurement wins over whatever is already in `reads`, blank or not.
# Every other key follows plain `setdefault` -- only a still-empty slot gets
# filled. See the module docstring for why `mild_as` is here: the new
# `(bar size, type name)` summary from `pa_reinf.rebar_prefill` outranks the
# old marks-list `_assembly_reinforcement` may already have written under the
# same key.
OVERRIDE_KEYS = ('supports_ft', 'face_lifters', 'edge_lifters',
'ps_rows', 'mild_as')
def merge_model_reads(reads, parts):
"""Fold `parts` into `reads` IN PLACE, per `OVERRIDE_KEYS`, and return it.
`parts` is either one dict or a list/tuple of dicts (`pa_supports.
read_supports`'s, `pa_lifters.read_lifters`'s, `pa_reinf.read_
reinforcement_prefill`'s, in whatever order the caller has them handy --
order only matters between two dicts that both carry the same key, which
none of the three do today). A `None` value in any part is skipped
outright; it never counts as "the model answered" and never overwrites
an existing value OR fills a blank.
Returns `reads` (the same object, mutated) so a caller can chain it, but
the mutation is the real effect -- this is not written to be used as a
pure expression.
"""
if isinstance(parts, dict):
parts = [parts]
for part in (parts or []):
if not part:
continue
for key, value in part.items():
if value is None:
continue
if key in OVERRIDE_KEYS or reads.get(key) is None:
reads[key] = value
return reads
+761
View File
@@ -0,0 +1,761 @@
# -*- coding: utf-8 -*-
"""Panel Analysis Reinforcement page -- the real strand/rebar pre-fill.
WHY THIS EXISTS: the owner's complaint, verbatim, 2026-09-03: "reinforcement
也没正确识别 rebar 跟 strands" (the Reinforcement page pre-fills almost
nothing). Two separate reads, kept in two functions because they come from
two different sources and fail independently:
* `strand_prefill` turns Panel Lift's own strand read (`liftexport.
read_prestress`'s `panel.prestress` shape -- diameter, loss, and a list
of `{area_in2, initial_pull_lb, y, z_in}` per strand) into the form's
`ps_*` fields, in the exact grammar `panelanalysisform.parse_strand_rows`
reads back (`'count @ depth_in [@ pull_pct]'` per line).
* `rebar_prefill` classifies an assembly's members the same way
`_assembly_reinforcement` in the PanelAnalysis button script does
(`K.is_strand`, the WWF prefix, `K.reinforcing_verdict`), but for the
REBAR half it stops reporting member MARKS (useless -- a comma list of
piece marks does not say how much steel is in the panel) and instead
reports a real `(bar size, type name)` summary with a total area.
`read_reinforcement_prefill` is the merged entry point the button would call
-- see the bottom of this docstring for the wiring `_reads_for` still needs.
WHERE z_in and depth_in AGREE, so no conversion runs here
-----------------------------------------------------------
`ps_rows`' `depth_in` is documented as "how deep from the bottom"
(`panelanalysisform.py`, the `ps_rows` `_F(...)` entry, and again in
`parse_strand_rows`'s docstring: "e.g. '4 @ 2 @ 75' is 4 strands, 2 in. from
the bottom"). `liftexport.read_prestress` measures its own `z_in` the same
way -- `centre[T] - lo[T]`, with the comment directly above it: "Up from the
form face. The form face is the low end of the thickness axis: a panel is
cast face down on the bed." (`liftexport.py` lines 1601-1604). Both are
"distance up from the low end of the thickness axis" -- the same axis, the
same zero, the same direction -- and `panelanalysisform._prestress_from`
proves it operationally: when it turns a typed `ps_rows` line back into a
strand dict for the solve, it writes `'z_in': row['depth_in']` directly, with
no thickness arithmetic in between (`panelanalysisform.py` line 1849). So
`strand_prefill` below does the same thing in reverse -- `depth_in =
z_in` -- and `thickness_in` is accepted (for parity with the caller, which
already has it on hand) but never used to flip or offset anything. If a
future family of parts ever measures `z_in` from the OTHER face, that is the
place to add `thickness_in - z_in`; nothing observed on this job needs it.
No Revit import at module scope. `rebar_prefill` and `read_reinforcement_
prefill` reach for `vibe.knowledge` / `vibe.ticketqc` only inside the
function that needs them, exactly like `_assembly_reinforcement` in the
button script -- so this module imports cleanly on host CPython (`tests/
conftest.py`'s trick) even though `ticketqc` itself cannot.
"""
import re
# Same default the module docstring above cites: no plant maps
# `panelanalysis.bar_size_pattern`, so the last two digits of the TYPE name
# are read as the bar size -- 'S04' -> #4, 'MC_17-04' -> #4, 'MC_T2-05' -> #5.
DEFAULT_BAR_SIZE_PATTERN = r'(\d{2})$'
# Standard ASTM bar areas, in^2. A size this table does not know contributes
# no area to `mild_as_in2` -- same discipline as `panelanalysisform.
# strand_area_in2`: an unknown size is never guessed at, only named.
_BAR_AREA_IN2 = {3: 0.11, 4: 0.20, 5: 0.31, 6: 0.44, 7: 0.60, 8: 0.79}
_DEFAULT_FPU_KSI = 270.0
def _mode(values):
"""The most common value, ties broken by first appearance. None if empty.
Plain dict-counting rather than `collections.Counter` -- this file has to
import on IronPython 2.7 inside the button as well as host CPython, and
Counter's ordering guarantees are not worth relying on for the tie-break.
"""
values = [v for v in values if v is not None]
if not values:
return None
counts = {}
order = []
for v in values:
if v not in counts:
counts[v] = 0
order.append(v)
counts[v] += 1
best = order[0]
best_n = counts[best]
for v in order[1:]:
if counts[v] > best_n:
best = v
best_n = counts[v]
return best
def _fmt_dia(dia):
try:
return '{0:g}'.format(float(dia))
except (TypeError, ValueError):
return None
def strand_prefill(prestress, thickness_in, fpu_ksi=None):
"""The `ps_*` fields off Panel Lift's own strand read, or {}.
`prestress` is `panel.prestress` from `liftexport.export()` / the return
of `liftexport.read_prestress`: `{'diameter_in', 'loss_pct', 'strands':
[{'area_in2', 'initial_pull_lb', 'y', 'z_in', ...}, ...]}`. None, {}, or a
prestress dict with no strands all mean "nothing to prefill" -> {}, the
same "no prestress" the form itself treats as valid (all `ps_*` blank).
`thickness_in` is accepted for parity with the caller (see the module
docstring for why it is not used to convert `z_in`) and may be None.
`ps_pull_lb` is the MODAL `initial_pull_lb` across all strands -- the
pull most of them share -- so it stands in as the form's one default pull
while `ps_rows` calls out only the groups that differ from it.
`ps_rows` is one line per distinct depth, DEPTHS ROUNDED TO THE NEAREST
1/8 in. before grouping (real strand beds are not measured to the
thousandth, and without rounding, floating-point noise in the geometry
read would split one physical row of strands into several). A group's
line carries ' @ {pull_pct:.0f}' only when that group's own pull differs
from the modal pull -- matching, so that the common case (every strand
pulled the same) reads as plain 'count @ depth' lines with no percentage
noise, exactly the shape a hand-typed strand schedule uses.
`strand_rows` is the STRUCTURED alternative to `ps_rows` -- one dict per
depth group, `{'count', 'depth_in', 'dia_in', 'pull_lb'}` -- for
`panelanalysisform.prefill` to seed the `strand_row` table directly, so a
real per-row diameter/pull rides in from the start instead of a text
line that gets re-parsed. Same depth grouping (rounded to 1/8 in.) as
`ps_rows`; `dia_in` / `pull_lb` are the SAME value on every row -- Panel
Lift's own `read_prestress` stamps one diameter and one `it_lb` pull onto
every strand it finds (it refuses outright when the design summary lists
more than one pulled group), so there is only ever one design value of
each to put on a row, never a per-row measurement.
`strand_x_in` is the flat per-strand horizontal position list
`pa_sketch_reinf._explicit_strand_x` reads (one entry per strand, in
`strand_rows` order -- depth-group order, then the order `read_prestress`
already sorted its strands in, ascending `y`). Each value is that
strand's `y` (from `read_prestress`'s own `to_y`, feet from the WIDTH
axis' own low end in the piece's as-drawn/erected frame -- the identical
convention `job['panel']['lifters']`' own `x`/`y` and this piece's `y`
already use) converted to inches. That is NOT necessarily "from the rail
`strand_rail_location` names" -- which rail the dimension chain is really
drawn from is separate plant knowledge this function has no access to
(it takes no `know`) -- so the position is reported honestly as
model-frame-relative, and only emitted at all when every strand in
`strand_rows` produced one (a partial list would silently misalign
against the rows it is supposed to match).
"""
if not prestress:
return {}
strands = prestress.get('strands') or []
if not strands:
return {}
out = {'ps_count': len(strands)}
dia_text = _fmt_dia(prestress.get('diameter_in'))
if dia_text is not None:
out['ps_dia_in'] = dia_text
loss = prestress.get('loss_pct')
if loss is not None:
out['ps_loss_pct'] = loss
fpu = fpu_ksi
if fpu is None:
fpu = prestress.get('fpu_ksi')
if fpu is None:
fpu = _DEFAULT_FPU_KSI
pulls = [s.get('initial_pull_lb') for s in strands]
modal_pull = _mode(pulls)
if modal_pull is not None:
out['ps_pull_lb'] = modal_pull
groups = {}
for s in strands:
z = s.get('z_in')
if z is None:
continue
depth = round(z * 8.0) / 8.0
groups.setdefault(depth, []).append(s)
lines = []
rows = []
xs = []
for depth in sorted(groups.keys()):
members = groups[depth]
line = '{0} @ {1:g}'.format(len(members), depth)
group_pull = _mode([m.get('initial_pull_lb') for m in members])
if (group_pull is not None and modal_pull is not None
and abs(group_pull - modal_pull) > 1e-6):
area = _mode([m.get('area_in2') for m in members])
if area:
pull_pct = group_pull / (area * fpu * 1000.0) * 100.0
line += ' @ {0:.0f}'.format(pull_pct)
lines.append(line)
rows.append({'count': len(members), 'depth_in': depth,
'dia_in': dia_text, 'pull_lb': modal_pull})
for m in sorted(members, key=lambda s: s.get('y') or 0.0):
y_ft = m.get('y')
if y_ft is not None:
xs.append(round(y_ft * 12.0, 3))
if lines:
out['ps_rows'] = '\n'.join(lines)
if rows:
out['strand_rows'] = rows
out['strand_rows_basis'] = (
'{0} strand row(s) grouped by depth (rounded to the nearest '
'1/8 in., matching ps_rows) off the assembly\'s strand members: '
'depth is z_in, up from the FORM FACE -- the low end of the '
'thickness axis, since a panel is cast face down on the bed; '
'diameter and pull are the design spec\'s one group value '
'(it_lb), the same value every strand on this piece already '
'carries.'.format(len(rows)))
strand_count = sum(r['count'] for r in rows)
if xs and len(xs) == strand_count:
out['strand_x_in'] = xs
out['strand_x_in_basis'] = (
'per-strand horizontal position, in inches, off '
'read_prestress\'s own to_y (feet from the width axis\' low '
'end in the as-drawn/erected frame -- the same convention '
"job['panel']['lifters'] x/y already use), one entry per "
'strand in strand_rows order. This is NOT necessarily '
'measured from the rail strand_rail_location names -- which '
'rail the dimension chain is drawn from on the drawing is '
'separate plant knowledge this read has no access to.')
return out
def _type_name_of(box):
"""The member's TYPE name, or None. The box itself first, the element
second.
`ticketqc.member_boxes` does not put a `type` key on the box at all --
confirmed by reading its source (`ticketqc.py`, `member_boxes`: the dict
it appends carries `id, family, category, mark, lo, hi, element`, no
`type`) -- and a probe run against a real assembly came back with every
box's `type` key present but `None`. So the type name is read off
`box['element']` instead: for a `FamilyInstance`, `.Name` (the base
`Element.Name`, not `.Symbol.Name`) IS the type name, a well-known Revit
API quirk. Wrapped in `try` because `box['element']` may be missing
entirely (a hand-built test box) or the API call may fail for a reason
that has nothing to do with this function.
"""
t = box.get('type')
if t:
return str(t)
element = box.get('element')
if element is not None:
try:
name = element.Name
except Exception:
name = None
if name:
return str(name)
return None
def _bar_size_from_type(type_name, pattern):
if not type_name:
return None
try:
compiled = re.compile(pattern)
except Exception:
compiled = re.compile(DEFAULT_BAR_SIZE_PATTERN)
m = compiled.search(type_name)
if not m:
return None
try:
return int(m.group(1))
except (IndexError, ValueError, TypeError):
return None
def rebar_prefill(boxes, know):
"""`{'mild_as', 'mild_as_in2', 'mesh', 'reinf_basis'}` off assembly boxes.
`boxes` is the shape `ticketqc.member_boxes` returns (a list of `{family,
category, mark, lo, hi, element}` dicts, `type` optional). Classification
matches `_assembly_reinforcement` in the PanelAnalysis button script
exactly: `K.is_strand` first (strands are `strand_prefill`'s job, not
this one's), then the `families.wwf_prefix` test for mesh, then
`K.reinforcing_verdict(know, family)[0] is True` for rebar -- `None`
(cannot judge) and `False` are both left OUT of the count, the same
"never invent a verdict" rule `reinforcing_verdict` itself documents.
Bars are summarised by (bar size, type name), not by member mark: a
52-bar wall is 52 marks and one useful sentence, not 52 useless ones.
Size comes from the plant's `panelanalysis.bar_size_pattern` (default
`r'(\\d{2})$'`) applied to the TYPE name (see `_type_name_of`). A bar
whose type name does not match the pattern (or has no type name at all)
is still counted and shown -- as `'{n} x ? ({type})'` -- and named again
in `reinf_basis`, rather than silently dropped, because a bar this
function cannot size is still a bar somebody has to notice is missing an
area.
`mild_as_in2` totals only the bars whose size WAS parsed, using the
standard ASTM areas in `_BAR_AREA_IN2` -- an unparsed bar contributes no
area, matching `panelanalysisform.strand_area_in2`'s refusal to guess.
Returns {} when the boxes carry neither mesh nor rebar (an all-hardware
assembly, or none supplied).
"""
if not boxes:
return {}
from vibe import knowledge as K
try:
wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF')
except Exception:
wwf_prefix = 'WWF'
wwf_prefix = wwf_prefix.upper()
try:
pattern = K.get(know, 'panelanalysis.bar_size_pattern',
DEFAULT_BAR_SIZE_PATTERN)
except Exception:
pattern = DEFAULT_BAR_SIZE_PATTERN
if not pattern:
pattern = DEFAULT_BAR_SIZE_PATTERN
mesh_names = []
rebar_groups = {} # (size or None, type_name or None) -> count
# Every family whose verdict came back other than True -- False
# ("confirmed not a bar", the ordinary case for connection hardware) and
# None ("cannot judge", the ambiguous case) are NOT told apart here, on
# purpose: `reinforcing_verdict`'s own "list" mode returns a confident
# False for a family nobody has EXPLICITLY ruled out, same as one that
# really is just hardware (2026-09-07 finding on WPA655 -- the bare
# `S_(SCHEDULED)` family, still an open plant-knowledge question, comes
# back False and reads exactly like `DB_PATCH` would). So this function
# cannot tell "safely excluded" from "quietly dropped a real bar group"
# any better than the caller could -- it just stops hiding the count.
excluded = {} # family -> count
for b in boxes:
family = b.get('family') or ''
try:
if K.is_strand(know, family):
continue
except Exception:
pass
if family.upper().startswith(wwf_prefix):
if family not in mesh_names:
mesh_names.append(family)
continue
try:
verdict = K.reinforcing_verdict(know, family)[0]
except Exception:
verdict = None
if verdict is not True:
excluded[family] = excluded.get(family, 0) + 1
continue
type_name = _type_name_of(b)
size = _bar_size_from_type(type_name, pattern)
key = (size, type_name)
rebar_groups[key] = rebar_groups.get(key, 0) + 1
if not mesh_names and not rebar_groups:
return {}
out = {}
if mesh_names:
out['mesh'] = ', '.join(mesh_names)
if rebar_groups:
def _sort_key(item):
(size, type_name), _count = item
return (size if size is not None else float('inf'),
type_name or '')
parts = []
basis = []
total_area = 0.0
for (size, type_name), count in sorted(rebar_groups.items(),
key=_sort_key):
label = type_name if type_name else 'unknown type'
if size is None:
parts.append('{0} x ? ({1})'.format(count, label))
basis.append(
"{0} x {1} -- no bar size parsed from the type name "
"(panelanalysis.bar_size_pattern {2!r})"
.format(count, label, pattern))
else:
parts.append('{0} x #{1} ({2})'.format(count, size, label))
area = _BAR_AREA_IN2.get(size)
if area is not None:
total_area += area * count
out['mild_as'] = ', '.join(parts)
out['mild_as_in2'] = '{0:.2f}'.format(total_area)
if excluded:
n_members = sum(excluded.values())
by_family = ', '.join(
'{0} {1}'.format(excluded[f], f) for f in sorted(excluded))
basis.append(
'{0} other member(s) across {1} famil{2} ({3}) did not '
'verdict as reinforcing under the current knowledge -- '
'False here can mean "confirmed hardware" or "an '
'unresolved plant-knowledge question" equally, see '
'drawings.reinforcing_family_patterns / '
'_family_overrides for which.'
.format(n_members, len(excluded),
'y' if len(excluded) == 1 else 'ies', by_family))
if basis:
out['reinf_basis'] = basis
return out
# Bars closer together than this (rounded) are the same depth group -- the
# same discipline `strand_prefill` uses on strand depth (1/8 in.), loosened
# to 1/4 in. because rebar is not measured to strand tolerance.
_MILD_DEPTH_GROUP_IN = 0.25
def _member_axes(el, asm, know):
"""(to_x, to_y, lo, hi, L, W, T, basis) for `el`'s own panel frame, or
None on any failure.
The SAME (length, width, thickness) axis assignment and up-end flip
`liftexport.export` uses for everything else Panel Lift measures --
`liftexport._axes` on the panel's own `ticketqc.concrete_extents` (built
off the SAME `ticketqc._panel_frame` the strand/rebar boxes are already
read in), then `liftexport.erected_orientation` for which end reads as
x=0. Reusing both rather than re-deriving them is the point: a mild bar's
`from_ft`/`to_ft` has to land in the identical as-drawn frame
`job['panel']['lifters']`' x/y and `strand_x_in` already use, or the
three would each describe "the same piece" in a different, disagreeing
coordinate system.
When `asm` is None, or the origin rule cannot say which end is up, the
axes still resolve (thickness/length/width are pure geometry) but x/y
keep the model's own order -- `basis` names that explicitly rather than
silently asserting an orientation nobody established.
"""
try:
from vibe import ticketqc as tq
from vibe import liftexport as LX
except Exception:
return None
try:
frame = tq._panel_frame(el)
extents = tq.concrete_extents(el, frame)
except Exception:
return None
if frame is None or extents is None:
return None
lo = [v * 12.0 for v in extents[0]]
hi = [v * 12.0 for v in extents[1]]
try:
L, W, T = LX._axes(lo, hi)
except Exception:
return None
length_ft = (hi[L] - lo[L]) / 12.0
width_ft = (hi[W] - lo[W]) / 12.0
x_ascends, y_ascends = True, True
basis = ('the model\'s own axis order -- no assembly was given, so the '
'erected up-end rule could not run')
if asm is not None:
try:
vertical_axis, up_is_max, shares = LX.erected_orientation(
asm, el, frame, (L, W, T), know, (length_ft, width_ft))
except Exception:
vertical_axis, up_is_max, shares = None, None, {}
if vertical_axis is not None:
if vertical_axis == 'length':
x_ascends, y_ascends = up_is_max, True
else:
x_ascends, y_ascends = True, up_is_max
basis = ('the erected orientation (basis: {0})'
.format(shares.get('basis') or 'unknown'))
else:
basis = ('the model\'s own axis order -- the erected up-end '
'rule could not tell which end of this piece is up')
def to_x(value_in):
along = (value_in - lo[L]) / 12.0
return along if x_ascends else length_ft - along
def to_y(value_in):
across = (value_in - lo[W]) / 12.0
return across if y_ascends else width_ft - across
return {'to_x': to_x, 'to_y': to_y, 'lo': lo, 'hi': hi,
'L': L, 'W': W, 'T': T, 'basis': basis}
def _median_gap(values):
values = sorted(values)
gaps = sorted(values[i + 1] - values[i]
for i in range(len(values) - 1))
if not gaps:
return None
mid = len(gaps) // 2
if len(gaps) % 2:
return gaps[mid]
return (gaps[mid - 1] + gaps[mid]) / 2.0
def cluster_split(members, spread_key, gap_ratio=5.0, min_gap_in=6.0):
"""[[member, ...], ...] -- `members` (each a dict carrying `spread_key`)
split into CLUSTERS along that key wherever consecutive members are far
enough apart to be a different physical feature, not one spaced-out
run. PUBLIC (module level, not nested in `mild_bar_rows_prefill`) so it
is directly host-testable with synthetic data -- the function it serves
needs a live `el`/`asm` (through `_member_axes`) and cannot run at all
on plain CPython, which is exactly why this fix needed a test of its
own reachable a different way.
2026-09-05, WPA655 finding: a (size, depth, direction) GROUP can
legitimately hold bars from two unrelated locations (e.g. two separate
openings' own diagonal edge bars) that happen to share a size/depth/
direction -- grouping them into ONE row reported a `spacing_in` (the
median gap across ALL members) fooled by exactly this shape: two tight
within-location pairs far apart read as "spacing 3in" (the median of
[3in, 3in, ~37ft]) while `from_ft`/`to_ft` correctly spanned the whole
panel -- an internally INCONSISTENT row (a reader sees '4 bars, 3in
apart, 0 to 37 ft' and the arithmetic does not close: 4 bars at 3in
apart span 9in, not 37ft).
Sorts by `spread_key` and starts a new cluster whenever a gap is more
than `gap_ratio` times the SMALLEST positive gap seen in the whole run
-- a within-cluster gap sits close to that minimum for a genuinely
evenly-spaced pattern; a gap to the next physical feature does not.
`min_gap_in` floors the 'smallest gap' so two bars only an inch or two
apart (an ordinary production tolerance) never reads as its own tiny
cluster unit that then flags every larger, still-legitimate spacing as
a break. Fewer than 3 members is returned as one cluster
unconditionally -- there is no spacing pattern that small to protect
anyway.
"""
if len(members) < 3:
return [members]
ordered = sorted(members, key=lambda m: m[spread_key])
gaps = [ordered[i + 1][spread_key] - ordered[i][spread_key]
for i in range(len(ordered) - 1)]
positive = [g for g in gaps if g > 1e-6]
if not positive:
return [ordered]
threshold = max(min(positive), min_gap_in) * gap_ratio
clusters = [[ordered[0]]]
for i, gap in enumerate(gaps):
if gap > threshold:
clusters.append([])
clusters[-1].append(ordered[i + 1])
return clusters
def mild_bar_rows_prefill(el, asm, know, boxes):
"""`{'mild_bar_rows', 'mild_bar_rows_basis'}` off assembly rebar boxes,
or {} -- the STRUCTURED alternative to `rebar_prefill`'s `mild_as`
summary text, for `panelanalysisform.prefill` to seed the `mild_bar`
table directly (`[{'size', 'count', 'depth_in', 'direction', 'from_ft',
'to_ft', 'spacing_in'}, ...]`).
Classification is identical to `rebar_prefill` (strand/mesh/rebar,
`K.is_strand` -> `families.wwf_prefix` -> `K.reinforcing_verdict`), so
the two functions always agree on WHICH boxes are rebar; this one just
also places them. `boxes` needs `el`/`asm` (through `_member_axes`) for
the length/width/thickness axes and the erected x/y flip -- `rebar_
prefill` never needed either, so it keeps its own simpler signature.
One row per (bar size, depth rounded to the nearest 1/4 in., direction)
group. `direction` is 'Longitudinal' when a bar's own box runs longer
along the LENGTH axis than the WIDTH axis, 'Transverse' otherwise --
ties go to Longitudinal. `depth_in` is the group's boxes' average box
centre on the THICKNESS axis, measured from the FORM FACE -- same
convention `strand_prefill`'s `z_in` uses (the low end of the thickness
axis; a panel is cast face down on the bed). `from_ft`/`to_ft` are
always the group's extreme LENGTH-axis positions (`to_x` at each
member's own two ends, min and max across the group) -- for a
longitudinal group that is each bar's own run; for a transverse group
(each bar short along the length, spread out along it) it is the span
from the first bar's position to the last. `spacing_in` is the median
gap between members along whichever axis they are actually spread over
-- WIDTH for a longitudinal group (parallel bars set apart across the
panel), LENGTH for a transverse group -- and is left `None` for a group
of fewer than 3 members, where "the gap" is not a well-formed spacing.
A box missing `lo`/`hi` (a hand-built test box, or a geometry solve that
failed upstream) is skipped, not guessed at. Returns {} when there is
nothing to place (`boxes` empty, no box classifies as rebar, or the
panel's own axes could not be solved) or on any exception -- never
raises.
"""
if not boxes:
return {}
try:
from vibe import knowledge as K
except Exception:
return {}
axes = _member_axes(el, asm, know)
if axes is None:
return {}
to_x = axes['to_x']
lo, L, W, T = axes['lo'], axes['L'], axes['W'], axes['T']
try:
wwf_prefix = str(K.get(know, 'families.wwf_prefix', 'WWF') or 'WWF')
except Exception:
wwf_prefix = 'WWF'
wwf_prefix = wwf_prefix.upper()
try:
pattern = K.get(know, 'panelanalysis.bar_size_pattern',
DEFAULT_BAR_SIZE_PATTERN)
except Exception:
pattern = DEFAULT_BAR_SIZE_PATTERN
if not pattern:
pattern = DEFAULT_BAR_SIZE_PATTERN
groups = {} # (size, depth_key, direction) -> [member dict, ...]
# Same tracking, and the same caveat, as `rebar_prefill`'s `excluded`:
# a False verdict here can mean "confirmed hardware" or "an unresolved
# plant-knowledge question" (2026-09-07, WPA655: the bare `S_(SCHEDULED)`
# family -- 42 real mild-bar members, #3/#4/#5 -- verdicted False and
# vanished from this table with no trace, because nothing distinguished
# it from an ordinary connection embed). Never silently drop the count.
excluded = {} # family -> count
for b in boxes:
family = b.get('family') or ''
try:
if K.is_strand(know, family):
continue
except Exception:
pass
if family.upper().startswith(wwf_prefix):
continue
try:
verdict = K.reinforcing_verdict(know, family)[0]
except Exception:
verdict = None
if verdict is not True:
excluded[family] = excluded.get(family, 0) + 1
continue
blo, bhi = b.get('lo'), b.get('hi')
if blo is None or bhi is None:
continue
type_name = _type_name_of(b)
size = _bar_size_from_type(type_name, pattern)
span_L = bhi[L] - blo[L]
span_W = bhi[W] - blo[W]
direction = 'Longitudinal' if span_L >= span_W else 'Transverse'
depth_in = ((blo[T] + bhi[T]) / 2.0) - lo[T]
depth_key = round(depth_in / _MILD_DEPTH_GROUP_IN) * _MILD_DEPTH_GROUP_IN
x0, x1 = sorted((to_x(blo[L]), to_x(bhi[L])))
centre_L_in = (blo[L] + bhi[L]) / 2.0
centre_W_in = (blo[W] + bhi[W]) / 2.0
groups.setdefault((size, depth_key, direction), []).append({
'depth_in': depth_in, 'x0': x0, 'x1': x1,
'along_length_in': centre_L_in,
'along_width_in': centre_W_in,
})
if not groups:
return {}
def _sort_key(item):
(size, depth_key, direction), _members = item
return (size if size is not None else float('inf'), depth_key,
direction)
rows = []
unsized = 0
n_split = 0
for (size, depth_key, direction), members in sorted(groups.items(),
key=_sort_key):
spread_key = ('along_width_in' if direction == 'Longitudinal'
else 'along_length_in')
clusters = cluster_split(members, spread_key)
if len(clusters) > 1:
n_split += 1
for cluster in clusters:
count = len(cluster)
if size is None:
unsized += count
depth_avg = sum(m['depth_in'] for m in cluster) / float(count)
from_ft = min(m['x0'] for m in cluster)
to_ft = max(m['x1'] for m in cluster)
spacing_in = None
if count >= 3:
spacing_in = _median_gap([m[spread_key] for m in cluster])
rows.append({
'size': ('#{0}'.format(size) if size is not None else None),
'count': count, 'depth_in': round(depth_avg, 3),
'direction': direction, 'from_ft': round(from_ft, 3),
'to_ft': round(to_ft, 3),
'spacing_in': (round(spacing_in, 3)
if spacing_in is not None else None),
})
basis = (
'{0} rebar group(s) read off the assembly members, grouped by (bar '
'size, depth rounded to the nearest 1/4 in., direction), each '
'further split into separate rows wherever the members are not one '
'evenly-spaced run (see the 2026-09-05 clustering note). Size from '
'the type name (panelanalysis.bar_size_pattern {1!r}); depth is the '
'box centre on the thickness axis, up from the FORM FACE (the low '
'end of the thickness axis -- a panel is cast face down on the '
'bed, the same convention strand depth uses); direction is '
"Longitudinal when a bar's own box runs longer along the panel's "
'length than its width, Transverse otherwise; from/to are the '
"row's extreme positions along the length ({2}); spacing is the "
'median gap across the perpendicular axis for a row of 3 or more '
'bars, left blank otherwise.'.format(len(rows), pattern,
axes['basis']))
if n_split:
basis += (' {0} bar-size/depth/direction group(s) were not one '
'evenly-spaced run and were split into separate rows so '
'each row\'s spacing matches its own extent.'
.format(n_split))
if unsized:
basis += (' {0} bar(s) carried with no size parsed from the type '
'name.'.format(unsized))
if excluded:
n_members = sum(excluded.values())
by_family = ', '.join(
'{0} {1}'.format(excluded[f], f) for f in sorted(excluded))
basis += (
' {0} other member(s) across {1} famil{2} ({3}) did not '
'verdict as reinforcing under the current knowledge and are NOT '
'in this table -- False there can mean "confirmed hardware" or '
'"an unresolved plant-knowledge question" equally, see '
'drawings.reinforcing_family_patterns / _family_overrides for '
'which.'.format(n_members, len(excluded),
'y' if len(excluded) == 1 else 'ies', by_family))
return {'mild_bar_rows': rows, 'mild_bar_rows_basis': basis}
def read_reinforcement_prefill(el, asm, know, prestress=None, fpu_ksi=None):
"""Both halves merged: strands off `prestress`, rebar off the assembly.
Defensive by construction, not by a wrapping try/except around the whole
thing: the two halves run in SEPARATE try blocks, so a rebar-side failure
(no assembly, `ticketqc` unavailable -- true on host CPython, where
`vibe.ticketqc` cannot even import) never takes the strand half down with
it, and vice versa. Never raises.
"""
out = {}
try:
out.update(strand_prefill(prestress, None, fpu_ksi))
except Exception:
pass
try:
from vibe import ticketqc as tq
frame = tq._panel_frame(el)
boxes = tq.member_boxes(el.Document, asm, el, frame, know) or []
out.update(rebar_prefill(boxes, know))
out.update(mild_bar_rows_prefill(el, asm, know, boxes))
except Exception:
pass
return out
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,994 @@
# -*- coding: utf-8 -*-
"""The Concrete Extents drawings -- pure geometry, both Pythons, no Revit,
no WPF. Two entry points, `outline()` and `section()`, are what
`vibe.pa_extents.page()`'s two `drawing` blocks name (`module:
'pa_sketch_extents'`) -- see that module's "WHY THE TWO DRAWINGS ARE NOW
STACKED" docstring note for the owner ruling (2026-09-04) this exists to
satisfy: the outline drawn bigger, the section moved BELOW it instead of
beside it, and a cut line on the outline that the section redraws around --
"跟做核磁一样" (like an MRI, scrubbing through slices).
THE SEAM is the same one `panelsketch.py` draws for itself: the WPF side
(the `Canvas`, the brushes, and the actual DRAGGING of the cut line) is the
renderer's job, not this module's. This module's job stops at a flat list of
drawing PRIMITIVES in a normalised, already-in-pixels coordinate space
(origin top-left, y down, the same vocabulary `panelsketch.py`'s own module
docstring documents), plus a `problems` list of plain-English sentences for
anything it could not draw -- the caller only walks the list and paints each
primitive, or reads a `problems` sentence and shows it. That keeps this file
importable and testable on plain CPython (`tests/conftest.py`) and runnable
under IronPython 2.7 inside Revit: no f-strings, no `pathlib`, no
`dataclasses`, no typing, no `enum`, standard library only.
WHY `outline()` NO LONGER DELEGATES TO `panelsketch.extents_outline()`
(owner headless-render finding, 2026-09-04, on a 37.58 x 8.375 ft x 8 in
panel): that function LETTERBOXES the panel rectangle -- it fits the
picture to whichever of width/height is tighter, preserving aspect ratio --
which is exactly wrong for this drawing's own container. The container's
height comes from a small `aspect` ratio (`pa_extents._ratio_clamp`, roughly
`width_ft / length_ft`) applied to whatever width the renderer's `Canvas`
ends up with, so the box handed to the drawing function is ALREADY close to
the panel's own true proportions; `extents_outline()`'s own margin +
`dim_room` accounting (subtracted from an already-short `avail_h` a second
time) was enough to flip which axis is "tighter" and letterbox to HEIGHT
instead, leaving the panel at roughly half the canvas's width -- precisely
the "面板只占了显示窗口宽度的一半" the owner found. `panelsketch.py` is not
being changed to fix this (`extents_outline()` still letterboxes, and other
callers may want that) -- `outline()` below draws its own picture instead,
following the OWNER'S GENERAL RULE that already governs every OTHER
full-width drawing in this codebase (`pa_sketch_handling.lifter_dunnage()`,
`pa_sketch_reinf`'s `full_width` sections): "画图的时候,panel 的长度总要占满
整个显示窗口的宽度" -- the drawable width is ALWAYS the panel's full width,
never letterboxed down for height's sake. `section()` below is UNCHANGED --
it already fills its own width; see its own docstring note.
Because that means `outline()` no longer needs `extents_outline()`'s several
hundred lines of layout math, it draws directly: the panel rectangle spans
the full available width (inside a left/right margin -- see
`_outline_margins()`), the overall W dimension on the left, the overall L
label at the panel's bottom-LEFT (not centred -- see "WHERE THE CUT AND L
LABELS SIT" below), and openings/reveals/solid-zones to scale with their own
locating dimension chains (along-length from the top, across-width from the
left). It still REUSES, rather than re-derives, the two pieces of
`panelsketch` that are pure row-geometry with no letterboxing opinion of
their own: `_parse_opening_row()` (via `_openings_rows()`) resolves one
opening row's axes, the same axis-assignment judgement call documented on
that function itself; `_opening_rect_primitives()` / `_gap_dimension_chain()`
turn resolved rows into primitives given an already-placed `(ox, oy, draw_w,
draw_h)` box -- exactly the "do not re-derive" discipline this module
already followed for `section()`. `_title_primitive()` is reused the same
way. None of the three carries a letterboxing opinion; only
`extents_outline()` itself does, and that is the one function `outline()` no
longer calls.
WHERE THE CUT AND L LABELS SIT (owner finding, same headless render): the
station label ("cut @ 18.8 ft") and the overall length label ("L = 37.58
ft") both used to render CENTRED on the cut line's own x position -- which,
at the cut's default midpoint, is the panel's horizontal centre, so both
labels sat directly ON the red cut line they were meant to be read beside.
Fixed two ways, permanently rather than only for the default midpoint:
`_cut_marker_primitives()` anchors the station label `'start'` and offsets
it `_CUT_LABEL_OFFSET_PX` (14px, comfortably past the 12px the tests check)
to the RIGHT of the tick, never centred on it; and the L label is drawn at
the panel's bottom-LEFT corner (`anchor: 'start'`, `x: ox`), not centred
under the panel, so it does not sit under the cut line at its most common
resting place (the midpoint) either. A full-width leader line is still
drawn under the panel so the label still reads as dimensioning the whole
span, only the TEXT moved.
OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER for the cut line's own
pixel position -- read this before touching the WPF half. `outline()` places
the draggable line's tick at `x = geo['x0'] + cut_frac * (geo['x1'] -
geo['x0'])`, where `geo = outline_geometry(values, width_px, height_px)`
(the same call `outline()` itself makes, so the static tick this module
draws and the live draggable line the renderer draws can never disagree).
`outline_geometry()` needs no Revit, no WPF, and -- unlike every primitive
function in this module -- no `panelsketch` either: the box it returns
(`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}`, the
on-canvas panel rectangle's own left/right/top/bottom in pixels) depends
only on `width_px` / `height_px` and, for the vertical axis only,
`length_ft` / `width_ft` (falling back to a stand-in ratio, same as
`pa_extents._OUTLINE_ASPECT_DEFAULT`, when those are not entered yet -- see
`outline_geometry()`'s own docstring). **REPORT TO THE RENDERER OWNER:**
`PanelAnalysis.pushbutton/script.py`'s `_add_cutline()` currently computes
the draggable line's x as `x = frac * w` -- the bare canvas width, with NO
margin offset at all (and the drag handler's `_move()` reads it back the
same way, `frac2 = nx / entry['w']`). That was already wrong before this
change (the old `extents_outline()` picture was drawn inset from the
canvas edges too, just by different, letterboxed amounts), and it is wrong
after this change as well: the panel rectangle's left edge is at
`geo['x0']`, not `0`, and its right edge is at `geo['x1']`, not `w`. The fix
on that side is `x = geo['x0'] + frac * (geo['x1'] - geo['x0'])` for drawing
and `frac2 = (nx - geo['x0']) / (geo['x1'] - geo['x0'])` for the drag
readback, both calling `pa_sketch_extents.outline_geometry(values, w, h)` to
get `geo` -- this module exposes exactly that pair (`x0`, `x1`) for this
reason. Not fixed here: that file is WPF, outside this agent's ownership,
and the rule for this checkout is "no bridge, don't touch the renderer" --
this docstring note IS the report.
THE IMPORT IS GUARDED because `panelsketch.py` is a SIBLING agent's file.
`outline()` still needs three of its row/primitive helpers for openings
(`_openings_rows`, `_opening_rect_primitives`, `_gap_dimension_chain`) and
one for the title (`_title_primitive`) -- an import failure, or any of those
four missing, is "not available yet", never a crash: `outline()` still draws
the panel rectangle, the L/W labels and the cut marker (none of which need
`panelsketch` at all any more) and reports, in `problems`, that openings
could not be drawn. `section()` is unchanged and still hard-degrades to a
single centred note when `panelsketch` (or one of ITS several helpers) is
missing -- see that function's own docstring, carried over from before this
rewrite.
MISSING GEOMETRY IS NEVER A BLANK CANVAS HERE, unlike `panelsketch`'s own
`sketch()` / `elevation()` / `extents_outline()` / `extents_section()`,
which return `([], problems)` when `length_ft` / `width_ft` / `thickness_in`
is missing and leave the caller to decide what an empty list means. This
module's two entry points are always on screen (the block contract has no
"don't show this yet" option), so a hard stop here is a single `text`
primitive that SAYS what is missing, in the middle of the canvas, rather
than nothing at all -- `_center_note()` below is the one place that shape is
built, so every caller of it looks the same.
THE CUT FRACTION, `values['_cut_frac']`, is a plain string (or a number,
either is accepted) between 0 and 1 -- the fraction of `length_ft` the cut
line sits at, `0.5` (the panel's midpoint) when the form has never carried
one. It is form data like anything else the renderer's `current_values()`
reads back off the page -- this module never invents a default other than
0.5, and never rejects a value outside `[0, 1]`; it clamps, the same
"present but flagged" discipline `panelsketch.py`'s own support-clamping
uses, minus the flag: a cut line has no "outside the panel" meaning to flag
(dragging it past an end is simply dragging it to that end).
"""
try:
from vibe import panelsketch
except ImportError:
panelsketch = None
# --------------------------------------------------------------- utilities
def _cut_fraction(values):
"""The cut line's position as a fraction of the length, in `[0, 1]`.
`values['_cut_frac']` missing, blank, or unreadable -> the midpoint,
0.5 -- the same "waiting to be dragged" starting point the block
contract's own `cutline: {'default': 0.5}` declares to the renderer, so
the very first draw (before the user has ever touched the line) matches
what this function falls back to on every later bad value too."""
values = values or {}
raw = values.get('_cut_frac')
if raw is None:
return 0.5
try:
frac = float(raw)
except (TypeError, ValueError):
return 0.5
if frac < 0.0:
return 0.0
if frac > 1.0:
return 1.0
return frac
def _center_note(width_px, height_px, text):
"""The one-primitive fallback canvas: a centred `text` note, nothing
else. Used whenever this module cannot draw the real picture yet --
see the module docstring's "MISSING GEOMETRY IS NEVER A BLANK CANVAS"
note. `width_px` / `height_px` of 0 (or unreadable) still produces a
primitive at (0, 0) rather than raising -- there is no invariant this
function needs to protect by refusing to run."""
try:
w = float(width_px)
except (TypeError, ValueError):
w = 0.0
try:
h = float(height_px)
except (TypeError, ValueError):
h = 0.0
return [{'kind': 'text', 'x': w / 2.0, 'y': h / 2.0, 'text': text,
'role': 'note', 'anchor': 'middle'}]
# ------------------------------------------------------- outline geometry
#
# Pure pixel math, no `panelsketch` -- see the module docstring's
# "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE RENDERER" note. This is the
# ONE place the outline's on-canvas box is computed; `outline()` calls it
# for its own primitives so the static picture and the live draggable line
# can never disagree about where the panel rectangle actually is.
_OUTLINE_MARGIN_FRACTION = 0.12 # of the shorter canvas side, same
# formula `panelsketch._margin()` /
# `pa_sketch_handling._margin()` use
_OUTLINE_MARGIN_MIN_PX = 8.0
_OUTLINE_MARGIN_MAX_PX = 40.0
_OUTLINE_LEFT_EXTRA_PX = 40.0 # on top of the base margin, left only --
# room for the W dimension chain's line
# AND its rotated/stacked label (owner:
# "~60 px"; base margin (8-40) + this 40
# lands in that neighbourhood for every
# canvas size this drawing actually gets)
# Live-render finding, 2026-09-04 (coordinator): the panel rectangle's
# bottom edge sat on the canvas bottom and the "L = ..." label was clipped
# away entirely -- the container's height (from `pa_extents`'s `aspect`) had
# no room reserved for the title/cut-station label ABOVE the panel or the L
# label/dimension chain BELOW it. Top and bottom now carry their own FIXED
# pixel floors, independent of the shared base margin `m`: `_outline_margins`
# still shrinks them together with everything else only if the canvas is too
# small to hold even these floors (the tiny-canvas test cases), never
# otherwise. `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` is the matching change
# on the container-sizing side -- the two are meant to be read together.
_OUTLINE_TOP_MIN_PX = 26.0 # title row + cut-station label
_OUTLINE_BOTTOM_MIN_PX = 34.0 # L label + its dimension/leader chain
_OUTLINE_ASPECT_DEFAULT = 0.3 # width_ft / length_ft stand-in before
# real geometry exists -- mirrors
# `pa_extents._OUTLINE_ASPECT_DEFAULT`,
# so the box this returns before the
# user has typed length/width is still a
# reasonable shape, not a fresh guess
# this module owns independently of that
# one.
def _outline_margins(width_px, height_px):
"""(left, right, top, bottom) px margins around the outline's panel
rectangle. `left` is the widest -- it alone has to hold the W dimension
chain's line and its rotated/stacked label (see
`_OUTLINE_LEFT_EXTRA_PX`); `right` / `top` / `bottom` are the same base
margin `panelsketch._margin()` computes for every OTHER drawing in this
codebase (12% of the shorter canvas side, clamped 8..40px), holding one
thing each -- the cut station label and, when there are openings, their
along-length chain (top); the L label and its leader line (bottom); the
end of an opening's across-width chain (right).
All four SHRINK TOGETHER, proportionally, whenever the canvas itself is
smaller than their combined budget -- capped at 90% of each axis so a
tiny canvas (the `(20, 15)` case
`tests/test_pa_sketch_extents.py::test_outline_stays_inside_box`
exercises) still leaves the panel a positive, drawable size instead of
the margins alone consuming the whole box."""
width_px = float(width_px)
height_px = float(height_px)
m = min(width_px, height_px) * _OUTLINE_MARGIN_FRACTION
if m < _OUTLINE_MARGIN_MIN_PX:
m = _OUTLINE_MARGIN_MIN_PX
if m > _OUTLINE_MARGIN_MAX_PX:
m = _OUTLINE_MARGIN_MAX_PX
left, right = m + _OUTLINE_LEFT_EXTRA_PX, m
top = max(m, _OUTLINE_TOP_MIN_PX)
bottom = max(m, _OUTLINE_BOTTOM_MIN_PX)
h_budget = max(width_px * 0.9, 4.0)
if left + right > h_budget:
scale = h_budget / (left + right)
left *= scale
right *= scale
v_budget = max(height_px * 0.9, 4.0)
if top + bottom > v_budget:
scale = v_budget / (top + bottom)
top *= scale
bottom *= scale
return left, right, top, bottom
def outline_geometry(values, width_px, height_px):
"""`{'x0': left_px, 'x1': right_px, 'y0': top_px, 'y1': bottom_px}` --
the pixel box the outline's panel rectangle occupies (or WILL occupy,
even before `length_ft` / `width_ft` are entered -- see below). This is
the renderer's half of the cut-line contract: the draggable tick's x is
`x0 + cut_frac * (x1 - x0)`, exactly what `outline()`'s own
`_cut_marker_primitives()` computes, so the two can never disagree. See
the module docstring's "OUTLINE_GEOMETRY() IS THE CONTRACT WITH THE
RENDERER" note for the exact formula the WPF side needs and the report
on what it currently does instead.
`x0` / `x1` depend only on `width_px` and the margins `_outline_margins`
computes from `width_px` / `height_px` -- the panel ALWAYS spans the
full available width (see the module docstring's delegation note), so
they need no `length_ft` / `width_ft` at all and are just as valid
before the user has entered any geometry as after. `y0` / `y1` are
scale-true to `width_ft / length_ft` when both are entered and
positive; otherwise `_OUTLINE_ASPECT_DEFAULT` stands in, the same
"still a reasonable shape, never a guess this function owns" rule
`_center_note()`'s callers use for the rest of this module.
THE DRAWN HEIGHT NEVER OVERFLOWS THE CANVAS (2026-09-04 live-render
fix): `draw_h` is `min(avail_v, draw_w * width_ft / length_ft)`, where
`avail_v` is whatever is left of `height_px` after `_outline_margins`'s
top/bottom floors -- so the panel rectangle's bottom edge can never be
pinned to the canvas bottom (which used to crop the L label off the
canvas entirely; see `pa_extents._OUTLINE_LABEL_ROWS_ASPECT` for the
matching container-sizing fix, meant to keep the cap from actually
biting in the normal case). When the true-to-scale height DOES fit
(`avail_v` has slack), the panel is still centred vertically inside
that slack, same as before this fix. This is the one function that
computes the box; `outline()` calls it for its own primitives, so a
caller can trust this return matches what actually got drawn.
Never raises -- a `width_px` / `height_px` that will not parse as a
number is read as `0.0`, the same as `_center_note()` does."""
values = values or {}
try:
width_px = float(width_px)
except (TypeError, ValueError):
width_px = 0.0
try:
height_px = float(height_px)
except (TypeError, ValueError):
height_px = 0.0
left, right, top, bottom = _outline_margins(width_px, height_px)
x0 = left
x1 = max(width_px - right, x0)
draw_w = max(x1 - x0, 0.0)
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
if length_ft is not None and length_ft > 0 and width_ft is not None \
and width_ft > 0:
ratio = width_ft / length_ft
else:
ratio = _OUTLINE_ASPECT_DEFAULT
avail_v = max(height_px - top - bottom, 0.0)
draw_h = min(avail_v, draw_w * ratio)
y0 = top + max(0.0, (avail_v - draw_h) / 2.0)
y1 = y0 + draw_h
return {'x0': x0, 'x1': x1, 'y0': y0, 'y1': y1}
_CUT_TICK_PX = 8.0
_CUT_LABEL_OFFSET_PX = 14.0 # > the 12px the tests check, to the RIGHT of
# the tick -- see the module docstring's
# "WHERE THE CUT AND L LABELS SIT" note.
def _cut_marker_primitives(geo, length_ft, cut_frac):
"""The tick + station-label primitives for the cut line, at its true
proportional position along the outline's own on-canvas box (`geo`,
from `outline_geometry()`). The renderer draws the DRAGGABLE line itself
(the block contract's `cutline` key is what tells it to); these three
primitives are what make the static PNG/host picture -- and this
module's own tests -- show the same cut position without a live drag
handler. `role: 'cutline-tick'` / `'cutline-label'` names it for the
renderer to colour red (small red tick, owner ruling, 2026-09-04) the
same way `role: 'flagged'` primitives elsewhere in `panelsketch` are
coloured by role rather than by an explicit colour key on the
primitive. The label is anchored `'start'` and offset
`_CUT_LABEL_OFFSET_PX` to the right of the tick -- NEVER centred on it,
see the module docstring."""
ox, oy = geo['x0'], geo['y0']
draw_w = geo['x1'] - geo['x0']
draw_h = geo['y1'] - geo['y0']
cx = ox + cut_frac * draw_w
station_ft = cut_frac * length_ft
tick = _CUT_TICK_PX
# Floor at 4.0, not 0.0 -- the coordinator's clearance rule (every
# primitive's y within [0, height_px] with >= 4px of daylight from
# either edge), same reasoning `_outline_margins`'s `_OUTLINE_TOP_MIN_PX`
# exists for. `oy` is never less than that margin, so this floor is a
# backstop, not the normal path.
label_y = max(oy - tick - 14.0, 4.0)
return [
{'kind': 'line', 'x1': cx, 'y1': max(oy - tick, 0.0), 'x2': cx,
'y2': oy, 'role': 'cutline-tick'},
{'kind': 'line', 'x1': cx, 'y1': oy + draw_h, 'x2': cx,
'y2': oy + draw_h + tick, 'role': 'cutline-tick'},
{'kind': 'text', 'x': cx + _CUT_LABEL_OFFSET_PX, 'y': label_y,
'text': 'cut @ {0:.1f} ft'.format(station_ft),
'role': 'cutline-label', 'anchor': 'start', 'font_size': 11},
]
# --------------------------------------------------------------------- API
def outline(values, width_px, height_px):
"""(primitives, problems) -- the panel outline: the panel rectangle to
scale (spanning the FULL available drawing width -- see the module
docstring's delegation note), the overall W dimension on the left, the
overall L label at the panel's bottom-left, openings/reveals/solid-zones
to scale with their own locating dimension chains (along-length from the
top, across-width from the left), and the cut line's tick + station
label on top of it. Never raises, and never returns an empty canvas.
`values` is the whole form's dict, read the same way every other drawing
function in this codebase reads it (`.get()` only, never mutated)."""
values = values or {}
problems = []
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
if length_ft is None or length_ft <= 0:
problems.append(
'Length (ft) is missing or not a positive number; the outline '
'is waiting on it.')
if width_ft is None or width_ft <= 0:
problems.append(
'Width (ft) is missing or not a positive number; the outline '
'is waiting on it.')
if problems:
return _center_note(width_px, height_px, problems[0]), problems
geo = outline_geometry(values, width_px, height_px)
ox, oy = geo['x0'], geo['y0']
draw_w = geo['x1'] - geo['x0']
draw_h = geo['y1'] - geo['y0']
primitives = [{'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w,
'h': draw_h, 'role': 'panel'}]
if panelsketch is not None:
title_fn = getattr(panelsketch, '_title_primitive', None)
if title_fn is not None:
try:
title = title_fn(values.get('mark'), values.get('design_number'))
except Exception:
title = None
if title:
primitives.append(title)
# ---- W dimension chain, left of the panel, rotated label (font >= 12)
dim_x = max(ox - 20.0, 2.0)
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': max(dim_x - 14.0, 2.0),
'y': oy + draw_h / 2.0,
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical', 'font_size': 12})
# ---- L label, bottom-LEFT under the panel (never centred -- see the
# module docstring's "WHERE THE CUT AND L LABELS SIT" note), with a
# full-width leader line so it still reads as dimensioning the whole
# span even though the text itself sits at the left corner.
leader_y = oy + draw_h + 6.0
primitives.append({'kind': 'line', 'x1': ox, 'y1': leader_y,
'x2': ox + draw_w, 'y2': leader_y,
'role': 'dimension'})
primitives.append({'kind': 'text', 'x': ox, 'y': leader_y + 8.0,
'text': 'L = {0:.2f} ft'.format(length_ft),
'role': 'dimension', 'anchor': 'start',
'font_size': 12})
# ---- openings/reveals/solid-zones, to scale, plus locating chains --
# reused row-geometry from `panelsketch`, see the module docstring.
if panelsketch is None:
problems.append(
'vibe.panelsketch could not be imported; openings/reveals/'
'solid zones cannot be drawn on the outline.')
else:
rows_fn = getattr(panelsketch, '_openings_rows', None)
rect_fn = getattr(panelsketch, '_opening_rect_primitives', None)
chain_fn = getattr(panelsketch, '_gap_dimension_chain', None)
if None in (rows_fn, rect_fn, chain_fn):
problems.append(
'vibe.panelsketch is missing one of _openings_rows / '
'_opening_rect_primitives / _gap_dimension_chain; openings '
'cannot be drawn on the outline.')
else:
try:
rows, row_problems = rows_fn(values, length_ft, width_ft, None)
except Exception as exc: # never take the page down
rows, row_problems = None, [
'panelsketch openings/reveals/solid zones raised: '
'{0}'.format(exc)]
problems.extend(row_problems)
if rows:
try:
opening_prims, opening_problems = rect_fn(
rows, length_ft, width_ft, ox, oy, draw_w, draw_h)
except Exception as exc:
opening_prims, opening_problems = [], [
'panelsketch opening rectangles raised: '
'{0}'.format(exc)]
primitives.extend(opening_prims)
problems.extend(opening_problems)
along_ranges = [r['along'] for r in rows
if r['vert'] is not None]
vert_ranges = [r['vert'] for r in rows
if r['vert'] is not None]
if along_ranges:
# from the START, along the TOP -- above the panel, not
# below it, so the bottom stays clear for the L label.
# Sits BELOW the cut-station label (closer to the panel
# edge) with its own 4px floor, same reasoning as
# `_cut_marker_primitives`'s `label_y`.
top_chain_y = max(oy - 18.0, 4.0)
primitives.extend(chain_fn(
along_ranges, length_ft,
lambda lo: (ox + (lo / length_ft) * draw_w,
top_chain_y),
lambda hi: (ox + (hi / length_ft) * draw_w,
top_chain_y),
'{0:.2f} ft'))
if vert_ranges:
# from the LEFT, along the left side -- further out than
# the W dimension chain itself.
chain_x = max(dim_x - 30.0, 2.0)
primitives.extend(chain_fn(
vert_ranges, width_ft,
lambda lo: (chain_x,
oy + draw_h - (lo / width_ft) * draw_h),
lambda hi: (chain_x,
oy + draw_h - (hi / width_ft) * draw_h),
'{0:.2f} ft'))
elif rows is None:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
problems.append(
'Openings/reveals were entered as plain text, not '
'the structured rows this drawing needs to locate '
'them: {0!r}'.format(str(openings_text).strip()))
# ---- the cut line's tick + station label, on top of everything else
primitives = primitives + _cut_marker_primitives(
geo, length_ft, _cut_fraction(values))
clamp = getattr(panelsketch, '_clamp_primitive', None) \
if panelsketch is not None else None
if clamp is not None:
primitives = [clamp(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
def section(values, width_px, height_px):
"""(primitives, problems) -- the cross-section AT the cut line's current
station: a full width_ft x thickness_in rectangle, a Sandwich section's
wythe/insulation layers (same layering `panelsketch.extents_section()`
draws), any `Opening` row whose along-length extent contains the cut
station drawn as a full-depth gap at its across-width position, any
`Reveal` row at the station drawn as a partial-depth notch cut in from
its recorded face, dimension labels (`t = ... in`, `W = ... ft`), the
station itself (below the section, not on it), and the net concrete area
at that station in square inches. `Solid Zone` rows are named in
`problems` when they fall at the station but never drawn as a gap -- a
solid zone removes nothing, by definition, the same rule `panelsketch.py`'s
own cross-section and plan functions already follow. UNCHANGED by the
2026-09-04 outline rewrite (see the module docstring) -- this drawing
already fills its own width and its station label already sits below the
section, not on it. Never raises, never returns an empty canvas."""
values = values or {}
if panelsketch is None:
return (_center_note(
width_px, height_px,
'panelsketch is not available; the cross-section cannot be '
'drawn.'),
["vibe.panelsketch could not be imported; the cross-section "
"is waiting on it."])
width_ft = _safe_num(values.get('width_ft'))
thickness_in = _safe_num(values.get('thickness_in'))
problems = []
if width_ft is None or width_ft <= 0:
problems.append(
'Width (ft) is missing or not a positive number; the '
'cross-section is waiting on it.')
if thickness_in is None or thickness_in <= 0:
problems.append(
'Thickness (in) is missing or not a positive number; the '
'cross-section is waiting on it.')
if problems:
return _center_note(width_px, height_px, problems[0]), problems
thickness_ft = thickness_in / 12.0
length_ft = _safe_num(values.get('length_ft'))
cut_frac = _cut_fraction(values)
station_ft = cut_frac * length_ft if length_ft is not None \
and length_ft > 0 else None
margin_fn = getattr(panelsketch, '_margin', None)
letterbox_fn = getattr(panelsketch, '_letterbox', None)
clamp_val = getattr(panelsketch, '_clamp', None)
clamp_prim = getattr(panelsketch, '_clamp_primitive', None)
parse_row = getattr(panelsketch, '_parse_opening_row', None)
if None in (margin_fn, letterbox_fn, clamp_val, clamp_prim, parse_row):
return (_center_note(
width_px, height_px,
'panelsketch is missing a helper this drawing needs; the '
'cross-section cannot be drawn.'),
['vibe.panelsketch is missing one of _margin / _letterbox / '
'_clamp / _clamp_primitive / _parse_opening_row.'])
primitives = []
margin = margin_fn(width_px, height_px)
avail_w = max(float(width_px) - 2.0 * margin, 1.0)
avail_h = max(float(height_px) - 2.0 * margin, 1.0)
# Room outside the outline: the thickness dimension to the left, a
# Sandwich section's wythe/insulation labels to the right -- the same
# split `panelsketch.extents_section()` uses for this identical picture.
left_room = 40.0
right_room = 90.0
draw_w, draw_h = letterbox_fn(
width_ft, thickness_ft,
max(avail_w - left_room - right_room, 1.0), avail_h)
ox = margin + left_room + (avail_w - left_room - right_room - draw_w) / 2.0
oy = margin + (avail_h - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w,
'h': draw_h, 'role': 'panel-section'})
# ---- Sandwich wythes / insulation -- same rule as `panelsketch.
# extents_section()` / `panelsketch.section()`: only drawn as layers
# when all three thicknesses are entered AND add up to the panel
# thickness within 1/8 in; otherwise reported and left as one outline.
section_type = str(values.get('section_type') or '').strip().lower()
if section_type == 'sandwich':
top_in = _safe_num(values.get('wythe_top_in'))
ins_in = _safe_num(values.get('insulation_in'))
bot_in = _safe_num(values.get('wythe_bottom_in'))
layers = (top_in, ins_in, bot_in)
if any(v is None or v <= 0 for v in layers):
problems.append(
'Sandwich section: the wythe / insulation / wythe '
'thicknesses are not all entered; the cross-section is '
'drawn as one outline.')
elif abs(sum(layers) - thickness_in) > 0.125:
problems.append(
'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not '
'add up to the {3:.2f} in thickness; drawn as one outline.'
.format(top_in, ins_in, bot_in, thickness_in))
else:
px_per_in = draw_h / thickness_in
y_cursor = oy + draw_h
for name, layer_in, role in (('wythe', bot_in, 'wythe'),
('insulation', ins_in, 'insulation'),
('wythe', top_in, 'wythe')):
layer_h = layer_in * px_per_in
y_cursor -= layer_h
primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor,
'w': draw_w, 'h': layer_h, 'role': role})
primitives.append({'kind': 'text', 'x': ox + draw_w + 6.0,
'y': y_cursor + layer_h / 2.0 - 6.0,
'text': '{0} {1:.2f} in'
.format(name, layer_in),
'role': 'layer-label'})
# ---- openings / reveals AT the cut station
removed_in2 = 0.0
if station_ft is None:
problems.append(
'Length (ft) is not entered; openings and reveals cannot be '
'located along the panel to know which fall at this cut '
'station, so none are drawn here.')
else:
raw_rows = values.get('openings_rows')
if isinstance(raw_rows, list):
for i, item in enumerate(raw_rows):
row, row_problems = parse_row(
item, i + 1, length_ft, width_ft, thickness_ft)
problems.extend(row_problems)
if row is None:
continue
a0, a1 = row['along']
if not (a0 <= station_ft <= a1):
continue # this row does not reach the cut station
if row['type'] == 'Solid Zone':
problems.append(
'Solid Zone row {0} ({1}) is at the cut station but '
'removes nothing, by definition; not drawn as a '
'gap.'.format(row['index'], row['label'] or
'unlabeled'))
continue
if row['vert'] is None:
problems.append(
'{0} row {1} ({2}) is at the cut station but has '
'no across-width position/extent entered; not '
'drawn on the cross-section.'
.format(row['type'], row['index'],
row['label'] or 'unlabeled'))
continue
v0, v1 = row['vert']
cv0 = clamp_val(v0, 0.0, width_ft)
cv1 = clamp_val(v1, 0.0, width_ft)
if cv1 <= cv0:
continue
gx = ox + (cv0 / width_ft) * draw_w
gw = ((cv1 - cv0) / width_ft) * draw_w
if row['type'] == 'Opening':
# through -- the full thickness is gone over this width
primitives.append({'kind': 'rect', 'x': gx, 'y': oy,
'w': gw, 'h': draw_h,
'role': 'opening'})
removed_in2 += (cv1 - cv0) * 12.0 * thickness_in
else: # Reveal -- partial-depth, from the recorded face.
# `row['cross']` is the through-thickness axis
# `panelsketch._parse_opening_row()` resolves from
# `pos_cross` / `width` / `pos_cross_from` -- ALWAYS as
# an absolute (lo, hi) measured from the near/"left"
# face at 0 up to the far/"right" face at thickness_ft,
# regardless of which face it was typed from (that is
# what `_resolve_axis_range()` normalises). This module
# draws cross=0 at the TOP of the section rectangle and
# cross=thickness_ft at the bottom -- an arbitrary but
# consistent choice, inherited from that function's own
# "medium confidence" axis call, not re-litigated here.
if row['cross'] is None:
problems.append(
'{0} row {1} ({2}) is at the cut station but '
'has no through-thickness position/depth '
'entered; not drawn on the cross-section.'
.format(row['type'], row['index'],
row['label'] or 'unlabeled'))
continue
c0, c1 = row['cross']
cc0 = clamp_val(c0, 0.0, thickness_ft)
cc1 = clamp_val(c1, 0.0, thickness_ft)
if cc1 <= cc0:
continue
gy = oy + (cc0 / thickness_ft) * draw_h
gh = ((cc1 - cc0) / thickness_ft) * draw_h
primitives.append({'kind': 'rect', 'x': gx, 'y': gy,
'w': gw, 'h': gh, 'role': 'reveal'})
removed_in2 += (cv1 - cv0) * 12.0 * (cc1 - cc0) * 12.0
else:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
problems.append(
'Openings/reveals were entered as plain text, not the '
'structured rows this drawing needs to locate them at '
'the cut station.')
# ---- dimensions and labels, OUTSIDE the outline
dim_x = ox - 20.0
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': dim_x - 12.0,
'y': oy + draw_h / 2.0,
'text': 't = {0:.2f} in'.format(thickness_in),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical', 'font_size': 12})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(oy - 16.0, 0.0),
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'font_size': 12})
# ---- the cut station label, BELOW the section -- never on it, and
# never on the outline's own cut line either (this is a different
# drawing, drawn once per station rather than dragged).
station_text = ('cut @ {0:.1f} ft'.format(station_ft)
if station_ft is not None
else 'cut station: length (ft) not entered')
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 16.0, 'text': station_text,
'role': 'cutline-label', 'anchor': 'middle',
'font_size': 11})
gross_in2 = width_ft * 12.0 * thickness_in
net_in2 = gross_in2 - removed_in2
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 32.0,
'text': u'Net area = {0:.1f} in²'
.format(net_in2),
'role': 'annotation', 'anchor': 'middle',
'font_size': 11})
primitives = [clamp_prim(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
# `panelsketch._num` is the same "a float, or None" reader this module would
# otherwise have to duplicate -- reused rather than re-derived, same as
# `_parse_opening_row` above, but resolved lazily (`_safe_num`, not a bare
# `panelsketch._num` alias) so a `panelsketch` import that is present but
# missing `_num` (mid-rewrite) degrades to a local copy instead of an
# `AttributeError` neither `outline()` nor `section()` would otherwise catch
# before it reached the caller.
def _safe_num(value):
fn = getattr(panelsketch, '_num', None) if panelsketch is not None \
else None
if fn is not None:
return fn(value)
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
# --------------------------------------------------- real-unit geometry (ft)
#
# Added for the HTML-page rewrite (`vibe.pa_pagespec`): the two functions
# below are UNRELATED to `outline_geometry()` above despite the similar name
# -- that one returns a PIXEL box for one canvas size, purely so the WPF
# cutline and `outline()`'s own static tick agree. These return real feet /
# inches, independent of any canvas size, Revit, or WPF, so a page with
# neither can lay out its own picture and intersect its own cut line exactly
# the way `section()` does today. `outline_geometry_ft()` reuses
# `panelsketch._openings_rows()` for the openings list -- the same row parse
# `outline()` itself draws from (see the module docstring's "THE IMPORT IS
# GUARDED" note for why every call into `panelsketch` here is via `getattr`,
# never a bare attribute access) -- so the JSON and the still-live WPF
# picture can never disagree about what an opening row means.
_FALLBACK_LENGTH_FT = 1.0
_FALLBACK_WIDTH_FT = 0.3
def outline_geometry_ft(values):
"""Real-world panel outline + openings, in feet -- independent of any
canvas size. Never raises, and -- matching this module's own "MISSING
GEOMETRY IS NEVER A BLANK CANVAS" rule, applied to DATA rather than a
drawing -- never returns a zero-area outline: `length_ft` / `width_ft`
missing or non-positive falls back to a small stand-in rectangle,
flagged `'degraded': True` and named in `problems`, so a page opened
before either is typed still has a real (if placeholder) shape to draw.
{'length_ft': float, 'width_ft': float, 'thickness_in': float|None,
'degraded': bool,
'outline': [[0,0], [L,0], [L,W], [0,W]], # ft, closed implied
'openings': [{'type', 'role', 'label', 'index',
'along': [lo, hi] | None, # ft, length axis
'vert': [lo, hi] | None, # ft, width axis
'cross': [lo, hi] | None}, # ft, thru-thickness
...],
'problems': [str, ...]}
`values` is read only, via `.get(...)`, and never mutated.
"""
values = values or {}
problems = []
length_ft = _safe_num(values.get('length_ft'))
width_ft = _safe_num(values.get('width_ft'))
thickness_in = _safe_num(values.get('thickness_in'))
degraded = False
if length_ft is None or length_ft <= 0:
problems.append(
"'length_ft' is missing or not a positive number; showing a "
'placeholder outline.')
length_ft = _FALLBACK_LENGTH_FT
degraded = True
if width_ft is None or width_ft <= 0:
problems.append(
"'width_ft' is missing or not a positive number; showing a "
'placeholder outline.')
width_ft = _FALLBACK_WIDTH_FT
degraded = True
rows = []
if panelsketch is None:
if isinstance(values.get('openings_rows'), list) and \
values.get('openings_rows'):
problems.append(
'vibe.panelsketch could not be imported; openings could '
'not be resolved into geometry.')
else:
rows_fn = getattr(panelsketch, '_openings_rows', None)
if rows_fn is None:
problems.append(
'vibe.panelsketch is missing _openings_rows; openings '
'could not be resolved into geometry.')
else:
try:
raw_rows, row_problems = rows_fn(values, length_ft,
width_ft, None)
except Exception as exc: # never take the page down
raw_rows, row_problems = None, [
'openings could not be read: {0!r}'.format(exc)]
problems.extend(row_problems)
for r in (raw_rows or []):
rows.append({
'type': r.get('type'), 'role': r.get('role'),
'label': r.get('label'), 'index': r.get('index'),
'along': list(r['along']) if r.get('along') else None,
'vert': list(r['vert']) if r.get('vert') else None,
'cross': list(r['cross']) if r.get('cross') else None,
})
return {
'length_ft': length_ft, 'width_ft': width_ft,
'thickness_in': thickness_in, 'degraded': degraded,
'outline': [[0.0, 0.0], [length_ft, 0.0],
[length_ft, width_ft], [0.0, width_ft]],
'openings': rows, 'problems': problems,
}
def _clamp_num(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
def _merge_ranges(ranges):
"""Sorted, overlap-merged `[lo, hi)` pairs -- shared by `section_at()`
so two opening rows that overlap at one station read as one void."""
clean = sorted((lo, hi) for lo, hi in ranges if hi > lo)
merged = []
for lo, hi in clean:
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append((lo, hi))
return merged
def section_at(geometry, x_ft):
"""The cross-section profile at along-length station `x_ft`, computed
from an `outline_geometry_ft()`-shaped dict ALONE -- no Revit, no
canvas, no `values`. THIS IS THE ALGORITHM THE HTML PAGE REIMPLEMENTS IN
JAVASCRIPT (`docs/pa-web-spec.md`'s "section-at-cut" section carries the
same steps in prose, and the two must be kept in step -- not just
agreeing on the panels this module's own tests happen to cover):
1. Clamp `x_ft` to `[0, geometry['length_ft']]`.
2. `voids` = the WIDTH-axis ranges (`[lo, hi]`, ft from the left) open
at this station: every opening row whose `type` is 'Opening' (a
Reveal or Solid Zone does not go through -- matches `section()`'s
own rule and `panelanalysisform`'s "'Opening' rows always go full
depth" note) whose `along` range covers `x_ft`, AND whose `vert`
range is known -- a row with `vert: None` cannot be localised
across the width and is reported in `problems` instead of guessed.
3. Merge overlapping/touching ranges and sort ascending, so two
openings that overlap at this station read as one void, never a
double-counted pair.
Returns `{'x_ft', 'width_ft', 'thickness_in', 'voids': [[lo, hi], ...],
'problems': [...]}`. Never raises: a malformed `geometry` (missing keys,
wrong types) degrades to the widest reasonable stand-in -- this runs on
every cutline drag, and a redrawn slice is not the place to surface a
Python-shaped error to a viewer.
"""
problems = []
geometry = geometry if isinstance(geometry, dict) else {}
length_ft = _safe_num(geometry.get('length_ft'))
if length_ft is None or length_ft <= 0:
length_ft = _FALLBACK_LENGTH_FT
width_ft = _safe_num(geometry.get('width_ft'))
if width_ft is None or width_ft <= 0:
width_ft = _FALLBACK_WIDTH_FT
thickness_in = _safe_num(geometry.get('thickness_in'))
x = _safe_num(x_ft)
if x is None:
problems.append('x_ft is not a number; using the panel midpoint.')
x = length_ft / 2.0
x = _clamp_num(x, 0.0, length_ft)
raw_ranges = []
for row in (geometry.get('openings') or []):
if not isinstance(row, dict):
continue
type_ = str(row.get('type') or 'opening').strip().lower()
if type_ not in ('opening', ''):
continue
along = row.get('along')
if not along or len(along) != 2 or along[0] is None or \
along[1] is None:
continue
lo, hi = along
if not (lo <= x <= hi):
continue
vert = row.get('vert')
if not vert or len(vert) != 2 or vert[0] is None or vert[1] is None:
problems.append(
"opening {0!r} crosses this station but has no across-"
'width position entered; not punched into the section.'
.format(row.get('label') or row.get('index')))
continue
raw_ranges.append((float(vert[0]), float(vert[1])))
voids = _merge_ranges(raw_ranges)
return {'x_ft': x, 'width_ft': width_ft, 'thickness_in': thickness_in,
'voids': [list(v) for v in voids], 'problems': problems}
@@ -0,0 +1,920 @@
# -*- coding: utf-8 -*-
"""The Panel Analysis free-body sketch -- pure geometry, both Pythons, no
Revit, no WPF.
WHY THIS EXISTS: the owner's ruling, 2026-09-04, on the Structural Model
tab's headless render: "restrain & support 的那个示意图,本质上是一个
freebody,用一条直线来代替 member,同时显示 restrain 的位置,以及 loading" --
the diagram this section needs is a FREE-BODY, not the four-panel elevation
`panelsketch.elevation()` used to draw here (front + side + section
properties + cross section, three tiny sub-drawings and a block of text
fighting for the same canvas -- the owner's other complaint the same day).
One horizontal line stands in for the member; the panel's elevation range
fills the page width; restraints sit at their true positions along it;
loads are drawn ON it, not beside it.
THE SEAM, same discipline as `panelsketch.py`: this module's job stops at a
flat list of drawing PRIMITIVES in a normalised, already-in-pixels
coordinate space. The WPF `Canvas` painting is the button's job, not this
module's -- see `pa_structural.py`'s `drawing` block, which only NAMES this
module and its `freebody` entry point; the renderer looks the function up
by name and calls it. Standard library only (`re` is not even needed here),
runnable under IronPython 2.7 inside Revit and plain CPython on the host
(see `tests/conftest.py`): no f-strings, no `pathlib`, no `dataclasses`, no
typing, no `enum`.
THE INPUT is `values`, the SAME dict `pa_structural.page()` reads from the
form. Two plain fields anchor the horizontal axis:
bottom_elevation_ft / top_elevation_ft
the elevation range the free-body's full width represents -- x maps
elevation bottom -> top, left -> right. NOT a hard stop the way
`panelsketch.sketch()`'s length/width are: a free-body with no
elevation range typed in yet still has to draw a member line (this
is the diagram restraints and loads get ADDED to as the form fills
in, not a diagram that appears once the form is finished), so a
missing or unreadable range falls back to whatever span the
restraint/load/wind rows already cover, and finally to a bare
0.0-1.0 ft placeholder -- reported in `problems` either way, never
silently accepted as "0 to 1 is the real answer".
The three row-tables read here are the CONTRACT `pa_structural.py`'s module
docstring documents: one row lives at `'<table>.<n>.<col>'`, and
`'<table>.<n>.present' == 'yes'` marks the row as existing (`row_ids` /
`row_key` below implement the same key arithmetic `pa_structural.py` does,
independently -- this module does not import `pa_structural` or
`panelanalysisform`, the same leaf-module discipline `panelsketch.py`'s own
docstring explains: a module three layers from Revit should not be breakable
by a change to a sibling's parsing rules).
restraint -> elevation_ft (num), type (one of 'Pin' / 'Fixed' /
'Free' / 'Floor tie', case-insensitive, blank treated as
unrecognised), gravity / thermal (unused here -- nothing
in the free-body content the owner specified draws them),
stiffness_kin (unused here).
conc_load -> label (text, defaults to 'P<i>' by draw order when
blank), elevation_ft (num, required to place the row --
a row missing it is skipped and reported), offset_in
(num; sign picks which face the arrow is drawn on --
>= 0 or blank draws on the outside face, above the
member line; < 0 draws on the inside face, below),
dead_k / live_k (num, shown as '--' when blank -- never
defaulted to 0, which is a real, asserted value).
wind_zone -> label (as above, defaults to 'W<i>'), from_elev_ft /
to_elev_ft (num, both required to place the row; drawn
swapped-and-flagged if `from` sits above `to`),
width_ft (num, shown as typed, '--' when blank),
pressure_psf / suction_psf (num, '--' when blank).
MISSING/UNREADABLE INPUT IS REPORTED, NEVER GUESSED. A row missing what it
needs to be PLACED (an elevation, a from/to pair) is skipped and named in
`problems`; a row missing what it needs to be LABELLED (dead/live/pressure/
suction/width) is still drawn, with '--' standing in for the missing
number -- inventing a load magnitude nobody typed would be lying with a
picture instead of a number, which `panelsketch.py`'s own docstring already
rules out for openings, and the same rule applies here.
PRIMITIVES are plain dicts, coordinates already in pixels, origin top-left,
y increasing downward (WPF's `Canvas` convention), every one of them clamped
so it lies inside `[0, width_px] x [0, height_px]`. The vocabulary is the
same one `panelsketch.py`'s `_clamp_primitive` and the button's
`_draw_primitive` already speak, restricted here to the five kinds this
drawing actually needs:
{'kind': 'line', 'x1','y1','x2','y2', 'role': ...}
the member line itself (role 'member', carrying a 'width': 3.0 hint
-- see MEMBER LINE below); its two end ticks (role
'member-end-tick'); restraint hatch ticks; load and wind-zone arrow
shafts; the dimension chain between restraints; the wind-zone
extent bracket.
{'kind': 'rect', 'x','y','w','h', 'role': 'wind-zone-band', 'flagged': bool}
a thin band marking a wind zone's elevation extent, behind its row
of small arrows -- only drawn when the zone has a pressure or a
suction typed in (see WIND below).
{'kind': 'text', 'x','y', 'text', 'role': ..., 'anchor': 'middle'|'start'}
every label -- elevation end labels, restraint elevation/type,
load/wind-zone labels, dimension-chain spacings, and the one
"waiting on input" note this module ever draws on the canvas
itself (see NEVER RAISES below). `anchor` is 'middle' for labels
centred on a point (restraints, wind zones, dimension spacings,
elevation ends) and 'start' for a load label, which prints to the
RIGHT of its arrow rather than centred on it.
{'kind': 'circle', 'cx','cy','r', 'role': ..., 'flagged': bool}
a restraint whose type is 'Free' or unrecognised -- there is no
restraint SYMBOL to draw (a Free restraint restrains nothing), but
its position is still real data, marked with a small dot rather
than invented as a triangle or hatch it is not.
{'kind': 'polygon', 'points': [(x,y), ...], 'role': ..., 'flagged': bool}
the Pin / Floor-tie restraint triangle, and every arrowhead (loads,
wind-zone arrows).
MEMBER LINE: full canvas width less the margin (see `_margin` -- floored at
70 px specifically so the elevation end labels have room and never clip),
drawn 3 px dark with a short tick at each end. The end labels sit just
above those ticks, at the member's own end x-coordinates rather than
centred on the canvas edge -- centring "Bottom: -2.00 ft" ON the margin
boundary used to push half the text off the canvas (reported as "Bottom:"
rendering as "ottom:"); anchoring it at the member end and trusting the
70 px margin to hold the whole label is what fixes it.
`flagged` marks a primitive drawn from data that needed clamping or
correcting to be drawn at all (a restraint outside the elevation range, a
wind zone with `from` above `to`) -- present and visibly wrong, never
silently dropped, the same convention `panelsketch.py` uses for an
out-of-range support.
RESTRAINT SYMBOLS, coordinator follow-up on the live render, 2026-09-04:
16 px tall (`_RESTRAINT_TRI_DROP` / `_RESTRAINT_FREE_R`), apex/top touching
the member line, dropping below it. Every one of the three symbol kinds
(Pin/Floor-tie `polygon`, Fixed hatch `line`s, Free/unrecognised `circle`)
now carries `'stroke': '#333'`, `'fill': '#888'` and `'width': 2.5` --
CONFIRMED against the button's `_draw_primitive`, 2026-09-04: it reads
`'width'` on a `line` primitive already (the Fixed hatch's thickened tick
marks are real today), but does not handle `kind == 'circle'` or
`kind == 'polygon'` AT ALL -- not "ignores fill/stroke", the whole branch
is absent, so the Pin/Floor-tie triangle and the Free/unrecognised dot do
not draw regardless of what hints they carry. `'stroke'`/`'fill'`/`'width'`
on those two kinds are written now so nothing has to change here the day
the button's renderer is taught them; until then only the Fixed hatch's
extra thickness is visible. This is a renderer-side gap (the WPF button's
`script.py`, out of this module's ownership), reported rather than worked
around by changing what kind of primitive a restraint symbol is.
`_RESTRAINT_STROKE` / `_RESTRAINT_FILL` / `_RESTRAINT_WIDTH` are shared by
all three symbol kinds so "equally dark" (the coordinator's own phrase for
the Fixed block and Free circle) is one constant, not three copies that
can drift.
WIND, drawn in its own row above the loads (see LAYOUT ROWS below): a zone
with neither `pressure_psf` nor `suction_psf` entered draws no band and no
arrows -- there is nothing to point at yet -- just one small label reading
"<name>: no pressure entered yet". Never the em-dash form ("W1: +— / -—
psf...") that used to print when both were blank; a single em-dash for
just one of the two (one entered, one not) is still the ordinary
MISSING/UNREADABLE handling above and prints fine.
LAYOUT ROWS, top to bottom, packed in fixed pixel amounts rather than a
fraction of `height_px` (see the `_TOP_PAD`.. constants): wind band, loads,
the member with its restraints on it, restraint labels, the dimension
chain. Packing tight from y=0 is the fix for the owner's headless-render
finding, 2026-09-04, that the old fraction-of-height midpoint left roughly
40% of the canvas blank above the member -- nothing was actually drawn in
that reserved space. The restraint-label row and the dimension-chain row
below the member are packed the same way: `_RESTRAINT_LABEL_GAP` (4 px)
puts the label directly under the 16 px symbol, `_DIM_ROW_GAP` (22 px)
puts the dimension chain that far below the label row -- a same-day
follow-up on the SAME render, which found roughly 45% of the canvas empty
below those two rows once the top rows were fixed. The canvas HEIGHT
itself is not this module's decision -- `pa_structural._freebody_aspect`
shrinks the `aspect` it passes in when a diagram has few or no
loads/wind zones, rather than this module stretching a fixed row spacing
to fill whatever height it is handed.
LABEL STACKING: two labels within 40 px of each other along x, in the same
channel (restraints below the line, loads/wind above it), would otherwise
print on top of each other -- `_destack` alternates every other close-by
label further from the line so both stay legible. See its docstring.
NEVER RAISES. `freebody()` is wrapped end to end: anything that gets past
the row-parsing degrades (a bad row is skipped and reported, never a
crash), and the one path left that could still raise -- a genuinely
unexpected exception -- is caught at the top level and turned into a single
`text` primitive on the canvas plus a `problems` sentence naming what broke,
so the page never goes blank and never takes the rest of the form down with
it. `values` is never mutated -- every access below is `.get(...)`.
"""
_MARGIN_FRACTION = 0.14
_MARGIN_MIN_PX = 70.0 # end labels must fit inside this, never clipped
_MARGIN_MAX_PX = 110.0
_ARROW_LEN = 32.0 # px, concentrated-load arrow shaft (>= 30 px)
_ARROW_HEAD = 5.0 # px, arrowhead half-width/length
_WIND_ARROW_LEN = 16.0 # px, wind-zone arrow shaft (shorter, banded)
_WIND_ARROWS_MIN = 3
_WIND_ARROWS_MAX = 7
_RESTRAINT_TRI_HALF = 7.0
_RESTRAINT_TRI_DROP = 16.0 # restraint symbol height -- 16 px tall
_RESTRAINT_FREE_R = 8.0 # free/unrecognised dot radius -- 16 px across
_RESTRAINT_STROKE = '#333' # dark outline hint -- see RESTRAINT SYMBOL
_RESTRAINT_FILL = '#888' # mid-grey fill hint, same primitives
_RESTRAINT_WIDTH = 2.5 # px -- outline-thickness hint; also what
# actually renders today (see below)
_STACK_THRESHOLD = 40.0
_STACK_STEP = 13.0
# Vertical layout, stacked from the top -- see `_freebody`'s LAYOUT ROWS
# note: wind band, then loads, then the member (with restraints on it),
# then restraint labels, then the dimension chain. Fixed pixel amounts, not
# a fraction of `height_px`: the owner's headless render, 2026-09-04, found
# the old fraction-of-height midpoint left ~40% of the canvas blank above
# the member because nothing was actually drawn up there -- packing the
# rows tight from y=0 uses that space instead of reserving it and leaving
# it empty.
_TOP_PAD = 6.0 # px above the wind row's own label
_WIND_LABEL_H = 8.0 # px reserved for the wind row's text baseline
_WIND_ROW_GAP = 6.0 # px between the wind label and its arrow row
_LOAD_ROW_GAP = 6.0 # px between the wind arrows and the load arrows
_LOAD_LABEL_GAP = 6.0 # px between a load arrow's shaft and its label
_END_TICK_LEN = 6.0 # px, the member's end ticks above/below the line
_MEMBER_WIDTH = 3.0 # px -- "3-px dark line"; a hint for the WPF
# renderer (not this module's job), same
# convention as `flagged`
_RESTRAINT_LABEL_GAP = 4.0 # px, symbol bottom -> restraint-label row
_DIM_ROW_GAP = 22.0 # px, restraint-label row -> dimension row
# ------------------------------------------------------------------ parsing
def _num(value):
"""A float, or None. Accepts a number already, or text; blank -> None."""
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return None
try:
return float(text)
except (TypeError, ValueError):
return None
def _text_of(value):
if value is None:
return ''
try:
value.strip
except AttributeError:
try:
value = str(value)
except Exception:
return ''
return value.strip()
def _fmt_num(value, fmt='{0:.2f}'):
"""A formatted number, or the em-dash stand-in for "not entered" --
never a fabricated 0."""
if value is None:
return u'—'
return fmt.format(value)
def _clamp(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
# --------------------------------------------------------------------------
# row_ids / row_key -- the same contract `pa_structural.py` implements,
# independently (see the module docstring for why this module does not
# import that one). Kept identical on purpose.
# --------------------------------------------------------------------------
def row_key(table, n, col):
try:
from vibe import panelanalysisform as paf
return paf.row_key(table, n, col)
except (ImportError, AttributeError):
return '{0}.{1}.{2}'.format(table, n, col)
def row_ids(table, values):
"""Every PRESENT row id, ascending, as INT ids -- mirrors
`vibe.panelanalysisform.row_ids` exactly (see `pa_structural.row_ids`'s
docstring for why the fallback below matches it id-type and all)."""
try:
from vibe import panelanalysisform as paf
return list(paf.row_ids(table, values))
except (ImportError, AttributeError):
pass
values = values or {}
prefix = '{0}.'.format(table)
candidates = set()
for key in values:
if not isinstance(key, str) and not isinstance(key, type(u'')):
continue
if not key.startswith(prefix):
continue
n_txt = key[len(prefix):].split('.', 1)[0]
try:
n = int(n_txt)
except (TypeError, ValueError):
continue
if n > 0:
candidates.add(n)
out = [n for n in candidates
if str(values.get(row_key(table, n, 'present')) or '')
.strip().lower() == 'yes']
out.sort()
return out
def _row(table, n, values, cols):
"""One row's raw cell values, `{col: text}` -- `cols` names which
columns to read; every value comes back a stripped string (possibly
empty), never parsed here."""
out = {}
for col in cols:
out[col] = _text_of(values.get(row_key(table, n, col)))
return out
def _support_rows(values, problems):
"""[{'id', 'x_ft', 'elevation_ft'}, ...] -- the `support` row table
(owner ruling, 2026-09-05: bearing positions along the length, one
editable row each), converted to an ELEVATION the same way restraints
already are on this drawing (`bottom_elevation_ft + x_ft`) so a support
triangle sits on the same axis as everything else here. Falls back to
the legacy `supports_ft` comma-separated text field when the table has
no present rows at all -- a panel saved before this table existed.
`bottom_elevation_ft` missing/unreadable reports once and skips every
support (there is no axis to place them on), never a guessed 0.
"""
rows = row_ids('support', values)
xs = []
if rows:
for n in rows:
raw = _row('support', n, values, ('x_ft',))
x = _num(raw['x_ft'])
if x is None:
problems.append(
'Support row {0} has no readable position; not drawn '
'on the free body.'.format(n))
continue
xs.append(x)
else:
text = _text_of(values.get('supports_ft'))
if text:
for i, tok in enumerate(text.replace(',', ' ').split(), 1):
x = _num(tok)
if x is None:
problems.append(
'Support position {0!r} is not a number; not drawn '
'on the free body.'.format(tok))
continue
xs.append(x)
if not xs:
return []
bottom = _num(values.get('bottom_elevation_ft'))
if bottom is None:
problems.append(
"'bottom_elevation_ft' is missing or not a number; support "
'positions cannot be converted to an elevation and are not '
'drawn on the free body.')
return []
out = []
for i, x in enumerate(xs, 1):
out.append({'id': i, 'x_ft': x, 'elevation_ft': bottom + x})
return out
def _restraint_rows(values, problems):
rows = []
for n in row_ids('restraint', values):
raw = _row('restraint', n, values,
('elevation_ft', 'type'))
elev = _num(raw['elevation_ft'])
if elev is None:
problems.append(
'Restraint row {0} has no readable elevation; not drawn '
'on the free body.'.format(n))
continue
type_ = raw['type'].strip()
rows.append({'id': n, 'elevation_ft': elev, 'type': type_})
return rows
def _conc_load_rows(values, problems):
rows = []
for n in row_ids('conc_load', values):
raw = _row('conc_load', n, values,
('label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k'))
elev = _num(raw['elevation_ft'])
if elev is None:
problems.append(
'Concentrated load row {0} has no readable elevation; not '
'drawn on the free body.'.format(n))
continue
offset = _num(raw['offset_in'])
rows.append({'id': n, 'label': raw['label'].strip(),
'elevation_ft': elev, 'offset_in': offset,
'dead_k': _num(raw['dead_k']),
'live_k': _num(raw['live_k'])})
return rows
def _wind_zone_rows(values, problems):
rows = []
for n in row_ids('wind_zone', values):
raw = _row('wind_zone', n, values,
('label', 'from_elev_ft', 'to_elev_ft', 'width_ft',
'pressure_psf', 'suction_psf'))
frm = _num(raw['from_elev_ft'])
to = _num(raw['to_elev_ft'])
if frm is None or to is None:
problems.append(
'Wind zone row {0} has no readable from/to elevation; not '
'drawn on the free body.'.format(n))
continue
flagged = frm > to
if flagged:
frm, to = to, frm
problems.append(
'Wind zone row {0} has its from-elevation above its '
'to-elevation; shown swapped.'.format(n))
rows.append({'id': n, 'label': raw['label'].strip(),
'from_elev_ft': frm, 'to_elev_ft': to,
'width_ft': _num(raw['width_ft']),
'pressure_psf': _num(raw['pressure_psf']),
'suction_psf': _num(raw['suction_psf']),
'flagged': flagged})
return rows
# ------------------------------------------------------------------ layout
def _margin(width_px, height_px):
m = min(width_px, height_px) * _MARGIN_FRACTION
if m < _MARGIN_MIN_PX:
m = _MARGIN_MIN_PX
if m > _MARGIN_MAX_PX:
m = _MARGIN_MAX_PX
return m
def _elevation_span(values, restraint_rows, load_rows, wind_rows, problems):
"""(bottom, top) ft. Prefers `bottom_elevation_ft`/`top_elevation_ft`;
falls back to the span the rows already cover, and finally to a bare
placeholder -- see the module docstring's MISSING/UNREADABLE section."""
bottom = _num(values.get('bottom_elevation_ft'))
top = _num(values.get('top_elevation_ft'))
if bottom is None:
problems.append(
"'bottom_elevation_ft' is missing or not a number; the free "
'body is estimating its range from whatever rows are entered.')
if top is None:
problems.append(
"'top_elevation_ft' is missing or not a number; the free body "
'is estimating its range from whatever rows are entered.')
if bottom is not None and top is not None and top > bottom:
return bottom, top
if bottom is not None and top is not None and top <= bottom:
problems.append(
'Top elevation is not above bottom elevation; the free body '
'is estimating its range from whatever rows are entered '
'instead.')
elevs = []
for r in restraint_rows:
elevs.append(r['elevation_ft'])
for r in load_rows:
elevs.append(r['elevation_ft'])
for r in wind_rows:
elevs.append(r['from_elev_ft'])
elevs.append(r['to_elev_ft'])
if elevs:
lo, hi = min(elevs), max(elevs)
if hi <= lo:
hi = lo + 1.0
return lo, hi
problems.append(
'No elevation range and no rows entered yet; the free body shows '
'a placeholder 0.00-1.00 ft span.')
return 0.0, 1.0
def _destack(items, direction, threshold=_STACK_THRESHOLD,
step=_STACK_STEP):
"""Mutates each item's `'y'` in place so two labels within `threshold`
px of each other along x do not print on top of each other.
`items` is a list of dicts each already carrying `'x'` and a base
`'y'`. Walked in x order (a copy is sorted; the caller's own list order
is untouched); whenever one label sits closer than `threshold` to the
PREVIOUS one in that x order, it is pushed an extra `step` px further
from the member line (`direction` is +1 to push down, -1 to push up),
alternating so a run of three tight labels reads low/high/low rather
than climbing forever. Isolated labels (nothing within `threshold`) are
left at their base `y`.
"""
order = sorted(range(len(items)), key=lambda i: items[i]['x'])
toggle = False
prev_x = None
for idx in order:
item = items[idx]
if prev_x is not None and abs(item['x'] - prev_x) < threshold:
toggle = not toggle
if toggle:
item['y'] = item['y'] + direction * step
else:
toggle = False
prev_x = item['x']
return items
# --------------------------------------------------------------------- API
def freebody(values, width_px, height_px):
"""(primitives, problems) -- the free body: one line for the member,
restraints at their true positions, loads drawn on it. See the module
docstring for the full content and vocabulary. Never raises; always
returns at least the member line (see NEVER RAISES above)."""
try:
return _freebody(values, width_px, height_px)
except Exception as exc: # the backstop of last resort
w = float(width_px) if width_px else 300.0
h = float(height_px) if height_px else 100.0
prim = {'kind': 'text', 'x': _clamp(w / 2.0, 0.0, w),
'y': _clamp(h / 2.0, 0.0, h),
'text': 'Free-body drawing could not be produced: {0}'
.format(exc),
'role': 'note', 'anchor': 'middle'}
return [prim], ['free-body drawing failed: {0}'.format(exc)]
def _freebody(values, width_px, height_px):
values = values or {}
problems = []
try:
width_px = float(width_px)
except (TypeError, ValueError):
width_px = 300.0
try:
height_px = float(height_px)
except (TypeError, ValueError):
height_px = 120.0
if width_px <= 0.0:
width_px = 1.0
if height_px <= 0.0:
height_px = 1.0
restraint_rows = _restraint_rows(values, problems)
load_rows = _conc_load_rows(values, problems)
wind_rows = _wind_zone_rows(values, problems)
bottom, top = _elevation_span(values, restraint_rows, load_rows,
wind_rows, problems)
span = top - bottom
if span <= 0.0:
span = 1.0
top = bottom + 1.0
margin = _margin(width_px, height_px)
x_left = margin
x_right = width_px - margin
if x_right - x_left < 1.0:
x_left = width_px * 0.1
x_right = width_px * 0.9
if x_right <= x_left:
x_right = x_left + 1.0
def elev_x(elev_ft):
frac = (elev_ft - bottom) / span
frac = _clamp(frac, 0.0, 1.0)
return x_left + frac * (x_right - x_left)
# LAYOUT ROWS, stacked from the top (see the module docstring and the
# `_TOP_PAD`.. constants above): [wind band] [loads] [member+restraints]
# [labels] [dimension chain]. Each row's own top-left constant chains
# into the next, so `line_y` falls out of what actually has to be drawn
# above it rather than a fixed fraction of `height_px`.
wind_label_y = _TOP_PAD + _WIND_LABEL_H
wind_arrow_top_y = wind_label_y + _WIND_ROW_GAP
wind_arrow_bottom_y = wind_arrow_top_y + _WIND_ARROW_LEN
load_arrow_top_y = wind_arrow_bottom_y + _LOAD_ROW_GAP
line_y = load_arrow_top_y + _ARROW_LEN
load_base_y = load_arrow_top_y # load arrow-tail row
wind_base_y = wind_arrow_top_y # wind arrow-shaft top
# Restraint labels sit directly under the symbols (a small fixed gap,
# not a large one), and the dimension chain follows ~22 px below that
# -- coordinator, live headless render: with the old wider gaps
# (dim_y = restraint_label_y + 20 off of a smaller symbol) roughly the
# bottom 45% of the canvas sat empty below these two rows. See also
# `pa_structural._freebody_aspect`, which now shrinks the canvas
# itself for a diagram with few/no loads or wind zones instead of
# relying on this module to stretch content to fill a fixed height.
restraint_label_y = line_y + _RESTRAINT_TRI_DROP + _RESTRAINT_LABEL_GAP
dim_y = restraint_label_y + _DIM_ROW_GAP
primitives = []
# ---- the member line itself, full width less margin -- a 3-px dark
# line ('width' is a hint the renderer may not read yet, same
# convention as 'flagged') with a small tick at each end marking where
# the modelled member actually starts and stops.
primitives.append({'kind': 'line', 'x1': x_left, 'y1': line_y,
'x2': x_right, 'y2': line_y, 'role': 'member',
'width': _MEMBER_WIDTH})
for tick_x in (x_left, x_right):
primitives.append({'kind': 'line', 'x1': tick_x,
'y1': line_y - _END_TICK_LEN, 'x2': tick_x,
'y2': line_y + _END_TICK_LEN,
'role': 'member-end-tick'})
# End labels sit just above the ticks, positioned at the member's own
# ends and kept inside the margin reserved for them (`_margin`'s >= 70
# px floor) -- not centred exactly on the canvas edge, which is what
# used to clip "Bottom: -2.00 ft" down to "ottom:".
primitives.append({'kind': 'text', 'x': x_left,
'y': line_y - _END_TICK_LEN - 12.0,
'text': 'Bottom: {0:.2f} ft'.format(bottom),
'role': 'elevation-end', 'anchor': 'middle'})
primitives.append({'kind': 'text', 'x': x_right,
'y': line_y - _END_TICK_LEN - 12.0,
'text': 'Top: {0:.2f} ft'.format(top),
'role': 'elevation-end', 'anchor': 'middle'})
# ---- restraints: symbol at the line, label below
restraint_labels = []
for r in restraint_rows:
elev = r['elevation_ft']
flagged = elev < bottom or elev > top
if flagged:
problems.append(
'Restraint at {0:.2f} ft is outside the {1:.2f}-{2:.2f} ft '
'elevation range; shown clamped to the nearest end.'
.format(elev, bottom, top))
x = elev_x(elev)
type_lower = r['type'].strip().lower()
if type_lower in ('pin', 'floor tie'):
role = 'restraint-pin' if type_lower == 'pin' else \
'restraint-floortie'
points = [(x, line_y),
(x - _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP),
(x + _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP)]
# 'stroke'/'fill'/'width' are rendering hints, same convention
# as the member line's 'width' -- see RESTRAINT SYMBOLS in the
# module docstring for which of these the current WPF button
# actually reads today.
primitives.append({'kind': 'polygon', 'points': points,
'role': role, 'flagged': flagged,
'stroke': _RESTRAINT_STROKE,
'fill': _RESTRAINT_FILL,
'width': _RESTRAINT_WIDTH})
elif type_lower == 'fixed':
# a fixed-end hatch: three short ticks angled off the line,
# thickened ('width') so it reads as a dark hatched block
# rather than a hairline -- the renderer already honours
# 'width' on a 'line' primitive.
for dx in (-6.0, 0.0, 6.0):
primitives.append({
'kind': 'line', 'x1': x + dx, 'y1': line_y,
'x2': x + dx - 4.0, 'y2': line_y + _RESTRAINT_TRI_DROP,
'role': 'restraint-fixed', 'flagged': flagged,
'stroke': _RESTRAINT_STROKE, 'width': _RESTRAINT_WIDTH})
else:
# 'Free', blank, or unrecognised -- no restraint SYMBOL (there
# is nothing restraining here), just a location dot.
role = 'restraint-free' if type_lower == 'free' else \
'restraint-unknown'
# radius = half of _RESTRAINT_TRI_DROP, so the dot's top edge
# sits ON the line (same "on the line" placement as the
# triangle/hatch symbols) and its full height matches theirs.
primitives.append({'kind': 'circle', 'cx': x,
'cy': line_y + _RESTRAINT_FREE_R,
'r': _RESTRAINT_FREE_R, 'role': role,
'flagged': flagged,
'stroke': _RESTRAINT_STROKE,
'fill': _RESTRAINT_FILL,
'width': _RESTRAINT_WIDTH})
label_text = '{0:.2f} ft'.format(elev)
if r['type']:
label_text += u' — {0}'.format(r['type'])
restraint_labels.append({'x': x, 'y': restraint_label_y,
'text': label_text,
'role': 'restraint-label'})
_destack(restraint_labels, direction=1)
for lab in restraint_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'middle'})
# ---- dimension chain of restraint spacings, under the labels
placed = sorted(restraint_rows, key=lambda r: r['elevation_ft'])
for a, b in zip(placed, placed[1:]):
xa, xb = elev_x(a['elevation_ft']), elev_x(b['elevation_ft'])
primitives.append({'kind': 'line', 'x1': xa, 'y1': dim_y,
'x2': xb, 'y2': dim_y, 'role': 'dimension'})
spacing = b['elevation_ft'] - a['elevation_ft']
primitives.append({'kind': 'text', 'x': (xa + xb) / 2.0,
'y': dim_y + 4.0,
'text': '{0:.2f} ft'.format(spacing),
'role': 'dimension', 'anchor': 'middle'})
# ---- concentrated loads: an arrow onto the line, label at the tail
load_labels = []
for i, ld in enumerate(load_rows, 1):
x = elev_x(ld['elevation_ft'])
offset = ld['offset_in']
above = offset is None or offset >= 0.0
if offset is None:
problems.append(
'Load {0} has no offset entered; shown on the outside '
'face by default.'.format(ld['label'] or 'P{0}'.format(i)))
y_tail = line_y - _ARROW_LEN if above else line_y + _ARROW_LEN
primitives.append({'kind': 'line', 'x1': x, 'y1': y_tail,
'x2': x, 'y2': line_y, 'role': 'load-arrow'})
head_y = line_y - _ARROW_HEAD if above else line_y + _ARROW_HEAD
points = [(x, line_y), (x - _ARROW_HEAD, head_y),
(x + _ARROW_HEAD, head_y)]
primitives.append({'kind': 'polygon', 'points': points,
'role': 'load-arrowhead'})
name = ld['label'] or 'P{0}'.format(i)
offset_text = (u'{0:+.0f} in'.format(offset) if offset is not None
else u'—')
text = '{0}: D {1} k / L {2} k, e {3}'.format(
name, _fmt_num(ld['dead_k'], '{0:.1f}'),
_fmt_num(ld['live_k'], '{0:.1f}'), offset_text)
# To the RIGHT of the arrow shaft, level with its tail -- not
# stacked above/below the tail, which used to fight the arrowhead
# for the same few pixels right next to the member line.
load_labels.append({'x': x + _LOAD_LABEL_GAP, 'y': y_tail,
'text': text, 'role': 'load-label',
'_dir': -1.0 if above else 1.0})
# loads above and below the line stack independently (their base rows
# sit on opposite sides), so each is destacked against only its own
# side to avoid an above-line label being nudged toward a below-line
# neighbour it does not actually overlap.
above_labels = [l for l in load_labels if l['_dir'] < 0]
below_labels = [l for l in load_labels if l['_dir'] > 0]
_destack(above_labels, direction=-1)
_destack(below_labels, direction=1)
for lab in load_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'start'})
# ---- wind zones: a row of small arrows over the from/to extent, in
# its OWN band above the loads (previously the wind arrows shared the
# loads' vertical span and the two rows visually merged into one).
# A zone with neither pressure nor suction typed in yet draws no band
# and no arrows at all -- there is nothing to point at -- just a small
# label saying so; drawing a "+— / -—" arrow row would be inventing a
# pressure nobody entered.
wind_labels = []
for i, wz in enumerate(wind_rows, 1):
x0 = elev_x(wz['from_elev_ft'])
x1 = elev_x(wz['to_elev_ft'])
if x1 < x0:
x0, x1 = x1, x0
name = wz['label'] or 'W{0}'.format(i)
has_pressure = (wz['pressure_psf'] is not None or
wz['suction_psf'] is not None)
if has_pressure:
primitives.append({'kind': 'rect', 'x': x0,
'y': wind_arrow_bottom_y,
'w': max(x1 - x0, 1.0), 'h': 3.0,
'role': 'wind-zone-band',
'flagged': wz['flagged']})
span_px = x1 - x0
n_arrows = _WIND_ARROWS_MIN
if span_px > 0.0:
n_arrows = int(_clamp(round(span_px / 24.0),
_WIND_ARROWS_MIN, _WIND_ARROWS_MAX))
for j in range(n_arrows):
frac = 0.5 if n_arrows == 1 else j / float(n_arrows - 1)
ax = x0 + frac * span_px
primitives.append({'kind': 'line', 'x1': ax,
'y1': wind_arrow_top_y, 'x2': ax,
'y2': wind_arrow_bottom_y,
'role': 'wind-arrow'})
points = [(ax, wind_arrow_bottom_y),
(ax - 3.0, wind_arrow_bottom_y - _ARROW_HEAD),
(ax + 3.0, wind_arrow_bottom_y - _ARROW_HEAD)]
primitives.append({'kind': 'polygon', 'points': points,
'role': 'wind-arrowhead'})
text = '{0}: +{1} / -{2} psf, w {3} ft'.format(
name, _fmt_num(wz['pressure_psf'], '{0:.0f}'),
_fmt_num(wz['suction_psf'], '{0:.0f}'),
_fmt_num(wz['width_ft'], '{0:.1f}'))
else:
text = '{0}: no pressure entered yet'.format(name)
wind_labels.append({'x': (x0 + x1) / 2.0, 'y': wind_label_y,
'text': text, 'role': 'wind-label'})
_destack(wind_labels, direction=-1)
for lab in wind_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'middle'})
if not restraint_rows and not load_rows and not wind_rows:
primitives.append({'kind': 'text', 'x': (x_left + x_right) / 2.0,
'y': line_y + 30.0,
'text': 'No restraints or loads entered yet.',
'role': 'note', 'anchor': 'middle'})
primitives = [_clamp_primitive(p, width_px, height_px)
for p in primitives]
return primitives, problems
# --------------------------------------------------------------- clamping
def _clamp_primitive(prim, width_px, height_px):
"""Pulls one primitive's coordinates back inside the box, in place --
same discipline as `panelsketch._clamp_primitive`, extended for the
`circle` and `polygon` kinds this drawing also uses."""
kind = prim.get('kind')
if kind == 'rect':
x = _clamp(prim['x'], 0.0, width_px)
y = _clamp(prim['y'], 0.0, height_px)
w = prim['w']
h = prim['h']
if x + w > width_px:
w = width_px - x
if y + h > height_px:
h = height_px - y
if w < 0.0:
w = 0.0
if h < 0.0:
h = 0.0
prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h
elif kind == 'line':
prim['x1'] = _clamp(prim['x1'], 0.0, width_px)
prim['y1'] = _clamp(prim['y1'], 0.0, height_px)
prim['x2'] = _clamp(prim['x2'], 0.0, width_px)
prim['y2'] = _clamp(prim['y2'], 0.0, height_px)
elif kind == 'circle':
r = prim.get('r', 0.0)
prim['cx'] = _clamp(prim['cx'], r, max(width_px - r, r))
prim['cy'] = _clamp(prim['cy'], r, max(height_px - r, r))
elif kind == 'polygon':
prim['points'] = [(_clamp(px, 0.0, width_px),
_clamp(py, 0.0, height_px))
for px, py in prim.get('points', ())]
else:
if 'x' in prim:
prim['x'] = _clamp(prim['x'], 0.0, width_px)
if 'y' in prim:
prim['y'] = _clamp(prim['y'], 0.0, height_px)
return prim
# --------------------------------------------------- real-unit geometry (ft)
#
# Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no
# canvas size, no Revit -- just the same real-unit rows `freebody()` itself
# draws from, gathered under one dict a page with neither WPF nor a live
# model can still lay out. Built ENTIRELY from this module's own
# `_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` / `_elevation_
# span` -- the same four calls `freebody()` makes -- so the JSON and the
# still-live WPF picture can never disagree about what a row means.
def freebody_geometry(values):
"""`{'length_ft', 'bottom_elevation_ft', 'top_elevation_ft',
'restraints': [...], 'supports': [...], 'conc_loads': [...],
'wind_zones': [...], 'problems': [...]}` -- real feet / kips / psf, no
pixels.
`restraints` / `conc_loads` / `wind_zones` are exactly what
`_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` return (see
those functions' own docstrings for each row's shape); a row missing
the elevation it needs to be placed is skipped and named in `problems`,
the same "not drawn, and says why" rule those functions already follow.
`supports` (owner ruling, 2026-09-05) is `_support_rows`'s own
`{'id', 'x_ft', 'elevation_ft'}` list -- the `support` table's bearing
positions, converted to the same elevation axis restraints already
use, for the page's own "support triangles below the member,
restraints above" drawing rule. `bottom_elevation_ft` /
`top_elevation_ft` come from `_elevation_span`, which falls back to
the rows' own range, then to a bare 0.00-1.00 ft placeholder, when the
panel's own elevations are not entered -- never a crash, never an
empty span. Never raises; `values` is read-only.
"""
values = values or {}
problems = []
restraints = _restraint_rows(values, problems)
conc_loads = _conc_load_rows(values, problems)
wind_zones = _wind_zone_rows(values, problems)
supports = _support_rows(values, problems)
bottom, top = _elevation_span(values, restraints, conc_loads,
wind_zones, problems)
length_ft = _num(values.get('length_ft'))
return {
'length_ft': length_ft,
'bottom_elevation_ft': bottom, 'top_elevation_ft': top,
'restraints': restraints, 'supports': supports,
'conc_loads': conc_loads,
'wind_zones': wind_zones, 'problems': problems,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More