Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

1297 lines
63 KiB
Python

# -*- 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.
2026-09-04 RULING, THREE PARTS, all folded into this pass:
1. "handling 板块里面小灰字太多了,去掉放到 notes 里面,默认折叠" -- every
helper sentence this module used to print inline now carries
`'quiet': True` alongside its `kind: 'sentence'`. The block VOCABULARY
is unchanged (`_KNOWN_KINDS` in the test suite does not grow); `quiet`
is a hint for the renderer to route the sentence into the same
collapsed Notes panel `problems` already lands in, not a new kind. This
module does not (and cannot) verify the renderer honours the hint --
that is the renderer's job, tracked separately -- so every sentence
this module builds carries it regardless, and a page-import failure's
own explanation sentence is no exception (see `page()`'s early return).
2. "centroid 该算要算的" -- the Panel Weight / Centroid block now COMPUTES
the centroid instead of saying it is not tracked. The arithmetic lives
in the sibling `vibe.pa_sketch_handling` module (`centroid()`), shared
with the plan drawing's centroid marker so the two numbers can never
disagree; see that module's docstring for why Y is not mass-weighted
for a Sandwich section.
3. "lift&dunnage detail 画的完全不对...你总是横着画" -- the LIFTER AND
DUNNAGE DETAIL drawing no longer names `panelsketch.plan` as its
`source`. It names `vibe.pa_sketch_handling.lifter_dunnage` instead (a
new `module` key on the `drawing` block, alongside `source` -- see
`page()`'s drawing block for the exact shape), full-width, with an
`aspect` computed from the panel's own length/width so the container
is sized to roughly match the drawing's length-fills-the-width layout
before the drawing itself ever runs.
2026-09-04 SECOND RULING, off a headless render of this tab against a real
wall (four edge lifters all sitting at `x == length_ft`), five more parts:
1. THE FACE / SIDE EDGE LIFTERS TABLE labelled every `edge_lifters` row
'Side Edge' by which FIELD it came from, never by where it actually
sits -- wrong for the four lifters at `x == length_ft`, which are
TOP-edge lifters. `_lifter_tables` now classifies through
`pa_sketch_handling.parse_edge_lifters()` (the SAME function the
drawing calls, via `_classified_edge_rows_for_table` /
`_edge_type_label`), so the table's Type column reads 'Face' / 'Side
Edge' / 'Top Edge' / 'Bottom Edge' and can never disagree with the
picture above it. `top_edge_lifters` / `side_edge_lifters` -- new
free-text fields the reads sibling supplies, same `'x_ft @ frac'`
grammar -- are read when present, ahead of re-classifying
`edge_lifters` by geometry.
2. THE DUNNAGE SENTENCE used to read "Design contains no dunnage
entered" unconditionally -- there has never been a `dunnage` FIELD,
so it never once looked at whether `values['dunnage']` or the reads
sibling's `values['dunnage_positions_ft']` actually held anything.
It now asks `pa_sketch_handling.dunnage_positions()` (see
`_lifter_section_blocks`), and the RED inline note the drawing itself
used to print under the picture for the same "nothing entered" case
is gone -- see that function's own docstring. There is now exactly
ONE quiet sentence about dunnage on the whole page.
3. THE OLD "PANEL WEIGHT DISTRIBUTION" `weight_diagram` DRAWING under
Lifting Hardware is gone -- it was a small fixed-size picture with no
real per-stage weight-split data behind it. `_lifting_hardware_blocks`
now builds a "Lifting hardware" TABLE (Family / Kind / Qty) off
`lifters_basis` (see `_parse_lifters_basis` /
`_lifting_hardware_table_block`) -- the sentence
`pa_lifters.read_lifters()` already writes naming each lifter FAMILY
and how many of each kind. Capacities are not tracked anywhere this
page can reach yet ("come later"), which is now the section's only
remaining `problems` gap.
4. THE 'SHAPES' LEGEND moved to sit directly under the plan drawing
(`page()` appends it right after the `drawing` block, before the
Face / Side Edge Lifters section) instead of under the Stage Options
matrix, and carries `'compact': True` -- see `_legend_block`.
5. THE PANEL WEIGHT / CENTROID kv block now PREFERS
`values`/`reads['weight_centroid_ft']` -- an `{'x', 'y', 'z'}` dict
the reads sibling supplies straight off the concrete solid's own
measured centroid, basis 'concrete solid centroid' -- over
`pa_sketch_handling.centroid()`'s rectangle-plus-openings arithmetic.
See `_weight_centroid_xyz`.
2026-09-04 THIRD RULING, off a LIVE render of the second ruling's own fix,
two parts:
1. THE 'TOP EDGE LIFTERS' AND 'DUNNAGE' HEADINGS rendered with nothing
visible under them -- both had exactly one child block, and it was a
`quiet` sentence, which the renderer routes OUT of inline view
entirely (into the collapsed Notes panel) -- so a heading whose only
content is a quiet sentence reads as an empty section, not a heading
with a quiet note under it. `_lifter_tables` now returns TWO tables
instead of one: `main_table` (Face + Side Edge rows only, matching
Eriksson's own "Face / Side Edge Lifters" table) and `top_edge_table`
(Top Edge + Bottom Edge rows), the latter printed under the SEPARATE
"Top Edge Lifters" heading by `_lifter_section_blocks` instead of
being folded into the first table. "Dunnage" gets the same treatment
-- `_dunnage_table_block` prints an actual positions table (one row
per distinct dunnage position) off `pa_sketch_handling.
dunnage_positions()` when there is anything to show. Both headings
still fall back to a field editor (if one ever exists) and finally to
ONE quiet sentence, but ONLY when there is genuinely nothing of
either kind -- never a bare heading.
2. THE PANEL WEIGHT / CENTROID kv block had `Y (from bottom)` and `Z
(from left)` SWAPPED for a `weight_centroid_ft` read: the dict's own
axes are `{'x': along length, 'y': across width, 'z': through
thickness}` -- the MODEL's XYZ, not Eriksson's row labelling -- and
`_weight_centroid_xyz` used to read `dict['y']` straight into
`cg_y` (which the kv rows print as "from bottom", i.e. through
THICKNESS) and `dict['z']` into `cg_z` ("from left", i.e. across
WIDTH). It now returns `(cg_x, cg_y, cg_z)` ALREADY in the kv rows'
own axes -- `cg_y <- dict['z']`, `cg_z <- dict['y']` -- so
`_weight_centroid_block` never has to remember the crossover itself.
Read `_weight_centroid_xyz`'s own docstring before touching this
mapping again; getting it backwards is exactly what happened here.
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 first written, and that discipline has not relaxed since.
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. `vibe.pa_sketch_handling` (the centroid /
drawing sibling this module now depends on) and `vibe.pa_lifters` (the
lifter-line parser) are read the same defensively -- see `_import_psh` /
`_import_pa_lifters` -- because they are just as capable of being mid-edit
by another agent as `panelanalysisform` always was.
WHAT COULD NOT BE BUILT FROM REAL DATA, and why it is a `problems` entry
instead of a fabricated block -- shrunk twice since this module's first
draft (2026-09-04's first ruling closed the centroid and the drawing;
2026-09-04's second closed the edge-lifter grouping, the dunnage sentence
and the hardware table), but Eriksson's own "Handling" pages still carry
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 editing.
The form only has `face_lifters` / `edge_lifters` (plus, when the reads
sibling supplies them, `top_edge_lifters` / `side_edge_lifters`) as
free-text fields ("column @ fraction of width", one line per lifter);
this section SUMMARISES that text as an Eriksson-shaped table, grouped
into Face / Side Edge / Top Edge / Bottom Edge (see
`_lifter_tables`), but the underlying text fields are still what
the user actually edits, and stay on the page, visible, right below
the table.
* a dedicated "top edge lifters" FIELD on `panelanalysisform` itself.
There isn't one -- `top_edge_lifters` only ever arrives as a `values`
key the reads sibling fills in, never as a `panelanalysisform.FIELDS`
entry -- so the "Top Edge Lifters" heading falls back to an EDITABLE
field only if a future restructure adds one; today, with no field and
no top/bottom-edge lifter data either, it falls all the way to 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). When there IS top/bottom-edge data, the
heading gets a real table instead (see the THIRD ruling above) -- the
sentence is only the last resort, not the whole story.
* a "Dunnage" FIELD of any kind. There isn't one -- so the "Dunnage"
heading falls back to a positions TABLE (see `_dunnage_table_block`)
when `values['dunnage']` / `values['dunnage_positions_ft']` held
anything, off the same `pa_sketch_handling.dunnage_positions()` the
drawing itself calls, and only to the ONE quiet sentence when neither
did -- same "table first, sentence as the last resort" shape as Top
Edge Lifters above.
* the "Lifting Configurations" sketches on Eriksson's second page (one
small diagram per included stage, each with its own G/CG marker), and
a CAPACITY rating per lifter family. No `panelsketch` entry point for
the former exists, and nothing on this page's data path carries a
capacity number for the latter (the "Lifting hardware" table -- see
`_lifting_hardware_table_block` -- names each family and how many of
each kind, off `lifters_basis`, but stops there) -- so both remain one
`problems` entry apiece and a short explanatory (quiet) sentence, not
an invented drawing source or an invented number.
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.
STAGE-MATRIX EDITOR KINDS (owner, 2026-09-04: "很多东西应该是选项,而不是让用户
填写") -- `_stage_options_editor` already upgrades every row that carries a
fixed vocabulary (`lift_type` / `concrete_strength` / `stress_limits` /
`rigging`) to a `choice` editor, and `include` is always a `check`; this is
unchanged by the ruling above, kept here as confirmation, not as new work.
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.
"""
import re
# `feet_str` (decimal feet -> '8\'-4 1/2"') -- owner ruling, every length
# this tool displays is feet-inches, never decimal feet (CLAUDE.md /
# `docs/pa-web-spec.md`). Shared with `vibe.pa_extents`, which had the
# identical need first (2026-09-04) -- see `vibe.pa_units`'s own docstring
# for why the rounding lives in one place rather than two copies that can
# drift. Pure standard library, no Revit, safe to import at module scope.
from vibe.pa_units import feet_str as _feet_str
# 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 _num(value):
"""A float, or None. Accepts a number already, or text; blank -> None.
Small and local rather than imported -- see the module docstring's "no
panelsketch import" rule, which this module extends to its own tiny
parsing needs (the drawing `aspect` and a table's width_ft) the same
way `panelsketch.py` keeps its own `_num` rather than importing one."""
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 _clamp(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
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')
# 2026-09-04: the Face / Side Edge Lifters TABLE used to label every
# `edge_lifters` row "Side Edge" unconditionally, by which FIELD it came
# from -- a headless render against a real wall (four edge lifters all
# sitting at x == length_ft, i.e. TOP-edge lifters) caught that as wrong.
# The label now comes from `pa_sketch_handling.parse_edge_lifters()`'s
# actual classification (see `_edge_type_label` / `_lifter_tables`),
# never from the source field's name alone.
_EDGE_TYPE_LABEL = {('end', 'top'): 'Top Edge', ('end', 'bottom'): 'Bottom Edge',
('side', 'bottom'): 'Side Edge', ('side', 'top'): 'Side Edge'}
def _edge_type_label(kind, side):
"""'Top Edge' / 'Bottom Edge' / 'Side Edge' for one
`pa_sketch_handling.parse_edge_lifters()` row -- Eriksson's own three
groups (Face / Side Edge / Top Edge) plus the symmetric Bottom Edge
case that group naming does not otherwise cover. Never raises: an
unrecognised `(kind, side)` pair (should not happen -- see that
function's own docstring for the only values it returns) falls back to
the plain 'Edge' rather than a KeyError."""
return _EDGE_TYPE_LABEL.get((kind, side), 'Edge')
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 _import_psh():
"""The real `vibe.pa_sketch_handling` (centroid arithmetic + the plan
drawing), or (None, reason) -- same "never raise" discipline as
`_import_paf`. A sibling this module now depends on for its two
2026-09-04 additions (centroid, the plan drawing), so it gets the same
defensive treatment as every `panelanalysisform` accessor."""
try:
from vibe import pa_sketch_handling as psh
except ImportError as exc:
return None, ('vibe.pa_sketch_handling could not be imported '
'({0}); the panel centroid and the lifter/dunnage '
'drawing cannot be built'.format(exc))
return psh, None
def _import_pa_lifters():
"""The real `vibe.pa_lifters` (the lifter-line parser), or (None,
reason). Used only for `_lifter_tables`'s grouped summary table --
a missing/broken module degrades that ONE table to a `problems` entry,
never the whole page."""
try:
from vibe import pa_lifters
except ImportError as exc:
return None, ('vibe.pa_lifters could not be imported ({0}); the '
'Face / Side Edge Lifters table cannot be built (the '
'text fields below still work)'.format(exc))
return pa_lifters, 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 _quiet(text):
"""One `{'kind': 'sentence', ...}` block, always `'quiet': True` -- see
the module docstring's 2026-09-04 ruling #1. Every helper sentence this
module builds goes through this, never a bare sentence dict, so a
sentence added later cannot forget the flag."""
return {'kind': 'sentence', 'text': text, 'quiet': True}
def _stage_options_editor(kind, choices=None):
"""The matrix cell's editor kind: `check` / `num`, or `choice` for any
text row that carries its own fixed vocabulary (lift_type,
concrete_strength, stress_limits, rigging today), else plain `text`.
Matrix cells are built by `make_editor` directly (`_render_matrix`
bypasses `_render_field`'s registry-upgrade fallback -- see
`panelanalysisform.choices_for`'s docstring), so a row with real
`choices` must ask for `choice` explicitly here or it would render as a
free-text box forever, no matter what `FIELD_CHOICES` says (owner,
2026-09-04: "很多东西应该是选项,而不是让用户填写").
"""
if kind == 'bool':
return 'check'
if kind == 'num':
return 'num'
if choices:
return 'choice'
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, choices)
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():
"""The plan drawing's shape key, redrawn generically to match
`pa_sketch_handling.lifter_dunnage`'s glyph vocabulary (2026-09-04
ruling #3 replaced the drawing this legend describes, so the legend
changed with it -- a face lifter is a filled circle, an edge lifter a
triangle on the edge it sits on, dunnage a dashed transverse line; the
old Fills group described `panelsketch.plan`'s per-stage marker colors,
which the new drawing has no data to populate, so it is dropped rather
than kept as a key nothing on the page uses).
A SECOND 2026-09-04 ruling moved this block to sit directly under the
drawing (`page()` appends it immediately after the `drawing` block, no
heading or space between) and asks for it smaller -- `'compact': True`
is a rendering hint for that, the same "the renderer does not read it
yet, carried so a later renderer change needs no block-shape change"
status as `pa_sketch_handling`'s own `dashed` / `font_px` hints.
"""
return {
'kind': 'legend',
'compact': True,
'groups': [
('Shapes', [
(u'●', 'Face lifter'),
(u'▲', 'Edge lifter (side or top)'),
(u'- - -', 'Dunnage'),
]),
],
}
def _fmt_centroid(value, basis):
"""Feet-inches, never decimal feet (owner ruling -- every length this
tool displays is `8'-4 1/2"`, to 1/8"). `_feet_str` only fails (`None`)
when `value` itself is not a number; that case is folded into the same
'not entered yet' text a genuinely missing centroid already prints."""
if value is None:
return 'not entered yet'
text = _feet_str(value)
if text is None:
return 'not entered yet'
if basis:
return '{0} ({1})'.format(text, basis)
return text
def _weight_centroid_xyz(values, reads):
"""(cg_x, cg_y, cg_z) in ft, ALREADY in the kv block's own axes -- X
from the start (along length), Y from the bottom (through thickness),
Z from the left (across width) -- off `values['weight_centroid_ft']` /
(as a fallback) `reads['weight_centroid_ft']`.
THE DICT ITSELF USES A DIFFERENT AXIS ASSIGNMENT than the kv rows do,
and this is the one thing to get right here: the reads sibling writes
`{'x': along length, 'y': across width, 'z': through thickness}` -- the
MODEL's own XYZ, not Eriksson's row labelling. Reading `dict['y']`
straight into `cg_y` and `dict['z']` straight into `cg_z` (what an
identity mapping would do, and what this function did before
2026-09-04's THIRD ruling caught it on a live render -- "Centroid, Y
(from bottom)" printing the WIDTH figure and "Centroid, Z (from left)"
printing the THICKNESS figure) is backwards: `cg_y` (from bottom,
thickness) must come from `dict['z']`, and `cg_z` (from left, width)
from `dict['y']`. `dict['x']` (along length) DOES map straight to
`cg_x` -- only the Y/Z pair crosses over.
`None` unless all three components read as a number -- a partial dict
is not a centroid, and mixing a read axis with a computed one would be
exactly the kind of invented pairing CLAUDE.md SS3 rules out, so this
is all-or-nothing: either every axis comes from the same measured
source, or none does and `centroid()` computes the lot from geometry
instead."""
wc = values.get('weight_centroid_ft')
if wc is None and reads:
wc = reads.get('weight_centroid_ft')
if not isinstance(wc, dict):
return None
along_length = _num(wc.get('x'))
across_width = _num(wc.get('y'))
through_thickness = _num(wc.get('z'))
if along_length is None or across_width is None \
or through_thickness is None:
return None
return along_length, through_thickness, across_width
def _weight_centroid_block(paf, psh, 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
centroid (X from start / Y from bottom / Z from left, Eriksson's own
order) PREFERS `values`/`reads`' own `weight_centroid_ft` -- the reads
sibling's measured concrete-solid centroid, basis 'concrete solid
centroid' (2026-09-04) -- and falls back to
`pa_sketch_handling.centroid()`'s rectangle-plus-openings arithmetic
when that is not there (owner, 2026-09-04: "centroid 该算要算的") -- see
that function's docstring for the arithmetic and for why Y is never
mass-weighted for a Sandwich section. A row that could not be computed
either way says so in plain words rather than printing a fabricated
0.000.
"""
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')
cg_x = cg_z = cg_y = None
xz_basis = y_basis = None
measured = _weight_centroid_xyz(values, reads)
if measured is not None:
cg_x, cg_y, cg_z = measured
xz_basis = y_basis = 'concrete solid centroid'
elif psh is not None:
cg_x, cg_z, cg_y, xz_basis, y_basis, cg_problems = psh.centroid(values)
problems.extend(cg_problems)
else:
problems.append('the panel centroid could not be computed -- see '
'the vibe.pa_sketch_handling problem above')
return {
'kind': 'kv',
'rows': [
weight_row,
('Centroid, X (from start)', _fmt_centroid(cg_x, xz_basis)),
('Centroid, Y (from bottom)', _fmt_centroid(cg_y, y_basis)),
('Centroid, Z (from left)', _fmt_centroid(cg_z, xz_basis)),
],
}
def _num_or_none(v):
return _num(v)
def _fmt_transverse(fracs, width_ft):
"""'1'-6", 3'-2 3/8"' when `width_ft` is known, else '25%, 75%' -- the
frac values are what is actually typed (`values['face_lifters']` /
`values['edge_lifters']`, 'x_ft @ frac'); a real width converts them to
a distance because that is what "Transverse Position(s)" means on
Eriksson's own page, but the fraction alone is still shown rather than
blocking the whole row on a field that happens to be blank. A distance
prints feet-inches (owner ruling -- every length this tool displays is
`8'-4 1/2"`, never decimal feet); the fraction stays a percentage,
which is not a length at all."""
bits = []
for f in fracs:
if width_ft:
text = _feet_str(f * width_ft)
bits.append(text if text is not None else '{0:.2f} ft'.format(
f * width_ft))
else:
bits.append('{0:.0%}'.format(f))
return ', '.join(bits)
def _group_lifter_rows(rows):
"""[(x_ft, [frac, ...])], grouped by (rounded) x_ft, transverse fracs
kept in ascending order -- "grouping identical x into one row with its
transverse list" (owner, 2026-09-04). `rows` is the list of `{'x_ft',
'frac'}` dicts `pa_lifters.parse_lifter_lines` returns."""
groups = {}
order = []
for r in rows:
key = round(r['x_ft'], 3)
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(r['frac'])
order.sort()
return [(x, sorted(groups[x])) for x in order]
def _classified_edge_rows_for_table(psh, values, length_ft, width_ft,
problems):
"""[(x_ft, type_label, frac), ...] for every edge lifter, off
`pa_sketch_handling.parse_edge_lifters()` -- the SAME classifier the
drawing itself uses, so the table and the drawing can never disagree
about which group a lifter belongs to (2026-09-04: this is exactly the
bug a headless render caught -- the table used to label every
`edge_lifters` row 'Side Edge' by field name alone, even for the four
lifters sitting at `x == length_ft` the drawing correctly places on the
TOP edge). `[]`, with a `problems` note naming it, when `psh` is
unavailable or does not (yet) define `parse_edge_lifters` -- an older
`pa_sketch_handling` mid-refactor degrades this one grouping, never the
whole table."""
if psh is None:
return []
classify = getattr(psh, 'parse_edge_lifters', None)
if not callable(classify):
problems.append(
'vibe.pa_sketch_handling has no parse_edge_lifters() yet; the '
'Side Edge / Top Edge split cannot be built for the table')
return []
rows, edge_problems = classify(values, length_ft, width_ft)
problems.extend(edge_problems)
return [(r['x_ft'], _edge_type_label(r['kind'], r['side']), r['frac'])
for r in rows]
_MAIN_TABLE_TYPES = set(['Face', 'Side Edge'])
_TOP_EDGE_TABLE_TYPES = set(['Top Edge', 'Bottom Edge'])
_LIFTER_TABLE_HEADERS = ['Type', 'Qty', 'Longitudinal Position',
'Transverse Position(s)']
def _build_lifter_table(rows, width_ft):
"""One 'table' block off `[(x_ft, type_label, qty, fracs), ...]`, or
`None` for an empty list -- the shared row-formatting tail for both
tables `_lifter_tables` returns."""
if not rows:
return None
rows = sorted(rows, key=lambda r: (r[0], r[1]))
rows_out = []
for x_ft, type_label, qty, fracs in rows:
x_text = _feet_str(x_ft)
rows_out.append([type_label, str(qty),
x_text if x_text is not None
else '{0:.2f} ft'.format(x_ft),
_fmt_transverse(fracs, width_ft)])
return {'kind': 'table', 'headers': list(_LIFTER_TABLE_HEADERS),
'rows': rows_out}
def _lifter_tables(paf, psh, pa_lifters_mod, values, problems):
"""(main_table, top_edge_table) -- two 'table' blocks (Type, Qty,
Longitudinal Position, Transverse Position(s)), either `None` when
there is nothing parseable of that kind to show.
2026-09-04 THIRD ruling: these used to be ONE combined table holding
every lifter type, printed under the "Face / Side Edge Lifters"
heading -- which put a 'Top Edge' row under a heading that says 'Side
Edge Lifters', and left the separate "Top Edge Lifters" heading below
it with nothing but a quiet sentence (routed out of inline view) under
it, reading as an empty section on a live render. `main_table` now
holds ONLY Face + Side Edge rows (Eriksson's own "Face / Side Edge
Lifters" table shape); `top_edge_table` holds Top Edge + Bottom Edge
rows, printed by `_lifter_section_blocks` under the separate "Top Edge
Lifters" heading instead.
`face_lifters` parses with `pa_lifters.parse_lifter_lines`, the SAME
rule the prefill side writes its lines in (owner, 2026-09-04: "parse
with the same rule pa_lifters.lifter_lines writes"), so the table can
never show a different reading of the text than the field itself
holds. Edge lifters are classified by
`pa_sketch_handling.parse_edge_lifters()` (see
`_classified_edge_rows_for_table`) into 'Side Edge' / 'Top Edge' /
'Bottom Edge' groups -- reading `top_edge_lifters` / `side_edge_lifters`
when either carries text, else classifying `edge_lifters` by geometry,
exactly like the drawing. Both tables are read-only SUMMARIES; editing
stays in the text fields, which `_lifter_section_blocks` still appends
right after the main one -- "the fields must be visible and editable
on the page, not hidden."
"""
if pa_lifters_mod is None:
return None, None
width_ft = _num_or_none(values.get('width_ft'))
length_ft = _num_or_none(values.get('length_ft'))
combined = []
# Owner ruling, 2026-09-05: reads the `face_lifter` ROW TABLE (via
# `pa_sketch_handling.face_lifter_rows`, the SAME function the drawing
# reads) when it carries any row, falling back to the legacy
# `face_lifters` free-text field otherwise -- so the table and the
# drawing can never disagree, the same reasoning already applied to
# edge lifters below.
if psh is not None and hasattr(psh, 'face_lifter_rows'):
face_rows, face_problems = psh.face_lifter_rows(values)
if face_problems:
problems.append(
'Face lifters text could not be parsed as \'x_ft @ frac\' '
'lines ({0}); shown in the text field below, not in the '
'table.'.format('; '.join(face_problems)))
if face_rows:
row_dicts = [{'x_ft': x, 'frac': f} for x, f in face_rows]
for x_ft, fracs in _group_lifter_rows(row_dicts):
combined.append((x_ft, 'Face', len(fracs), fracs))
else:
face_text = values.get('face_lifters')
if face_text and str(face_text).strip():
rows, parse_problems = pa_lifters_mod.parse_lifter_lines(face_text)
if parse_problems:
problems.append(
'Face lifters text could not be parsed as \'x_ft @ '
'frac\' lines ({0}); shown in the text field below, '
'not in the table.'.format('; '.join(parse_problems)))
elif rows:
for x_ft, fracs in _group_lifter_rows(rows):
combined.append((x_ft, 'Face', len(fracs), fracs))
edge_triples = _classified_edge_rows_for_table(psh, values, length_ft,
width_ft, problems)
edge_groups = {}
edge_order = []
for x_ft, type_label, frac in edge_triples:
key = (round(x_ft, 3), type_label)
if key not in edge_groups:
edge_groups[key] = []
edge_order.append(key)
edge_groups[key].append(frac)
for key in edge_order:
x_ft, type_label = key
fracs = sorted(edge_groups[key])
combined.append((x_ft, type_label, len(fracs), fracs))
main_rows = [r for r in combined if r[1] in _MAIN_TABLE_TYPES]
top_rows = [r for r in combined if r[1] in _TOP_EDGE_TABLE_TYPES]
return (_build_lifter_table(main_rows, width_ft),
_build_lifter_table(top_rows, width_ft))
def _dunnage_table_block(psh, values, problems):
"""One 'table' block, one row per dunnage position (ft, ascending), or
`None` when there is nothing to show.
Reads through the SAME `pa_sketch_handling.dunnage_positions()` the
drawing itself calls, so the table and the picture can never disagree.
`None` for a genuinely empty result carries no `problems` entry of its
own (that function is already quiet about "nothing entered" -- see its
docstring); the caller decides what that gap is worth saying, once.
"""
if psh is None or not hasattr(psh, 'dunnage_positions'):
return None
positions, dunnage_problems = psh.dunnage_positions(values)
problems.extend(dunnage_problems)
if not positions:
return None
rows = []
for p in sorted(positions):
text = _feet_str(p)
rows.append([text if text is not None else '{0:.2f} ft'.format(p)])
return {'kind': 'table', 'headers': ['Dunnage Position'], 'rows': rows}
def _lifter_section_blocks(paf, psh, pa_lifters_mod, values, problems):
"""Face / Side Edge Lifters, Top Edge Lifters, Dunnage -- three headed
groups, NONE of which may render as a heading with nothing visible
under it (2026-09-04 THIRD ruling -- a live render showed exactly that
for the last two, because a `quiet` sentence is routed out of inline
view entirely, so a heading whose only child was one read as an empty
section even though a block technically existed).
"Face / Side Edge Lifters" prints an Eriksson-shaped summary table
(see `_lifter_tables`) above the two editable text fields it is built
from -- Face + Side Edge rows ONLY now; Top Edge / Bottom Edge rows
moved to the section below. "Top Edge Lifters" prints ITS OWN table
when there is anything in it, falling back to the (not-yet-real)
`top_edge_lifters` FIELD, then to a quiet sentence only when there is
truly nothing of either kind -- see the module docstring for why an
absent field is still a `problems` entry. "Dunnage" prints a positions
table off `pa_sketch_handling.dunnage_positions()` -- the SAME function
the drawing itself calls, so the two can never disagree -- falling back
the same way: field, then ONE quiet sentence, never a bare heading.
"""
blocks = [{'kind': 'heading', 'text': 'Face / Side Edge Lifters',
'level': 2}]
main_table, top_edge_table = _lifter_tables(paf, psh, pa_lifters_mod,
values, problems)
if main_table is not None:
blocks.append(main_table)
else:
blocks.append(_quiet(
'No face or side edge lifter positions entered yet.'))
# Owner ruling, 2026-09-05: the `face_lifters` / `edge_lifters` free-
# text boxes ("3.50 @ 0.18", one lifter per line) are replaced by two
# EDITABLE ROW TABLES -- `face_lifter` (x_ft along the length, y_ft
# across the width) and `edge_lifter` (edge: Top/Bottom/Left/Right,
# position_ft along that edge) -- the same mechanism `pa_structural.py`
# uses for `restraint` / `wind_zone`. `pa_sketch_handling.
# face_lifter_rows` / `.parse_edge_lifters` already prefer these tables
# over the legacy text fields (see that module's own comment); the old
# `FIELDS` entries stay declared, read only as a fallback for a panel
# saved before these tables existed, never rendered on the page itself.
# `empty_state` (issue #22, items 5/11): a table with no rows says WHY
# -- the model read's own `lifters_basis` sentence when it wrote one,
# else this text -- so a blank table is never mistaken for "no lifters".
blocks.append({'kind': 'editable_table', 'table': 'face_lifter',
'columns': [
{'col': 'x_ft', 'label': 'Position along length (ft)',
'editor': 'num'},
{'col': 'y_ft', 'label': 'Position across width (ft)',
'editor': 'num'},
], 'add_label': 'Add face lifter',
'empty_state': {
'basis_keys': ['lifters'],
'text': 'No face lifters read from the model and '
'none entered yet -- the read left no '
'reason, so this is could-not-judge, not '
'"no face lifters".'}})
blocks.append({'kind': 'editable_table', 'table': 'edge_lifter',
'columns': [
{'col': 'edge', 'label': 'Edge', 'editor': 'choice',
'choices': ['Top', 'Bottom', 'Left', 'Right']},
{'col': 'position_ft', 'label': 'Position along '
'that edge (ft)', 'editor': 'num'},
], 'add_label': 'Add edge lifter',
'empty_state': {
'basis_keys': ['lifters'],
'text': 'No edge lifters read from the model and '
'none entered yet -- the read left no '
'reason, so this is could-not-judge, not '
'"no edge lifters".'}})
blocks.append(_quiet(
'Top/Bottom are the panel\'s two long edges (position measured '
'along the length); Left/Right are its two ends (position '
'measured across the width). The summary table above groups rows '
'that share a position into one line.'))
blocks.append({'kind': 'heading', 'text': 'Top Edge Lifters', 'level': 2})
if top_edge_table is not None:
blocks.append(top_edge_table)
elif _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(_quiet('Design contains no top edge lifters.'))
# Owner ruling, 2026-09-05: a bare "Dunnage" heading with nothing
# visibly under it (the old fallback was a `_quiet` sentence, which the
# renderer routes OUT of inline view entirely into the collapsed Notes
# panel -- the exact same bug the THIRD ruling above already fixed for
# Top Edge Lifters) is worse than either hiding the heading or saying
# why in plain sight. This module cannot tell "the plant has no dunnage
# concept configured at all" apart from "this panel's own handling plan
# just does not use dunnage" -- `page()` is never handed `know`, only
# `values`/`reads` -- so rather than guess at that distinction, the
# heading always shows and the explanation is always VISIBLE (not
# `_quiet`) whenever there is no table: a reader sees a real sentence
# under the heading either way, never an empty-looking section.
blocks.append({'kind': 'heading', 'text': 'Dunnage', 'level': 2})
dunnage_table = _dunnage_table_block(psh, values, problems)
if dunnage_table is not None:
blocks.append(dunnage_table)
elif _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': 'No dunnage positions entered, and none '
'were read off the model or the plant\'s own '
'handling configuration -- this panel is '
'shown with no dunnage support in the '
'drawing above.'})
return blocks
def _parse_lifters_basis(text):
"""[(qty, kind, family_names)] off one `lifters_basis` sentence, e.g.
"8 face lifters (FAMILY_A) and 4 edge lifters (FAMILY_B) read off the
assembly." -- `pa_lifters.read_lifters()`'s own `why` sentence, the
only source this module has for a lifter's FAMILY name (the position
fields carry no such thing). `kind` is 'Face' or 'Edge', matching
`read_lifters()`'s own two-group vocabulary (it does not itself split
edge into side/top -- that split only exists in the position table
above, off different data). `family_names` is `[]` when the sentence
named none (no parenthetical) -- reported as 'not named' by the
caller, never guessed at. `[]` for a blank/unrecognisable sentence;
never raises."""
if not text:
return []
out = []
for m in re.finditer(
r'(\d+)\s+(face|edge)\s+lifters?(?:\s*\(([^)]*)\))?',
str(text), re.IGNORECASE):
qty = int(m.group(1))
kind = m.group(2).capitalize()
fams_raw = m.group(3) or ''
families = [f.strip() for f in fams_raw.split(',') if f.strip()]
out.append((qty, kind, families))
return out
def _import_knowledge():
"""The real `vibe.knowledge` (the five-layer merged capacity lookup), or
(None, reason) -- same "never raise" discipline as `_import_paf`."""
try:
from vibe import knowledge
except ImportError as exc:
return None, ('vibe.knowledge could not be imported ({0}); the '
'Lifting Hardware table cannot show Tension/Shear '
'capacities'.format(exc))
return knowledge, None
def _capacity_cell(know_mod, know, family, kind, fc_psi, assembly_families):
"""One Tension/Shear table cell: `{'text', 'title'}` -- a plain
read-only cell with the rating's own basis sentence as its hover title
(owner ruling, 2026-09-05), or `'-'` (a bare dash, no title) when
nothing is named, nothing is known, or `vibe.knowledge` could not be
reached at all. `vibe.knowledge.lifter_capacity` already tries
override -> plant chart -> back-cast -> catalogue, in that order, and
explains itself either way (`why`) -- this cell only formats what it
returns, never re-derives a number.
"""
if know_mod is None or know is None or not family:
return '-'
try:
value, basis, why = know_mod.lifter_capacity(
know, family, fc_psi=fc_psi, kind=kind,
assembly_families=assembly_families)
except Exception as exc:
return {'text': '-', 'title': 'lifter_capacity() raised: {0}'
.format(exc)}
if value is None:
return {'text': '-', 'title': why or basis or 'not rated'}
return {'text': '{0:,.0f} lb'.format(value), 'title': why or basis or ''}
def _lifting_hardware_table_block(values, reads, know, problems):
"""One 'table' block, "Family / Kind / Qty / Tension / Shear" -- each
lifter family named in `lifters_basis` (see `_parse_lifters_basis`),
read off `values` first, `reads` as a fallback, the same order every
other prefilled field on this page uses. `None` when there is no basis
sentence to parse, or nothing in it.
Tension/Shear (owner ruling, 2026-09-05) come from `vibe.knowledge.
lifter_capacity` -- override, then the plant's own tested chart, then a
back-cast, then the published catalogue, per that function's own
precedence -- rated at `fci_psi` (release strength; Handling is a
stripping/transport/erection stage, never the in-place condition) when
it is a number, else unadjusted. A row naming more than one family (a
basis sentence with no per-family split) rates only the FIRST -- there
is no way to tell which family a shared qty belongs to from the
sentence alone, so rating an arbitrary one and labelling it as if it
covered the row would overstate what is actually known; the cell's
hover title says so. `know` missing (no plant/job knowledge layer
reached this page) or `vibe.knowledge` unimportable both print a bare
`-`, never a fabricated number.
"""
text = values.get('lifters_basis')
if not text and reads:
text = reads.get('lifters_basis')
entries = _parse_lifters_basis(text) if text else []
if not entries:
problems.append(
'No lifter family basis (lifters_basis) entered or read off '
'the model yet; the Lifting Hardware table cannot be built.')
return None
know_mod = None
if know is not None:
know_mod, know_reason = _import_knowledge()
if know_mod is None:
problems.append(know_reason)
all_families = []
for _qty, _kind, families in entries:
all_families.extend(families)
fc_psi = _num(values.get('fci_psi'))
if fc_psi is None:
fc_psi = _num(values.get('fc_psi'))
rows = []
for qty, kind, families in entries:
family_text = ', '.join(families) if families else 'not named'
rate_family = families[0] if families else None
title_extra = (' (rating the first of {0} families named on this '
'row)'.format(len(families))
if len(families) > 1 else '')
tension = _capacity_cell(know_mod, know, rate_family, 'tension',
fc_psi, all_families)
shear = _capacity_cell(know_mod, know, rate_family, 'shear',
fc_psi, all_families)
if title_extra and isinstance(tension, dict):
tension['title'] = (tension.get('title') or '') + title_extra
if title_extra and isinstance(shear, dict):
shear['title'] = (shear.get('title') or '') + title_extra
rows.append([family_text, '{0} lifter'.format(kind), str(qty),
tension, shear])
return {'kind': 'table',
'headers': ['Family', 'Kind', 'Qty', 'Tension', 'Shear'],
'rows': rows}
def _lifting_hardware_blocks(values, reads, know, problems):
"""Eriksson page 26: the lifter family/quantity/capacity table plus a
note on what could not be carried over (see the module docstring's
last bullet).
2026-09-04: the OLD tiny "PANEL WEIGHT DISTRIBUTION" `weight_diagram`
drawing is gone -- it drew nothing this page's own data actually
supports (a per-stage weight split `panelanalysisform` does not model)
-- replaced by a real "Lifting hardware" table built from
`lifters_basis`. 2026-09-05: that table grew Tension/Shear capacity
columns (`_capacity_cell`); the lifting-configuration sketch remains
the only unbuilt gap.
"""
blocks = [{'kind': 'heading', 'text': 'Lifting Hardware', 'level': 2}]
table = _lifting_hardware_table_block(values, reads, know, problems)
if table is not None:
blocks.append(table)
else:
blocks.append(_quiet(
'No lifter family/quantity basis available yet; the Lifting '
'Hardware table has nothing to show.'))
problems.append(
'Eriksson also prints a small lifting-configuration sketch per '
'included stage (a G/CG marker over each stage\'s lift points); '
'there is no data source here for it yet, so it is not shown.')
return blocks
def _lifter_dunnage_drawing_block(values):
"""The LIFTER AND DUNNAGE DETAIL `drawing` block: `module` +
`source` name `pa_sketch_handling.lifter_dunnage` (2026-09-04 ruling
#3 -- the old block named `panelsketch.plan`, which letterboxes to
whichever axis is tighter instead of always filling the width). `aspect`
is `width_ft / length_ft` clamped to `[0.15, 0.5]`, computed here off
whatever the user has typed so far so the container is sized close to
right before the drawing itself ever runs; `0.3` (the middle of that
range) stands in only when there is not yet enough to compute a real
one -- a layout guess, not an engineering value.
"""
length_ft = _num(values.get('length_ft'))
width_ft = _num(values.get('width_ft'))
if length_ft and length_ft > 0 and width_ft and width_ft > 0:
aspect = _clamp(width_ft / length_ft, 0.15, 0.5)
else:
aspect = 0.3
return {
'kind': 'drawing', 'module': 'pa_sketch_handling',
'source': 'lifter_dunnage', 'full_width': True, 'aspect': aspect,
'caption': 'Lifter and dunnage detail',
# Every `values` key `pa_sketch_handling.lifter_dunnage_geometry`
# reads, so an edit to any of them redraws this picture. The
# `face_lifter` / `edge_lifter` / `opening` entries are TABLE
# names: the page matches a row key (`face_lifter.3.x_ft`) on the
# `<table>.` prefix, the same way `pa_structural`'s free body lists
# `restraint` / `support`. Until 2026-09-07 (issue #22, item 7)
# this listed only the legacy free-text keys, so editing a lifter
# ROW never redrew the picture -- the owner saw a read-wrong
# lifter he could not correct ("如果你读错了,用户不就是改不了了嘛").
'depends_on': ['face_lifter', 'edge_lifter', 'opening',
'face_lifters', 'edge_lifters', 'openings_rows',
'length_ft', 'width_ft', 'thickness_in', 'dunnage'],
}
def page(values, reads=None, know=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.
`know` is the optional merged knowledge tree (`vibe.knowledge.load()`'s
own shape) -- used only by the Lifting Hardware table's Tension/Shear
columns (`_lifting_hardware_table_block`); `None` prints a bare `-` in
both, never a fabricated rating. `vibe.pa_pagespec._load_page` passes
this through only when it detects `page` declares the parameter, so
this stays backward compatible with any caller still passing two
arguments.
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},
_quiet('The Handling section could not be built -- see '
'the problem above.')],
problems)
psh, psh_reason = _import_psh()
if psh is None:
problems.append(psh_reason)
pa_lifters_mod, pal_reason = _import_pa_lifters()
if pa_lifters_mod is None:
problems.append(pal_reason)
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(_quiet('The per-stage handling options could not '
'be built -- see the problem above.'))
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, psh, 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, legend directly under it (2026-09-04 ruling) ------
blocks.append(_lifter_dunnage_drawing_block(values))
blocks.append(_legend_block())
blocks.append({'kind': 'space', 'h': 8})
# --- lifter / dunnage sections -----------------------------------
blocks.extend(_lifter_section_blocks(paf, psh, pa_lifters_mod, values,
problems))
blocks.append({'kind': 'rule'})
# --- Lifting Hardware (page 26) ------------------------------------
blocks.extend(_lifting_hardware_blocks(values, reads, know, problems))
return blocks, problems