8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
1297 lines
63 KiB
Python
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
|