WillTools: precast shop-ticket QC and panel handling tools
ci / test (push) Has been cancelled

Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
This commit is contained in:
2026-09-03 16:33:47 -05:00
commit 52d94303bc
281 changed files with 155656 additions and 0 deletions
+528
View File
@@ -0,0 +1,528 @@
# -*- coding: utf-8 -*-
"""The Panel Analysis form's "Handling" section, rendered as generic blocks.
WHY THIS EXISTS: the owner has now said three times that the Panel Analysis
input form is too ugly and unreadable to use ("太丑了", "你这么输入用户上哪儿能
看懂"), and the last ruling was blunt about the fix -- "全部给我做成普通人能看懂
的样子", make every part of it something an ordinary person can understand. The
"Handling" section is the worst offender: it used to be three lonely text
boxes (`strip_mult` / `transport_mult` / `erection_mult`) even though Eriksson
Wall's own printed page for this section is a whole per-stage MATRIX --
eleven questions asked once per handling stage, plus lifter/dunnage tables and
a plan drawing. `vibe.panelanalysisform` was rebuilt first (its own docstring
covers that ruling and the Eriksson-parity one before it) and now exposes a
real stage matrix (`STAGES`, `STAGE_OPTIONS`, `stage_matrix`,
`stage_field_key`). This module is the second half: turning that data plus
the plain-language field metadata into the flat, renderer-agnostic BLOCK list
a generic WPF page consumes, matching Eriksson's own page 25/26 layout
(Stage Options matrix + legend + weight/centroid + plan drawing + lifter and
dunnage tables + Lifting Hardware) as closely as the two modules' real data
will honestly support.
THE BLOCK VOCABULARY IS FIXED and belongs to the page renderer, not to this
module -- 'heading' / 'columns' / 'field' / 'matrix' / 'table' / 'legend' /
'kv' / 'sentence' / 'drawing' / 'rule' / 'space', nothing else. An unknown
`kind` renders as nothing, so a typo here is a silently blank section rather
than a crash; `page()` is tested against exactly that vocabulary for that
reason.
READ `vibe.panelanalysisform` DEFENSIVELY. Another agent owns that module and
was mid-restructure of exactly the stage matrix this file depends on while
this file was written. Every accessor this module leans on --
`STAGES` / `STAGE_OPTIONS` / `stage_field_key` / `FIELDS` / `label_of` /
`symbol_of` / `help_of` -- is reached through `getattr`/`hasattr` or a
try/except, never assumed. A shape that is not there yet (or has changed
shape) becomes one plain-English sentence in `problems`, and the rest of the
page still renders around the hole -- never a raised exception, and never an
invented value standing in for the one that could not be read. This is the
same "could not judge" vs "judged, found nothing" discipline as everywhere
else in this codebase (see CLAUDE.md SS3): a missing accessor is reported by
name, not silently skipped and not guessed at.
WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry
instead of a fabricated block: Eriksson's own "Handling" pages carry several
things `vibe.panelanalysisform` does not model yet --
* a per-lifter STRUCTURED row (quantity, longitudinal position, two
transverse positions, a tick per stage, "on top face") for Face / Side
Edge Lifters. The form only has `face_lifters` / `edge_lifters` as two
free-text fields ("column @ fraction of width", one line per lifter), so
the structured table is rendered as those two text fields instead of an
invented set of per-row keys nothing in the form can parse or validate.
* a dedicated "top edge lifters" field. There isn't one -- only
`face_lifters` / `edge_lifters` exist -- so the section renders a
sentence saying so, in the same voice Eriksson's own page uses for "no
top edge lifters on this design" (never a blank, per CLAUDE.md SS3's
"nothing here" vs "never asked" rule -- an omitted field and an empty
one must not look the same).
* a "Dunnage" field of any kind. Same treatment.
* the computed Panel Weight / Centroid block. `weight_lb` is a real field
(the user's override, or what the model could read) so its row renders
from `values`/`reads`; the three centroid components (X from start, Y
from bottom, Z from left) are not produced anywhere in this pipeline yet,
so those rows say so in plain words rather than printing a fabricated
0.000.
* the "Lifting Configurations" sketches on Eriksson's second page (one
small diagram per included stage, each with its own G/CG marker) and the
"Face Lifters / Name / Capacity Method" block above them. No
`panelsketch` entry point for either exists (only `plan` and
`weight_diagram` do, per the sibling agent adding them), and no
panelanalysisform field carries a lifter name or capacity-check method --
so both become one `problems` entry apiece and a short explanatory
sentence, not an invented drawing source or an invented field.
THE STAGE MATRIX'S "DOES NOT APPLY" COLUMNS are a STRUCTURAL fact about which
questions make sense for which stage, not a report of which stage happens to
be ticked on right now. Reading Eriksson's own page 25 by column: Rigging
Configuration / Sling Angle / Add Ground Reaction / Minimum-Maximum Tilt
Angle / Assume Top Edge Lifters Carry Panel Weight are answered only for the
two erection stages (Primary, Secondary) and dashed for Stripping and
Transportation in EVERY sample seen, independent of whether a given erection
stage is itself switched on -- a crane pick has a rigging configuration and a
tilt range; a stripping pull or a truck ride does not. `_ERECTION_ONLY` below
is that fixed structural map. It is deliberately NOT keyed off each stage's
own `include` flag: the owner's rule for `panelanalysisform._parse_stage_matrix`
is that switching a stage off must never make its OTHER cells un-requestable
or drop what the user already typed into them, and printing an em dash over a
cell the user could still type into would do exactly that. So the only cells
this module ever marks `{'na': True}` are ones no stage state can turn back
on; a merely-unticked stage's cells stay ordinary editable cells, matrix-wide.
Standard-library only. No Revit, no WPF, no `panelsketch` import (a source
name is enough -- see the block vocabulary's 'drawing' kind). Runs on
IronPython 2.7 in the button and on plain CPython in `tests/`: no f-strings,
no dataclasses/typing/enum, `'{0}'.format(...)` throughout.
"""
# Editor kind a FIELDS-style `kind` renders as, for the plain fields this
# section pulls straight from `vibe.panelanalysisform.FIELDS` (weight_lb,
# self_weight_factor, face_lifters, edge_lifters). Anything not listed here
# falls back to a free-text box -- 'supports' / 'openings' / 'strands' are
# all typed as structured-but-still-one-text-box fields on the real form, and
# nothing in the Handling section uses them, but the fallback keeps this
# honest if that ever changes.
_EDITOR_OF_KIND = {
'text': 'text',
'num': 'num',
'int': 'num',
'supports': 'text',
'openings': 'text',
'strands': 'text',
}
# Fallback choice vocabulary for the free-text stage-matrix options, used
# ONLY when `STAGE_OPTIONS`'s own row is the older 6-tuple shape with no
# choices slot of its own (see `_unpack_stage_option`). Once the row carries
# its own choices, that is authoritative and this is never consulted -- two
# sources of truth for the same list would drift. These are OFFERED choices,
# never a default -- the current value still comes from whatever is in
# `values[key]`, blank if nothing was typed. Picking one is the user's
# decision; the owner's instruction is explicit that none of them stand in
# as a default.
_STAGE_CHOICES_FALLBACK = {
'lift_type': ['Static Lift', 'Rolling Block'],
'concrete_strength': ['Initial', 'In-Place'],
'stress_limits': ['Handling', 'In-Place'],
'rigging': ['Auto'],
}
def _unpack_stage_option(opt):
"""(opt_key, label, symbol, help, kind, choices) from one STAGE_OPTIONS
row, or None if the row cannot be read at all.
Defensive against exactly the kind of mid-refactor shape change this
module was written under: `STAGE_OPTIONS` grew a seventh `choices` slot
on live disk between this module's first draft and this test run (a
7-tuple with the new slot, where the 6-tuple it replaced had none). Both
shapes are accepted; `choices` falls back to
`_STAGE_CHOICES_FALLBACK` only for the older shape, and is `None`/`[]`
for an option with no fixed vocabulary either way. `drives` (index 5 of
the 7-tuple) is not needed here -- NA-ness is decided structurally by
`_ERECTION_ONLY`, not by `drives` -- so it is simply not unpacked."""
try:
opt_key, label, symbol, help_text, kind, _drives, choices = opt
choices = list(choices) if choices else None
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
pass
try:
opt_key, label, symbol, help_text, kind, _drives = opt
choices = _STAGE_CHOICES_FALLBACK.get(opt_key)
return opt_key, label, symbol, help_text, kind, choices
except (TypeError, ValueError):
return None
# The stage-matrix options that only make sense for a stage where the panel
# is actually being picked up and set (Primary / Secondary Erection). See the
# module docstring's "STRUCTURAL fact" paragraph -- this is NOT about whether
# a given stage is ticked on.
_ERECTION_ONLY = set([
'rigging', 'sling_angle', 'ground_reaction',
'min_tilt_deg', 'max_tilt_deg', 'top_crane_carries',
])
_NOT_ERECTION_STAGES = set(['Stripping', 'Transportation'])
# Fields this section pulls straight out of `vibe.panelanalysisform.FIELDS`
# by key, grouped the way this page presents them. Every key here is checked
# against the real module before use -- see `_field_block`.
_LOAD_FACTOR_KEYS = ('weight_lb', 'self_weight_factor')
_LIFTER_TEXT_KEYS = ('face_lifters', 'edge_lifters')
def _import_paf():
"""The real `vibe.panelanalysisform`, or (None, reason).
A separate function so `page()` reads like the rest of this module: one
early check, one plain-English reason, never a raised ImportError.
"""
try:
from vibe import panelanalysisform as paf
except ImportError as exc:
return None, ('vibe.panelanalysisform could not be imported '
'({0}); the Handling section cannot be built at '
'all'.format(exc))
return paf, None
def _field_exists(paf, key):
"""Whether `key` is a real entry in `paf.FIELDS` right now.
Never assumes `FIELDS` itself is present or list-shaped -- the module
this reads from is being restructured by another agent as this is
written.
"""
fields = getattr(paf, 'FIELDS', None)
if not fields:
return False
try:
for f in fields:
if isinstance(f, dict) and f.get('key') == key:
return True
except TypeError:
return False
return False
def _field_kind(paf, key):
for f in getattr(paf, 'FIELDS', None) or []:
if isinstance(f, dict) and f.get('key') == key:
return f.get('kind') or 'text', bool(f.get('required'))
return 'text', False
def _field_block(paf, key):
"""A `{'kind': 'field', ...}` block for a real `panelanalysisform` key.
Caller must have already confirmed `_field_exists(paf, key)`. Labels,
symbols and hints all come from the module's own accessors rather than
being re-typed here, so this page can never drift from the field it is
describing -- and those accessors already degrade to the bare key if
something about the field record is missing, so this cannot raise.
"""
label = paf.label_of(key) if hasattr(paf, 'label_of') else key
symbol = paf.symbol_of(key) if hasattr(paf, 'symbol_of') else ''
hint = paf.help_of(key) if hasattr(paf, 'help_of') else ''
kind, required = _field_kind(paf, key)
editor = _EDITOR_OF_KIND.get(kind, 'text')
return {
'kind': 'field', 'key': key, 'label': label,
'symbol': symbol or None, 'hint': hint or None,
'required': required, 'editor': editor, 'choices': None,
}
def _row_label(label, symbol):
"""'label — symbol', matching `panelanalysisform.display_label`'s own
join -- the symbol rides beside the plain English, never replaces it."""
if symbol:
return u'{0} — {1}'.format(label, symbol)
return label
def _stage_options_editor(kind):
if kind == 'bool':
return 'check'
if kind == 'num':
return 'num'
return 'text'
def _stage_matrix_block(paf, problems):
"""The Stage Options matrix block, or None if it cannot be built.
Reads `STAGES` and `STAGE_OPTIONS` off `paf` defensively -- each missing
or malformed piece is one `problems` sentence naming it, and a row that
cannot be unpacked is skipped rather than raising, so a partial
restructure of the source module degrades this one matrix instead of the
whole page.
"""
stages = list(getattr(paf, 'STAGES', ()) or ())
if not stages:
problems.append('vibe.panelanalysisform has no STAGES list yet; '
'the Stage Options matrix cannot be built')
return None
options = getattr(paf, 'STAGE_OPTIONS', None)
if not options:
problems.append('vibe.panelanalysisform has no STAGE_OPTIONS list '
'yet; the Stage Options matrix cannot be built')
return None
stage_field_key = getattr(paf, 'stage_field_key', None)
if not callable(stage_field_key):
problems.append('vibe.panelanalysisform has no stage_field_key() '
'yet; the Stage Options matrix cells have no keys '
'to write to')
return None
rows = []
for opt in options:
unpacked = _unpack_stage_option(opt)
if unpacked is None:
problems.append('a Handling stage option row has an unexpected '
'shape ({0!r}); skipped'.format(opt))
continue
opt_key, label, symbol, help_text, kind, choices = unpacked
editor = _stage_options_editor(kind)
cells = []
for stage in stages:
if opt_key in _ERECTION_ONLY and stage in _NOT_ERECTION_STAGES:
cells.append({'na': True})
continue
try:
key = stage_field_key(stage, opt_key)
except Exception as exc:
# A broken key builder must not take the whole page down;
# name it and leave that one cell blank instead.
problems.append(
'could not build the form key for {0} / {1} ({2}); that '
'cell is left blank'.format(stage, label, exc))
cells.append(None)
continue
cells.append({'editor': editor, 'key': key, 'choices': choices})
rows.append({'label': _row_label(label, symbol),
'hint': help_text or None, 'cells': cells})
if not rows:
problems.append('every Handling stage option row was unreadable; '
'the Stage Options matrix has nothing to show')
return None
return {'kind': 'matrix', 'columns': stages, 'rows': rows}
def _legend_block():
"""Eriksson's own two-column legend, redrawn generically.
Shape names ('triangle', 'bar') and fill names ('blank', 'light',
'hatch', 'solid', 'tan') are markers for the renderer to draw, not
engineering values -- there is nothing here for `panelanalysisform` to
supply, so this block is static.
"""
return {
'kind': 'legend',
'groups': [
('Shapes', [
('triangle', 'Face lifter'),
('bar', 'Dunnage'),
]),
('Fills', [
('blank', 'Not specified'),
('light', 'Stripping'),
('hatch', 'Erection'),
('solid', 'Stripping + Erection'),
('tan', 'Transportation'),
]),
],
}
def _weight_centroid_block(paf, values, reads, problems):
"""The Panel Weight / Centroid kv block.
Weight comes from the real `weight_lb` field -- the user's override, or
whatever the model could read, exactly like every other prefilled field
on this form -- converted to kips to match Eriksson's own display. The
three centroid components are not computed anywhere in this pipeline
yet, so they say that in plain words rather than printing a fabricated
0.000; see the module docstring.
"""
weight_row = ('Panel weight', 'not entered yet -- computed from the '
'geometry above once you run the analysis')
if _field_exists(paf, 'weight_lb'):
text = (values.get('weight_lb') or '').strip()
if not text and reads:
text = paf._fmt(reads.get('weight_lb')) if hasattr(paf, '_fmt') \
else str(reads.get('weight_lb') or '')
if text:
try:
kips = float(text) / 1000.0
weight_row = ('Panel weight',
'{0:.3f} kips (as entered)'.format(kips))
except (TypeError, ValueError):
weight_row = ('Panel weight',
'{0} lb (as entered, not a plain number)'
.format(text))
else:
problems.append('vibe.panelanalysisform has no weight_lb field; '
'panel weight cannot be shown')
not_tracked = 'not tracked on this form yet'
problems.append('panel centroid (X from start / Y from bottom / Z '
'from left) is not computed anywhere in this pipeline '
'yet; the Panel Weight / Centroid block shows weight '
'only')
return {
'kind': 'kv',
'rows': [
weight_row,
('Centroid, X (from start)', not_tracked),
('Centroid, Y (from bottom)', not_tracked),
('Centroid, Z (from left)', not_tracked),
],
}
def _lifter_section_blocks(paf, problems):
"""Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three blocks.
Only `face_lifters` / `edge_lifters` are real fields; the structured
per-lifter table Eriksson prints (qty, two positions, a tick per stage)
has no backing data model here, and "top edge lifters" / "dunnage" have
no field at all. See the module docstring for why each of those is a
sentence and a `problems` entry rather than an invented table.
"""
blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters',
'level': 2}]
any_lifter_field = False
for key in _LIFTER_TEXT_KEYS:
if _field_exists(paf, key):
blocks.append(_field_block(paf, key))
any_lifter_field = True
else:
problems.append('vibe.panelanalysisform has no {0} field; that '
'lifter position cannot be entered'.format(key))
if any_lifter_field:
blocks.append({
'kind': 'sentence',
'text': ('Eriksson prints one row per lifter with a quantity, '
'two positions and a tick per stage; this form does '
'not yet parse the text above into that shape, so '
'each line is kept as typed.'),
})
blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2})
if _field_exists(paf, 'top_edge_lifters'):
# Not expected today, but if a future restructure adds the field,
# render it rather than silently keeping the old sentence.
blocks.append(_field_block(paf, 'top_edge_lifters'))
else:
problems.append('vibe.panelanalysisform has no top edge lifters '
'field; this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no top edge lifters.'})
blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2})
if _field_exists(paf, 'dunnage'):
blocks.append(_field_block(paf, 'dunnage'))
else:
problems.append('vibe.panelanalysisform has no dunnage field; '
'this section always reads as empty')
blocks.append({'kind': 'sentence',
'text': 'Design contains no dunnage entered.'})
return blocks
def _lifting_hardware_blocks(problems):
"""Eriksson page 26: the weight-distribution drawing plus a note on what
could not be carried over (see the module docstring's last bullet)."""
blocks = [
{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2},
{'kind': 'drawing', 'source': 'weight_diagram',
'caption': 'PANEL WEIGHT DISTRIBUTION', 'w': 760, 'h': 220},
]
problems.append(
'Eriksson also prints a small lifting-configuration sketch per '
'included stage (a G/CG marker over each stage\'s lift points) '
'and a Face Lifters name / capacity-check method block above the '
'weight distribution; neither has a panelsketch drawing source or '
'a panelanalysisform field yet, so neither is shown')
return blocks
def page(values, reads=None):
"""(blocks, problems) for the Handling section of the Panel Analysis form.
`values` is the form's current string-valued dict, read-only here -- this
function never assigns into it, so the caller's dict comes back
unchanged no matter what this renders. `reads` is the optional dict of
what the model could read off the framing element (the same shape
`panelanalysisform.prefill` takes); it is used only as a fallback for the
weight row when `values` itself is blank, and its absence never raises.
Returns a flat list of block dicts and a list of plain-English problem
sentences -- never raises, per the module docstring's "could not judge"
discipline. An empty `problems` list means every accessor this section
depends on was present and shaped as expected; a non-empty one names
every gap, so the caller can decide whether to surface it to the user
(a stale accessor is worth knowing about even though the page itself
still renders around it).
"""
problems = []
paf, reason = _import_paf()
if paf is None:
problems.append(reason)
return ([{'kind': 'heading', 'text': 'Handling', 'level': 1},
{'kind': 'sentence',
'text': ('The Handling section could not be built -- see '
'the problem above.')}],
problems)
values = values or {}
blocks = [{'kind': 'heading', 'text': 'Handling', 'level': 1}]
# --- Stage Options matrix -------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Stage Options', 'level': 2})
matrix_block = _stage_matrix_block(paf, problems)
if matrix_block is not None:
blocks.append(matrix_block)
else:
blocks.append({'kind': 'sentence',
'text': ('The per-stage handling options could not '
'be built -- see the problem above.')})
blocks.append({'kind': 'space', 'h': 8})
blocks.append(_legend_block())
blocks.append({'kind': 'space', 'h': 8})
# --- Panel Weight / Centroid ------------------------------------------
blocks.append({'kind': 'heading', 'text': 'Panel Weight & Centroid',
'level': 3})
blocks.append(_weight_centroid_block(paf, values, reads, problems))
blocks.append({'kind': 'rule'})
# --- Load factors (weight override / self-weight factor) --------------
load_factor_blocks = []
for key in _LOAD_FACTOR_KEYS:
if _field_exists(paf, key):
load_factor_blocks.append(_field_block(paf, key))
else:
problems.append('vibe.panelanalysisform has no {0} field'
.format(key))
if load_factor_blocks:
blocks.append({'kind': 'heading', 'text': 'Load Factors',
'level': 3})
blocks.extend(load_factor_blocks)
blocks.append({'kind': 'space', 'h': 8})
# --- plan drawing -------------------------------------------------
blocks.append({'kind': 'drawing', 'source': 'plan',
'caption': 'LIFTER AND DUNNAGE DETAIL', 'w': 760,
'h': 420})
blocks.append({'kind': 'space', 'h': 8})
# --- lifter / dunnage sections -----------------------------------
blocks.extend(_lifter_section_blocks(paf, problems))
blocks.append({'kind': 'rule'})
# --- Lifting Hardware (page 26) ------------------------------------
blocks.extend(_lifting_hardware_blocks(problems))
return blocks, problems