WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
@@ -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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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
|
||||
|
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.
|
||||
|
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.
|
||||
|
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}** {1}'.format(w['design'],
|
||||
specsave.link(w['path']))
|
||||
if w['design'] in copies:
|
||||
line += ' — copy: {0}'.format(
|
||||
specsave.link(copies[w['design']]))
|
||||
output.print_md(line)
|
||||
|
||||
if cancelled_save:
|
||||
output.print_md(
|
||||
'> No second copy was saved -- the save dialog was cancelled. '
|
||||
'Nothing was lost: every spec above is on disk at the path shown, '
|
||||
'which is where Ticket QC reads it from.')
|
||||
if in_place:
|
||||
output.print_md(
|
||||
'> {0}: you picked the file the spec is already saved as, so '
|
||||
'nothing further was written. The path above is it.'
|
||||
.format(', '.join(in_place)))
|
||||
if len(written) > 1 and copies:
|
||||
output.print_md(
|
||||
'> More than one design was extracted, so the FOLDER you chose '
|
||||
'was used and each spec kept its own design number as its file '
|
||||
'name -- that name is how everything downstream finds it.')
|
||||
for problem in copy_problems:
|
||||
output.print_md('> The copy could not be written — {0}'
|
||||
.format(problem))
|
||||
|
||||
output.print_md(
|
||||
'Then check the fields the reader did fill against the drawing. '
|
||||
'Ticket QC picks specs up automatically, from the paths above.')
|
||||
|
||||
# --------------------------------------------------------- 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}** — {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 `—` would render here too -- but a plain dash
|
||||
# is right in the panel AND in the .md, so it stays.
|
||||
title='{0} - what the reader could not place'.format(w['design']),
|
||||
summary=specsave.summary_line(entries, blanks),
|
||||
problem=specsave.problem_sentence(entries, problems))
|
||||
reason = specsave.shared_reason(entries)
|
||||
if entries:
|
||||
# The mechanical reader gives every unplaced note the same reason.
|
||||
# Printed once it is an explanation; printed on all fourteen lines
|
||||
# it is what stops the list being read.
|
||||
if reason:
|
||||
section.md('_{0}_'.format(reason))
|
||||
for i, entry in enumerate(entries):
|
||||
text, why = entry
|
||||
line = '**{0}.** {1}'.format(i + 1, text)
|
||||
if why and not reason:
|
||||
line += ' — {0}'.format(why)
|
||||
section.md(line)
|
||||
else:
|
||||
section.md('Every text note on this design summary matched a '
|
||||
'rule. Nothing was dropped and nothing was left over.')
|
||||
if blanks:
|
||||
section.md(
|
||||
'**Still blank** — the summary filled none of these, '
|
||||
'and a check that reads one of them does not run: {0}'
|
||||
.format(', '.join(['`' + b + '`' for b in blanks])))
|
||||
if problems:
|
||||
section.md('**Validation:** ' + '; '.join(problems))
|
||||
# PLAIN PATH INSIDE THE FOLD, NOT A MARKDOWN LINK. `foldreport.md_html`
|
||||
# converts bold, code and italic and nothing else -- a markdown link
|
||||
# written here reaches the panel as the literal text `[path](file:///
|
||||
# ...)`, which is worse than no link at all. Measured on stubs,
|
||||
# 2026-08-30. The CLICKABLE link is in the list above, printed through
|
||||
# pyRevit's own markdown, which does render one.
|
||||
section.md('The spec: `{0}` (clickable in the list above)'
|
||||
.format(w['path']))
|
||||
if w['design'] in copies:
|
||||
section.md('Your copy: `{0}`'.format(copies[w['design']]))
|
||||
sections.append(section)
|
||||
|
||||
output.add_style(fold.STYLE)
|
||||
output.inject_script(fold.SCRIPT)
|
||||
opened = fold.sections_with_problems(sections)
|
||||
output.print_md('---')
|
||||
if opened:
|
||||
output.print_md(
|
||||
'**{0} of the {1} design(s) below opened themselves** — '
|
||||
'they have notes the reader could not place. The rest are '
|
||||
'folded; click a header to open one.'
|
||||
.format(len(opened), len(sections)))
|
||||
else:
|
||||
output.print_md(
|
||||
'**Nothing below needs you.** Every note on every summary matched '
|
||||
'a rule. The sections are folded; click a header to open one.')
|
||||
output.print_html(fold.html(sections))
|
||||
|
||||
if skipped:
|
||||
output.print_md('## Skipped')
|
||||
for design, why in skipped:
|
||||
output.print_md('- **{0}** - {1}'.format(design, why))
|
||||
|
||||
# ---------------------------------------------- review specs already on disk
|
||||
if existing:
|
||||
output.print_md('## Specs already on disk')
|
||||
rows = []
|
||||
for design in sorted(existing):
|
||||
spec = existing[design]
|
||||
problems = designspec.validate_spec(spec)
|
||||
row_cells = [design,
|
||||
spec.get('source', {}).get('extractor') or '?',
|
||||
spec.get('source', {}).get('reviewed_by') or 'NOT reviewed',
|
||||
'ok' if not problems else '; '.join(problems[:2]),
|
||||
str(len(spec.get('unresolved') or []))]
|
||||
# A spec on disk feeds Ticket QC whether this press wrote it or an
|
||||
# earlier one did, so the label belongs on the whole folder, not
|
||||
# only on what was extracted just now.
|
||||
if the_scope.is_declared:
|
||||
row_cells.append('OUT OF SCOPE' if out_of_scope(design) else '-')
|
||||
rows.append(row_cells)
|
||||
columns = ['design', 'extractor', 'reviewed by', 'validation',
|
||||
'unresolved notes']
|
||||
if the_scope.is_declared:
|
||||
columns.append('scope')
|
||||
output.print_table(rows, columns=columns)
|
||||
if the_scope.is_declared:
|
||||
output.print_md(
|
||||
'`scope` marks a spec whose design number falls outside {0}. '
|
||||
'Nothing is hidden and nothing is deleted -- Ticket QC will use '
|
||||
'these specs if it is pointed at those pieces, and the label is '
|
||||
'so that it is a decision rather than an accident.'
|
||||
.format(the_scope.label()))
|
||||
output.print_md(
|
||||
'`reviewed by` stays NOT reviewed until a person sets '
|
||||
'`source.reviewed_by` in the spec file by hand, which is the only '
|
||||
'thing that ever marks a spec as checked.')
|
||||
output.print_md(
|
||||
'`extractor` says which reader wrote the file: `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.
|
||||
|
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.
|
||||
|
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 '
|
||||
'— 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.
|
||||
|
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.
|
||||
|
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.
|
||||
|
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 — '
|
||||
'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
|
||||
|
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.
|
||||
|
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 '
|
||||
'— 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 '
|
||||
'— 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".
|
||||
|
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.
|
||||
|
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
|
||||
|
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.
|
||||
|
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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()))]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 `—`, ` `, `†` and `&` and nothing else, so a
|
||||
# `·` 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 — what is read-only and why, where each '
|
||||
'file lives, the whole provenance chain, the run-time options '
|
||||
'— 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}** — {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** — 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 — 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('|', '|')
|
||||
@@ -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
|
||||
@@ -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 += ' — ' + _text(section.problem)
|
||||
return head
|
||||
|
||||
|
||||
def _folded_header(section):
|
||||
bits = ['**' + _text(section.title) + '**']
|
||||
if section.summary:
|
||||
bits.append(_text(section.summary))
|
||||
return '- ' + _text(' — ').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 `—` 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('—', '--').replace(' ', ' ')
|
||||
.replace('†', '+').replace('&', '&'))
|
||||
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('—', '--').replace('†', '+')
|
||||
.replace(' ', ' ').replace('&', '&'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- 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 `—` 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">— ' +
|
||||
_entities(_escape(section.problem)) + '</span>')
|
||||
elif section.summary:
|
||||
head.append('— ' + _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 (`—`, `†`)
|
||||
# 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('&', '&').replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
|
||||
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 `—` (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('&' + name + ';', '&' + name + ';')
|
||||
return body
|
||||
@@ -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 [])
|
||||
@@ -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)))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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, []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||