# -*- 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 # `.` 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