# -*- coding: utf-8 -*- """Panel Analysis Handling tab: the "Lifter and Dunnage Detail" plan drawing, plus the panel's weight centroid -- pure geometry/arithmetic, both Pythons, no Revit, no WPF, no `vibe.panelsketch` import. WHY THIS IS A SEPARATE MODULE FROM `panelsketch.py`: the owner's ruling (2026-09-04) was specific about what this drawing has to look like -- "lift&dunnage detail 画的完全不对。而且太小了。画图的时候,panel 的长度总要占满 整个显示窗口的宽度,因为不管什么板,你总是横着画" (the old drawing was wrong and too small; the panel's LENGTH must always fill the drawing's full width, because a panel is always drawn lengthwise no matter what it is) -- a layout rule `panelsketch.plan()` does not follow (it letterboxes to whichever axis is tighter, so a squat panel can end up far short of the column width). Rather than change `panelsketch.plan()` out from under whatever else reads it, this is a new, narrower entry point owned by the same module (`vibe.pa_handling`) that reads the rest of the Handling tab, so the two stay in lockstep. THE SEAM is identical to `panelsketch.py`'s own: a flat list of drawing PRIMITIVES in a normalised, already-in-pixels coordinate space, origin top-left, y increasing downward (WPF's `Canvas` convention) -- the WPF painting is the button's job. `_clamp_primitive` below guarantees every primitive lies inside `[0, width_px] x [0, height_px]`, same discipline as `panelsketch._clamp_primitive`. PRIMITIVE VOCABULARY reuses `panelsketch.plan()`'s own kinds on purpose (read `panelsketch.py`'s `_clamp_primitive` and the button's `_drawing_canvas` / `_draw_primitive` before changing this) so the EXISTING renderer already knows how to paint everything here with no renderer change required: {'kind': 'rect', ...} panel outline / openings {'kind': 'dot', ..., 'role': 'face-lifter'} a FACE lifter -- the renderer already draws `dot` as a small filled circle. {'kind': 'support', ..., 'role': 'edge-lifter', 'edge': 'bottom'|'top', 'edge_kind': 'end'|'side', 'ambiguous': bool} an EDGE lifter -- the renderer already draws `support` as a filled triangle (hollow when `flagged`); `edge` names which side of the panel it sits on ('bottom'/'top' of whichever axis `edge_kind` says -- the panel's two ENDS for 'end', its two long SIDES for 'side'), same "informational until a renderer wants to orient the glyph" status as before. `edge_kind` and `ambiguous` are new (2026-09-04) -- see `parse_edge_lifters()`. {'kind': 'line', ..., 'role': 'dunnage', 'dashed': True} a transverse dunnage line, paired with a `role: 'dunnage-label'` text primitive reading 'Dunnage'. `dashed` is a hint the current renderer does not read yet (it draws every `line` solid); carried so a later renderer change needs no primitive change. {'kind': 'text', ..., 'role': 'dimension', 'font_px': 11, 'stagger': 'inner'|'outer'} a dimension-chain label -- `font_px` is the same kind of forward-compatible hint as `dashed` above (the current renderer hardcodes every `text` primitive to 10px regardless). `stagger` (transverse-chain labels only) names which of the two columns a close-packed label was pushed into -- see the THIRD ruling. {'kind': 'marker', 'glyph': 'cross', 'role': 'centroid'} + a 'CG' text the weight centroid, same as `panelsketch.plan()`. {'kind': 'line'/'text', 'role': 'dimension'} dimension chains, same shapes as everywhere else in `panelsketch.py`. `role` in `'face-lifter'` / `'edge-lifter'` / `'dunnage'` is the exact vocabulary `panelsketch.plan()`'s own docstring already reserved for this data the day it existed ("wire it in next to `_parse_opening_row`") -- matched here for consistency even though the primitives themselves live in this module, not that one. INPUT is the same `values` dict `panelanalysisform.py` / `panelsketch.py` read from the form -- string-valued, never mutated. Keys read, all optional except `length_ft` / `width_ft` (see below): length_ft, width_ft required -- see MISSING INPUT below. thickness_in, section_type, wythe_top_in, insulation_in, wythe_bottom_in feed `centroid()`'s Y (through-thickness) figure. openings_rows same shape `panelsketch._parse_opening_row` documents (a list of dicts: 'type', 'pos_along', 'pos_along_from', 'length', 'pos_vert', 'height', 'label'). This module only ever needs the along-length / through-width axes (a PLAN view has no through-thickness axis), so it parses a narrower version of that row shape than `panelsketch.py` does -- duplicated, not imported, for the same "stay a leaf" reason `panelsketch.py` gives for not importing `panelanalysisform`. face_lifters, edge_lifters free text, one lifter per line, `'x_ft @ frac'` -- `x_ft` along `length_ft` from the start, `frac` the fraction of `width_ft` from the bottom edge. The exact grammar `vibe.pa_lifters.parse_lifter_lines` reads; duplicated here rather than imported for the same leaf-module reason as `openings_rows` above. `edge_lifters` rows are classified END vs SIDE by geometry -- see `parse_edge_lifters()`. top_edge_lifters, side_edge_lifters free text, same `'x_ft @ frac'` grammar as `edge_lifters` -- the reads sibling's OWN classification of which edge a lifter sits on, preferred outright over re-deriving it from `edge_lifters` by geometry when either carries text. See `parse_edge_lifters()`. dunnage free text, dunnage positions in ft along `length_ft`, comma/whitespace separated. dunnage_positions_ft a list of ft positions along `length_ft`, the reads sibling's own dunnage read off Panel Lift's job -- preferred outright over parsing `dunnage` text when it is a list with at least one usable entry. See `dunnage_positions()`. There is NO plant-rule fallback reachable from this leaf module for either dunnage source (that lookup lives in `vibe.knowledge`, which this module deliberately does not import -- see MISSING INPUT below); neither present means no dunnage lines are drawn, and is NOT reported from here -- see `dunnage_positions()`'s own docstring for why, and `pa_handling.page()` for where it is said instead. mark, design_number label only, as in `panelsketch.py`. MISSING INPUT IS THE HEADLINE STATE, same law as `panelsketch.py`: `length_ft` / `width_ft` missing, blank, non-numeric or <= 0 is the one hard stop -- `sketch()`... `lifter_dunnage()` returns `([], problems)`, no panel drawn, nothing guessed. Every other gap (no lifters typed, no dunnage, an opening row with an unresolved axis, a lifter outside the panel) still draws what it safely can and reports the rest, exactly like every `panelsketch.py` entry point. THE LENGTH-FILLS-THE-WIDTH LAYOUT (the owner's ruling quoted above) means `lifter_dunnage()` does NOT letterbox to whichever axis is tighter the way every `panelsketch.py` drawing does -- `draw_w` is always the full available width, and `draw_h = draw_w * (width_ft / length_ft)` follows from it, true-to-scale but never fit-to-height. The `'aspect'` the caller's `drawing` block declares (`width_ft / length_ft`, clamped) is what sizes the CONTAINER to roughly match this before this function ever runs, so the two are meant to agree; `_clamp_primitive` is still the backstop that keeps every primitive inside whatever box actually gets passed in. 2026-09-04 SECOND RULING, off a headless render of this tab against a real wall (37.58 x 8.375 ft, 8 face lifters, 4 edge lifters all sitting at `x == length_ft`), three parts, all folded into this pass: 1. EDGE LIFTER CLASSIFICATION was wrong. The old code treated every `edge_lifters` row identically -- position it along the top/bottom of the PANEL OUTLINE by its `frac` (`<= 0.5` -> 'bottom', else 'top'), the same way a face lifter is positioned, just with a triangle glyph instead of a circle. For four lifters that all share `x == length_ft` that collapses them onto two overlapping points at the panel's two right-hand corners -- "the drawing put one triangle at the corner" -- when what they actually are is four TOP-EDGE (end) lifters spread along the short edge at `x = length_ft`, one per transverse fraction. `parse_edge_lifters()` below is the fix: an edge lifter whose `x_ft` sits within `_END_TOL_FT` (0.5 ft) of 0 or of `length_ft` is an END lifter -- drawn ON that end edge (`x` pinned to 0 or `draw_w`), at its TRANSVERSE position (`y = frac * width_ft`, continuously, not snapped to the panel's top/bottom); one whose `frac` sits within `_SIDE_TOL_FRAC` (0.05) of 0 or 1 (and does not already qualify as an END lifter -- the x-check runs first) is a SIDE lifter -- drawn on that long edge at its longitudinal `x_ft`, the old behaviour. A row that fits neither test is still drawn (never dropped) as a side lifter by which half of `frac` it falls in, the historical default, but comes back with `ambiguous: True` and a `problems` sentence, because a "edge" lifter that sits on no edge at all is worth a flag. `top_edge_lifters` / `side_edge_lifters` -- free text in the same `'x_ft @ frac'` grammar as `edge_lifters`, added by the sibling that reads Panel Lift's own classification (`vibe.pa_lifters. edge_lifter_lines`) -- are PREFERRED outright over re-classifying `edge_lifters` by geometry, when either carries text: a `top_edge_lifters` row is always an END lifter (`kind`), but that field files BOTH a panel's bottom-end and top-end lifters -- the name is a holdover, not a promise -- so `side` still comes from `x_ft` via `_end_side_from_x`; a `side_edge_lifters` row is always a SIDE lifter, bottom/top chosen by `frac` alone. `pa_handling._lifter_table_block` reads this exact same function (through the `psh` it already imports) for its own "Face / Side Edge / Top Edge" table groups, so the table and the drawing can never disagree about what a given lifter is. 2. DIMENSION CHAIN READABILITY. Every dimension-chain text primitive now carries `'font_px': 11` -- a rendering HINT, same discipline as `dashed` on a dunnage line (see the vocabulary list below): the current renderer does not read it yet, but a later renderer change needs no primitive change to pick it up. The chain itself already drew one backbone line plus one tick/label per DISTINCT position (`sorted(set(round(..., 3)))`); that discipline is unchanged, and the end-edge lifters' transverse positions were already folded into `all_y_ft` the same as a face lifter's -- the classification fix above did not touch that part. (A live render of THIS fix still showed the chain squashed into an illegible ~20px box -- see the THIRD ruling below, which is the part that actually fixed it.) 3. DUNNAGE. `dunnage_positions()` (renamed from the old private `_dunnage_positions`, and now a documented sibling of `centroid()`) reads `values['dunnage_positions_ft']` -- a list of ft positions the reads sibling supplies straight off Panel Lift's job -- FIRST, and only falls back to parsing `values['dunnage']` free text when that list is absent or every entry in it is unusable. Each dunnage line drawn now carries a matching `'dunnage-label'` text primitive reading 'Dunnage'. And the part that actually changes behaviour: when NEITHER source has anything to draw from, this function no longer adds a `problems` sentence of its own. It used to ("Dunnage positions are not entered..."), and `PanelAnalysis.pushbutton/script.py`'s `_drawing_canvas` prints every `problems` entry a drawing function returns as a RED inline note directly under the drawing -- so a panel with genuinely no dunnage data got a permanent red warning under an otherwise-fine picture. `pa_handling.page()` now owns saying so, ONCE, as a single quiet sentence elsewhere on the page (see its own docstring) -- this function stays silent on the "nothing entered" case and reports only a REAL defect (an unreadable list, unparseable text) the way it always did. 2026-09-04 THIRD RULING, off a LIVE render of the second ruling's own font hint fix -- the transverse chain was still a squashed ~20px-wide box of ticks, because `font_px` is only a hint the renderer does not act on yet and the LAYOUT itself was still cramming the whole chain into the ordinary (<= 40 px) margin: 1. THE LEFT MARGIN is now reserved specifically for this chain -- `_left_margin()` returns at least `_LEFT_MARGIN_MIN_PX` (90 px) regardless of how small `_margin()` comes out, and `lifter_dunnage()` uses it (not the ordinary `margin`) for `ox`. `draw_w` is computed off the REMAINING width (`width_px - left_margin - margin`), so the panel still fills every pixel that is left over -- the length-fills-the- width rule from the FIRST ruling did not change, only what counts as "available." 2. TWO LABELS WHOSE PIXEL POSITIONS ARE CLOSE now stagger into an inner and an outer column instead of overlapping: whenever a label's `py` falls within `_LEFT_LABEL_STAGGER_TOL_PX` (14 px) of the PREVIOUS label's `py` (both walked in ascending-`y_ft` order), it draws at `outer_x` instead of `inner_x` (`_LEFT_LABEL_STAGGER_OFFSET_PX`, 34 px further from the chain); a label whose neighbour is far enough away resets to the inner column. Each label also carries a `'stagger': 'inner'|'outer'` hint alongside `font_px`, for a renderer that wants to draw a short leader line back to its own tick. `centroid()` is the second half of the owner's ruling ("centroid 该算要算的" -- if it can be computed, compute it): the panel's weight centroid, X from the start and Z from the left (the plan-view, area-weighted centroid, identical arithmetic to `panelsketch.plan()`'s own, corrected for `Opening` rows only -- a Reveal is partial-depth and a Solid Zone removes nothing, so neither reduces the plan-view area) and Y from the bottom (the through-thickness centroid). Y is deliberately NOT mass-weighted by each wythe's own unit weight for a Sandwich section: `panelanalysisform.py` has no established mapping from `unit_weight_pcf` / `unit_weight2_pcf` (wythe 1 vs wythe 2, a MATERIAL pairing) onto `wythe_top_in` / `wythe_bottom_in` (top vs bottom, a POSITION pairing) -- inventing one here would be exactly the kind of unverifiable guess CLAUDE.md SS3 rules out, so Y is computed from the wythe THICKNESSES alone (half the total, same as a solid section) with a `problems` note when the wythe trio is what made that possible instead of `thickness_in`. `centroid()` is called both by `page()` (for the printed kv block) and by `lifter_dunnage()` (for the marker), so the two can never show a different number for the same panel. Standard-library only (`re`, for the dunnage/lifter text splitting) -- IronPython 2.7 in the button, plain CPython in `tests/`: no f-strings, no dataclasses/typing/enum, `'{0}'.format(...)` throughout. `values` is never mutated by anything below. """ import re _MARGIN_FRACTION = 0.12 _MARGIN_MIN_PX = 8.0 _MARGIN_MAX_PX = 40.0 # 2026-09-04 THIRD RULING: a live render showed the transverse dimension # chain squashed into the ordinary (<= 40 px) margin -- a ~20-px-wide box # of illegible ticks. The left margin is now reserved specifically for # that chain, at least this wide regardless of how small `_margin()` comes # out for a given canvas -- see `_left_margin()` / `lifter_dunnage()`. _LEFT_MARGIN_MIN_PX = 90.0 # Two labels whose PIXEL y falls within this of each other alternate # between an inner and an outer column (see the transverse chain in # `lifter_dunnage()`) so neither is ever drawn on top of the other. _LEFT_LABEL_STAGGER_TOL_PX = 14.0 _LEFT_LABEL_STAGGER_OFFSET_PX = 34.0 # ------------------------------------------------------------------ parsing def _num(value): """A float, or None. Accepts a number already, or text; blank -> None.""" if value is None: return None if isinstance(value, bool): # bool is an int subclass 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 _margin(width_px, height_px): m = min(width_px, height_px) * _MARGIN_FRACTION if m < _MARGIN_MIN_PX: m = _MARGIN_MIN_PX if m > _MARGIN_MAX_PX: m = _MARGIN_MAX_PX return m def _left_margin(width_px, height_px): """The left margin `lifter_dunnage()` reserves for the panel -- always at least `_LEFT_MARGIN_MIN_PX` (90 px, wide enough for a readable 'NN.NN ft' vertical label plus its tick and stagger column), never less than the ordinary `_margin()` figure. Every other side of the drawing still uses plain `_margin()`; only the transverse dimension chain needed the extra room.""" return max(_margin(width_px, height_px), _LEFT_MARGIN_MIN_PX) def _clamp_primitive(prim, width_px, height_px): """Pulls one primitive's coordinates back inside the box, in place -- same contract as `panelsketch._clamp_primitive`.""" kind = prim.get('kind') if kind == 'rect': x = _clamp(prim['x'], 0.0, width_px) y = _clamp(prim['y'], 0.0, height_px) w = prim['w'] h = prim['h'] if x + w > width_px: w = width_px - x if y + h > height_px: h = height_px - y if w < 0.0: w = 0.0 if h < 0.0: h = 0.0 prim['x'], prim['y'], prim['w'], prim['h'] = x, y, w, h elif kind == 'line': prim['x1'] = _clamp(prim['x1'], 0.0, width_px) prim['y1'] = _clamp(prim['y1'], 0.0, height_px) prim['x2'] = _clamp(prim['x2'], 0.0, width_px) prim['y2'] = _clamp(prim['y2'], 0.0, height_px) else: if 'x' in prim: prim['x'] = _clamp(prim['x'], 0.0, width_px) if 'y' in prim: prim['y'] = _clamp(prim['y'], 0.0, height_px) return prim def _title_primitive(mark, design_number): mark = (mark or '').strip() if mark else '' design_number = (design_number or '').strip() if design_number else '' if not (mark or design_number): return None bits = [b for b in (mark, design_number) if b] return {'kind': 'text', 'x': 4.0, 'y': 4.0, 'text': ' / '.join(bits), 'role': 'title', 'anchor': 'start'} # ------------------------------------------------------------- openings _OPENING_ROLE_BY_TYPE = {'Opening': 'opening', 'Reveal': 'reveal', 'Solid Zone': 'solid-zone'} def _resolve_axis_range(pos, extent, total, frm, far_name): """(lo, hi) in ft from the NEAR edge, given a position/extent that may have been typed measured from the FAR edge instead. `None` when `pos` or `extent` is missing, or `extent` is non-positive -- see `panelsketch._resolve_axis_range`, duplicated rather than imported.""" if pos is None or extent is None or extent <= 0.0: return None if frm == far_name: lo = total - pos - extent else: lo = pos return lo, lo + extent def _parse_opening_row(item, index, length_ft, width_ft): """(row, problems) for one `openings_rows` entry -- the along-length / through-width axes only (a plan view has no through-thickness axis, so `pos_cross` is never read here). See `panelsketch._parse_opening_row` for the full axis-assignment discussion this narrows down from.""" if not isinstance(item, dict): return None, ['Opening row {0} is not a recognised row; skipped.' .format(index)] type_ = str(item.get('type') or 'Opening').strip() or 'Opening' label = str(item.get('label') or '').strip() problems = [] pos_along = _num(item.get('pos_along')) length_ext = _num(item.get('length')) along_from = item.get('pos_along_from') or 'start' along = _resolve_axis_range(pos_along, length_ext, length_ft, along_from, 'end') if along is None: problems.append( '{0} row {1} ({2}) has no along-length position/extent ' 'entered; not drawn.'.format(type_, index, label or 'unlabeled')) return None, problems pos_vert = item.get('pos_vert') height_ext = item.get('height') vert = _resolve_axis_range( _num(pos_vert) if pos_vert is not None else None, _num(height_ext) if height_ext is not None else None, width_ft, 'start', 'end') row = {'type': type_, 'role': _OPENING_ROLE_BY_TYPE.get(type_, 'opening'), 'label': label, 'index': index, 'along': along, 'vert': vert} return row, problems def _openings_rows(values, length_ft, width_ft): """(rows, problems). `rows` is `None` when `values['openings_rows']` is not a list; otherwise a list of parsed rows, one per entry that at least resolved an along-length position.""" raw = values.get('openings_rows') if not isinstance(raw, list): return None, [] rows = [] problems = [] for i, item in enumerate(raw): row, row_problems = _parse_opening_row(item, i + 1, length_ft, width_ft) problems.extend(row_problems) if row is not None: rows.append(row) return rows, problems def _opening_rect_primitives(rows, length_ft, width_ft, ox, oy, draw_w, draw_h): """Each row's `along` x `vert` extent, to scale, as a hollow rect.""" primitives = [] problems = [] for row in rows: if row['vert'] is None: problems.append( '{0} row {1} ({2}) has no vertical position/extent ' 'entered; not drawn.'.format(row['type'], row['index'], row['label'] or 'unlabeled')) continue x0, x1 = row['along'] y0, y1 = row['vert'] flagged = (x0 < 0.0 or y0 < 0.0 or x1 > length_ft or y1 > width_ft) if flagged: problems.append( '{0} {1} ({2}) falls partly or wholly outside the panel; ' 'shown clamped and flagged.'.format( row['type'], row['index'], row['label'] or 'unlabeled')) cx0 = _clamp(x0, 0.0, length_ft) cx1 = _clamp(x1, 0.0, length_ft) cy0 = _clamp(y0, 0.0, width_ft) cy1 = _clamp(y1, 0.0, width_ft) if cx1 <= cx0 or cy1 <= cy0: problems.append('{0} {1} is entirely outside the panel; not ' 'drawn.'.format(row['type'], row['index'])) continue px_x = ox + (cx0 / length_ft) * draw_w px_w = ((cx1 - cx0) / length_ft) * draw_w px_y = oy + draw_h - (cy1 / width_ft) * draw_h px_h = ((cy1 - cy0) / width_ft) * draw_h primitives.append({'kind': 'rect', 'x': px_x, 'y': px_y, 'w': px_w, 'h': px_h, 'role': row['role'], 'flagged': flagged}) if row['label']: primitives.append({'kind': 'text', 'x': px_x + px_w / 2.0, 'y': px_y + px_h / 2.0, 'text': row['label'], 'role': row['role'] + '-label', 'anchor': 'middle'}) return primitives, problems # --------------------------------------------------------- lifters/dunnage def _parse_lifter_lines(text): """(rows, problems) -- one `(x_ft, frac)` tuple per LINE, `'x_ft @ frac'`. All-or-nothing, the same discipline as `pa_lifters.parse_lifter_lines` -- duplicated, not imported, so this stays a leaf module (see the module docstring).""" rows = [] problems = [] if text is None: text = '' for i, raw in enumerate(str(text).splitlines(), 1): line = raw.strip() if not line: continue parts = [p.strip() for p in line.split('@')] if len(parts) != 2: problems.append( "line {0}: expected 'x_ft @ frac', got {1!r}".format( i, line)) continue x_ft = _num(parts[0]) frac = _num(parts[1]) if x_ft is None: problems.append('line {0}: x_ft is not a number ({1!r})' .format(i, parts[0])) continue if frac is None: problems.append('line {0}: frac is not a number ({1!r})' .format(i, parts[1])) continue rows.append((x_ft, frac)) if problems: return [], problems return rows, [] # --------------------------------------------------- lifter ROW TABLES # # Owner ruling, 2026-09-05: `face_lifters` / `edge_lifters` free text # ('x_ft @ frac', one lifter per line) is replaced on the page by two # EDITABLE ROW TABLES -- `face_lifter` (x_ft, y_ft) and `edge_lifter` (edge # [Top/Bottom/Left/Right], position_ft) -- the same row-table mechanism # `panelanalysisform` already uses for `restraint` / `wind_zone` / `opening`. # This module still does not IMPORT `panelanalysisform` (see the module # docstring's "stay a leaf" rule) -- `_table_row_ids` / `_table_row` below # reimplement its `row_ids`/`row_key` arithmetic independently, the same way # `pa_sketch_freebody.py` does for its own three tables. The free-text # fields are read only as a FALLBACK, for a panel saved before these tables # existed (see `_face_lifter_rows` / `parse_edge_lifters`). def _table_row_ids(table, values): try: from vibe import panelanalysisform as paf return list(paf.row_ids(table, values)) except (ImportError, AttributeError): pass values = values or {} prefix = table + '.' ids = set() for key in values: if not isinstance(key, str) and not isinstance(key, type(u'')): continue if not key.startswith(prefix): continue n_txt = key[len(prefix):].split('.', 1)[0] try: n = int(n_txt) except (TypeError, ValueError): continue if n > 0: ids.add(n) out = [n for n in ids if str(values.get('{0}.{1}.present'.format(table, n)) or '') .strip().lower() == 'yes'] out.sort() return out def _table_row(table, n, values, cols): out = {} for col in cols: out[col] = (values or {}).get('{0}.{1}.{2}'.format(table, n, col)) return out def _face_lifter_rows_from_table(values): """[(x_ft, frac), ...] off the `face_lifter` table's PRESENT rows -- its own `x_ft`/`y_ft` columns converted to the `(x_ft, frac-of-width)` pair every existing reader below already speaks. `[]` (never raises) when the table has no rows, or `width_ft` is not a usable positive number (a `y_ft` cannot become a fraction of nothing).""" values = values or {} width_ft = _num(values.get('width_ft')) out = [] for n in _table_row_ids('face_lifter', values): raw = _table_row('face_lifter', n, values, ('x_ft', 'y_ft')) x_ft = _num(raw.get('x_ft')) y_ft = _num(raw.get('y_ft')) if x_ft is None or y_ft is None or not width_ft or width_ft <= 0: continue out.append((x_ft, y_ft / width_ft)) return out def face_lifter_rows(values): """(rows, problems), `rows` = [(x_ft, frac), ...] -- the `face_lifter` table when it carries any row, else the legacy `face_lifters` free-text field (a panel saved before the table existed). Never both at once: the table, once it has any row, is authoritative. PUBLIC (unlike this module's other row-table helpers) because `pa_handling._lifter_tables` reads it too, for its own Face / Side Edge Lifters summary table -- same "the table and the drawing can never disagree" reasoning `parse_edge_lifters` already documents for edge lifters. """ rows = _face_lifter_rows_from_table(values) if rows: return rows, [] return _parse_lifter_lines((values or {}).get('face_lifters')) # Old private name, kept as an alias -- this module's own two call sites # were written against it before `pa_handling` needed the same function. _face_lifter_rows = face_lifter_rows _EDGE_LIFTER_EDGE_MAP = { 'left': ('end', 'bottom'), 'right': ('end', 'top'), 'bottom': ('side', 'bottom'), 'top': ('side', 'top'), } def _edge_lifter_rows_from_table(values, length_ft, width_ft): """[{'x_ft', 'frac', 'kind', 'side', 'ambiguous', 'source'}, ...] off the `edge_lifter` table's PRESENT rows -- the EXACT row shape `parse_edge_lifters()` returns, so every existing reader of that shape (the drawing, `pa_handling`'s summary table) needs no change to accept a table row instead of a classified `edge_lifters` text line. `edge` NAMES the side directly (owner ruling, 2026-09-05: Left/Right are the panel's two ENDS along its length -- 'left' = x=0, 'right' = x=length_ft; Top/Bottom are its two long SIDES across its width -- 'bottom' = y=0, 'top' = y=width_ft) -- there is no geometry left to classify, so `ambiguous` is always `False`. `position_ft` reads across the width for Left/Right, along the length for Top/Bottom. A row with an unrecognised edge word, or no readable position, is skipped (never guessed at). """ values = values or {} out = [] for n in _table_row_ids('edge_lifter', values): raw = _table_row('edge_lifter', n, values, ('edge', 'position_ft')) edge = str(raw.get('edge') or '').strip().lower() pos = _num(raw.get('position_ft')) mapping = _EDGE_LIFTER_EDGE_MAP.get(edge) if mapping is None or pos is None: continue kind, side = mapping if kind == 'end': if not width_ft or width_ft <= 0: continue x_ft = 0.0 if side == 'bottom' else (length_ft or 0.0) frac = pos / width_ft else: x_ft = pos frac = 0.0 if side == 'bottom' else 1.0 out.append({'x_ft': x_ft, 'frac': frac, 'kind': kind, 'side': side, 'ambiguous': False, 'source': 'edge_lifter'}) return out _END_TOL_FT = 0.5 _SIDE_TOL_FRAC = 0.05 def _end_side_from_x(x_ft, length_ft): """'bottom' / 'top' / None for one END-edge lifter's `x_ft` -- 'bottom' within `_END_TOL_FT` of the panel's start, 'top' within `_END_TOL_FT` of `length_ft`, `None` when it fits neither closely enough to call. Shared by `_classify_from_geometry` (the `edge_lifters` fallback) AND `parse_edge_lifters`'s own `top_edge_lifters` branch below -- see that function's docstring for why a "top edge lifters" ROW still needs this: `pa_lifters.edge_lifter_lines` (the reads sibling) names the field `top_edge_lifters` but actually files EVERY end lifter there, bottom and top both, same tolerance (`_END_TOL_FT` here matches its own `_END_TOL_FT` exactly) -- it is not a promise that every row in it is a TOP lifter, only that every row in it is an END lifter.""" if x_ft is None: return None if abs(x_ft - 0.0) <= _END_TOL_FT: return 'bottom' if length_ft and abs(x_ft - length_ft) <= _END_TOL_FT: return 'top' return None def _classify_from_geometry(x_ft, frac, length_ft): """(kind, side, ambiguous) for one `edge_lifters` row that has no explicit `top_edge_lifters` / `side_edge_lifters` field to lean on. `kind` is 'end' when `_end_side_from_x` can call a side for `x_ft` (within `_END_TOL_FT` of either panel end -- Eriksson's own "the end that ends up high once erected" for the 'top' case); else 'side' when `frac` sits within `_SIDE_TOL_FRAC` of 0 or 1 (`side` 'bottom' / 'top' of the WIDTH axis). A row that matches neither test is still classified 'side' by which half of `frac` it falls in -- the historical default, so the lifter is never dropped -- but `ambiguous` comes back `True`: an "edge" lifter sitting on no edge at all is worth flagging, not silently drawn as if it were an ordinary side lifter. """ end_side = _end_side_from_x(x_ft, length_ft) if end_side is not None: return 'end', end_side, False if frac is not None: if abs(frac - 0.0) <= _SIDE_TOL_FRAC: return 'side', 'bottom', False if abs(frac - 1.0) <= _SIDE_TOL_FRAC: return 'side', 'top', False side = 'bottom' if (frac if frac is not None else 0.0) <= 0.5 else 'top' return 'side', side, True def parse_edge_lifters(values, length_ft, width_ft): """(rows, problems) -- one dict per edge lifter: `{'x_ft', 'frac', 'kind', 'side', 'ambiguous', 'source'}`. `kind` is 'end' (the lifter sits on one of the panel's two short ends) or 'side' (one of its two long edges); `side` is 'bottom' or 'top' of whichever axis `kind` names. Shared by `lifter_dunnage()` (the drawing) and `pa_handling._lifter_table_block` (the Face / Side Edge / Top Edge summary table), so the two can never disagree about what a given lifter is -- same reason `centroid()` is shared for the CG marker and the printed kv block. `values['top_edge_lifters']` / `values['side_edge_lifters']` -- free text, the same `'x_ft @ frac'` grammar as `edge_lifters` -- are PREFERRED OUTRIGHT over classifying `edge_lifters` by geometry, whenever either one carries any text: both name `kind` outright (every `top_edge_lifters` row is `'end'`, every `side_edge_lifters` row is `'side'` -- no geometry check needed for THAT). `side` still needs one: `pa_lifters.edge_lifter_lines` (the reads sibling that writes these fields) files BOTH a panel's bottom-end AND top-end lifters into `top_edge_lifters` -- the field name is a holdover, not a promise every row in it is a TOP lifter -- so a `top_edge_lifters` row's `side` is still read off `x_ft` via `_end_side_from_x` (same `_END_TOL_FT` tolerance that module uses); a `side_edge_lifters` row's `side` is 'bottom'/'top' of the WIDTH axis, chosen by whichever half of `frac` it falls in. `edge_lifters` is read only when BOTH of those are blank, and each of its rows is classified by `_classify_from_geometry()`. Never raises; `values` never mutated. `width_ft` is accepted for a uniform call signature with `lifter_dunnage()`'s other helpers but not read here -- classification only ever needs `x_ft` / `frac` / `length_ft`. """ values = values or {} table_rows = _edge_lifter_rows_from_table(values, length_ft, width_ft) if table_rows: return table_rows, [] top_text = values.get('top_edge_lifters') side_text = values.get('side_edge_lifters') has_top = bool(top_text and str(top_text).strip()) has_side = bool(side_text and str(side_text).strip()) rows = [] problems = [] if has_top or has_side: if has_top: parsed, probs = _parse_lifter_lines(top_text) problems.extend('top edge lifters, ' + p for p in probs) for x_ft, frac in parsed: side = _end_side_from_x(x_ft, length_ft) ambiguous = side is None if ambiguous: side = 'top' problems.append( 'Top edge lifter at {0:.2f} ft / {1:.2f} does not ' 'sit near either panel end; classified top by the ' 'field name, position flagged ambiguous.' .format(x_ft, frac)) rows.append({'x_ft': x_ft, 'frac': frac, 'kind': 'end', 'side': side, 'ambiguous': ambiguous, 'source': 'top_edge_lifters'}) if has_side: parsed, probs = _parse_lifter_lines(side_text) problems.extend('side edge lifters, ' + p for p in probs) for x_ft, frac in parsed: side = 'bottom' if frac <= 0.5 else 'top' rows.append({'x_ft': x_ft, 'frac': frac, 'kind': 'side', 'side': side, 'ambiguous': False, 'source': 'side_edge_lifters'}) return rows, problems parsed, probs = _parse_lifter_lines(values.get('edge_lifters')) problems.extend(probs) for x_ft, frac in parsed: kind, side, ambiguous = _classify_from_geometry(x_ft, frac, length_ft) rows.append({'x_ft': x_ft, 'frac': frac, 'kind': kind, 'side': side, 'ambiguous': ambiguous, 'source': 'edge_lifters'}) if ambiguous: problems.append( 'Edge lifter at {0:.2f} ft / {1:.2f} sits neither near a ' 'panel end nor near a long edge; drawn as a side edge ' 'lifter by its fraction, position flagged ambiguous.' .format(x_ft, frac)) return rows, problems def dunnage_positions(values): """(positions_ft or None, problems) -- x positions (ft from start) for transverse dunnage lines. Prefers `values['dunnage_positions_ft']` -- a list of ft positions the reads sibling supplies straight off Panel Lift's job -- when it is a list with at least one entry that reads as a number; falls back to parsing `values['dunnage']` free text (one number per line, or comma/whitespace separated) otherwise. There is no plant-rule lookup reachable from this leaf module either way (that lives in `vibe.knowledge`, which this module deliberately does not import). NEITHER SOURCE PRESENT IS QUIET -- this is the 2026-09-04 fix: it used to add a `problems` sentence of its own for exactly this case, and `PanelAnalysis.pushbutton/script.py` prints every `problems` entry a drawing function returns as a permanent RED note directly under the drawing, so a panel with genuinely no dunnage data got a standing red warning under an otherwise-fine picture. `pa_handling.page()` now says so once, quietly, elsewhere on the page instead -- this function reports only a REAL defect: `dunnage_positions_ft` present but not a list, a list entry that will not parse as a number, or `dunnage` text with an unparseable token. `values` is never mutated. """ problems = [] raw_list = values.get('dunnage_positions_ft') if raw_list is not None: if isinstance(raw_list, (list, tuple)): out = [] for item in raw_list: v = _num(item) if v is None: problems.append( 'dunnage_positions_ft entry {0!r} is not a number; ' 'skipped.'.format(item)) continue out.append(v) if out: return out, problems # every entry (if any) was unusable -- fall through to text. else: problems.append( 'dunnage_positions_ft is not a list; ignored, the dunnage ' 'text field is read instead.') raw = values.get('dunnage') if raw is None or not str(raw).strip(): return None, problems text = str(raw).strip() out = [] for tok in re.split(r'[,\s]+', text): if not tok: continue v = _num(tok) if v is None: problems.append( 'Dunnage position {0!r} is not a number; skipped.' .format(tok)) continue out.append(v) if not out: return None, problems return out, problems # ----------------------------------------------------------------- centroid def centroid(values): """(cg_x_ft, cg_z_ft, cg_y_ft, xz_basis, y_basis, problems). `cg_x_ft` / `cg_z_ft`: the panel's PLAN-VIEW area centroid -- X from the START along `length_ft`, Z from the LEFT along `width_ft`. Half the length / width for a plain rectangle, corrected by subtracting each `Opening` row's area x its own centroid (`Reveal` / `Solid Zone` rows never reduce the plan-view area). `None` (with a `problems` entry naming it) when `length_ft` / `width_ft` is missing or non-positive. `cg_y_ft`: the through-thickness centroid, from the BOTTOM (the casting-bed rail) -- half of `thickness_in`, or when `section_type` is 'sandwich' and all three wythe thicknesses are entered, half of their SUM instead (the more granular figure when the two disagree; still geometrically half the total thickness -- see the module docstring for why this is not mass-weighted by wythe). `None` (with a `problems` entry) when neither figure is available. `xz_basis` / `y_basis`: one plain-English sentence each, naming what the number is based on -- 'solid rectangle', 'N opening(s) deducted', etc. `None` alongside the matching centroid value when it is `None`. Never raises. `values` is never mutated. """ values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) cg_x = cg_z = None xz_basis = None if length_ft is None or length_ft <= 0 or width_ft is None or width_ft <= 0: problems.append( 'Length (ft) and width (ft) are both needed to compute the ' 'panel centroid; not entered yet.') else: rows, row_problems = _openings_rows(values, length_ft, width_ft) problems.extend(row_problems) total_area = length_ft * width_ft mx = total_area * (length_ft / 2.0) mz = total_area * (width_ft / 2.0) net_area = total_area n_deducted = 0 if rows: for row in rows: if row['type'] != 'Opening' or row['vert'] is None: continue x0, x1 = row['along'] y0, y1 = row['vert'] cx0 = _clamp(x0, 0.0, length_ft) cx1 = _clamp(x1, 0.0, length_ft) cy0 = _clamp(y0, 0.0, width_ft) cy1 = _clamp(y1, 0.0, width_ft) if cx1 <= cx0 or cy1 <= cy0: continue area = (cx1 - cx0) * (cy1 - cy0) mx -= area * ((cx0 + cx1) / 2.0) mz -= area * ((cy0 + cy1) / 2.0) net_area -= area n_deducted += 1 if net_area > 0.0: cg_x = mx / net_area cg_z = mz / net_area xz_basis = ('{0} opening(s) deducted'.format(n_deducted) if n_deducted else 'solid rectangle') else: cg_x = length_ft / 2.0 cg_z = width_ft / 2.0 xz_basis = ('openings account for the whole panel area; shown ' 'at the panel geometric center') problems.append( 'Openings account for the whole panel area; the plan ' 'centroid is shown at the panel geometric center instead.') cg_y = None y_basis = None thickness_in = _num(values.get('thickness_in')) section_type = str(values.get('section_type') or '').strip().lower() if section_type == 'sandwich': top_in = _num(values.get('wythe_top_in')) ins_in = _num(values.get('insulation_in')) bot_in = _num(values.get('wythe_bottom_in')) layers = (top_in, ins_in, bot_in) if all(v is not None and v > 0 for v in layers): total_t = top_in + ins_in + bot_in cg_y = total_t / 24.0 y_basis = ('sandwich wythes ({0:.2f} + {1:.2f} + {2:.2f} in), ' 'half thickness'.format(bot_in, ins_in, top_in)) elif thickness_in is not None and thickness_in > 0: cg_y = thickness_in / 24.0 y_basis = ('solid rectangle, half thickness (sandwich wythe ' 'thicknesses not fully entered)') else: problems.append( 'Sandwich wythe thicknesses and panel thickness are both ' 'missing or incomplete; the through-thickness centroid ' 'cannot be computed.') else: if thickness_in is not None and thickness_in > 0: cg_y = thickness_in / 24.0 y_basis = 'solid rectangle, half thickness' else: problems.append( 'Thickness (in) is missing or not a positive number; the ' 'through-thickness centroid cannot be computed.') return cg_x, cg_z, cg_y, xz_basis, y_basis, problems # --------------------------------------------------------------------- API def lifter_dunnage(values, width_px, height_px): """(primitives, problems) -- the "Lifter and Dunnage Detail" plan: panel outline, openings/reveals/solid zones to scale, face lifters as filled circles, edge lifters as triangles on the edge they sit on (an END edge triangle pinned to `x = 0`/`length_ft`, a SIDE edge triangle pinned to `y = 0`/`width_ft` -- see `parse_edge_lifters()`), dunnage as dashed transverse lines with a 'Dunnage' label (when there is anything to draw one from -- see `dunnage_positions()`), dimension chains (lifter x positions along the top, transverse positions along the left, every label carrying a `font_px` hint) and the centroid marked. See the module docstring for the layout rule (length always fills the drawing's full width), the primitive vocabulary, and why it is a hard stop only for `length_ft` / `width_ft`. Never raises. `values` is never mutated. """ values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length (ft) is missing or not a positive number; the lifter ' 'and dunnage detail is waiting on it.') if width_ft is None or width_ft <= 0: problems.append( 'Width (ft) is missing or not a positive number; the lifter ' 'and dunnage detail is waiting on it.') if problems: return [], problems primitives = [] margin = _margin(width_px, height_px) left_margin = _left_margin(width_px, height_px) avail_w = max(float(width_px) - left_margin - margin, 1.0) avail_h = max(float(height_px) - 2.0 * margin, 1.0) # The panel's LENGTH always fills the drawing's full available width # (after the reserved left gutter below) -- never letterboxed to # whichever axis is tighter. Owner, 2026-09-04: a panel is always # drawn lengthwise, no matter what panel it is. draw_w = avail_w draw_h = draw_w * (width_ft / length_ft) # The LEFT margin is reserved for the transverse dimension chain (see # the THIRD ruling above `_LEFT_MARGIN_MIN_PX`) -- every other side # still uses the ordinary `margin`. ox = left_margin oy = margin + max(0.0, (avail_h - draw_h) / 2.0) primitives.append({'kind': 'rect', 'x': ox, 'y': oy, 'w': draw_w, 'h': draw_h, 'role': 'panel-plan'}) title = _title_primitive(values.get('mark'), values.get('design_number')) if title: primitives.append(title) rows, row_problems = _openings_rows(values, length_ft, width_ft) problems.extend(row_problems) if rows: opening_prims, opening_problems = _opening_rect_primitives( rows, length_ft, width_ft, ox, oy, draw_w, draw_h) primitives.extend(opening_prims) problems.extend(opening_problems) all_x = [] # every lifter's along-length position, for the top chain all_y_ft = [] # every lifter's transverse position (ft), for the left chain face_rows, face_problems = _face_lifter_rows(values) problems.extend(face_problems) for x_ft, frac in face_rows: flagged = x_ft < 0.0 or x_ft > length_ft or frac < 0.0 or frac > 1.0 if flagged: problems.append( 'Face lifter at {0:.2f} ft / {1:.2f} is outside the panel; ' 'shown clamped and flagged.'.format(x_ft, frac)) cx = _clamp(x_ft, 0.0, length_ft) cf = _clamp(frac, 0.0, 1.0) px = ox + (cx / length_ft) * draw_w py = oy + draw_h - cf * draw_h primitives.append({'kind': 'dot', 'x': px, 'y': py, 'role': 'face-lifter', 'flagged': flagged}) all_x.append(cx) all_y_ft.append(cf * width_ft) edge_rows, edge_problems = parse_edge_lifters(values, length_ft, width_ft) problems.extend(edge_problems) for row in edge_rows: x_ft, frac = row['x_ft'], row['frac'] kind, side = row['kind'], row['side'] flagged = x_ft < 0.0 or x_ft > length_ft or frac < 0.0 or frac > 1.0 if flagged: problems.append( 'Edge lifter at {0:.2f} ft / {1:.2f} is outside the panel; ' 'shown clamped and flagged.'.format(x_ft, frac)) cx = _clamp(x_ft, 0.0, length_ft) cf = _clamp(frac, 0.0, 1.0) if kind == 'end': # Pinned to the actual end edge (x = 0 or x = length_ft), not # to the lifter's own (possibly slightly-off) x_ft -- see the # module docstring's 2026-09-04 ruling #1. Transverse position # varies continuously with frac, never snapped to the panel's # top/bottom the way a side lifter's longitudinal position is. px = ox if side == 'bottom' else ox + draw_w py = oy + draw_h - cf * draw_h else: px = ox + (cx / length_ft) * draw_w py = oy + draw_h if side == 'bottom' else oy primitives.append({'kind': 'support', 'x': px, 'y': py, 'role': 'edge-lifter', 'flagged': flagged, 'edge': side, 'edge_kind': kind, 'ambiguous': row.get('ambiguous', False)}) all_x.append(cx) all_y_ft.append(cf * width_ft) if not face_rows and not edge_rows: problems.append( 'No face or edge lifter positions entered yet; the drawing ' 'shows the panel outline only.') # ---- dimension chain: lifter x positions from the start, along the top if all_x: uniq_x = sorted(set(round(x, 3) for x in all_x)) top_y = max(oy - margin * 0.6, 4.0) primitives.append({ 'kind': 'line', 'x1': ox + (uniq_x[0] / length_ft) * draw_w, 'y1': top_y, 'x2': ox + (uniq_x[-1] / length_ft) * draw_w, 'y2': top_y, 'role': 'dimension'}) for x_ft in uniq_x: px = ox + (x_ft / length_ft) * draw_w primitives.append({'kind': 'line', 'x1': px, 'y1': top_y, 'x2': px, 'y2': oy, 'role': 'dimension'}) primitives.append({'kind': 'text', 'x': px, 'y': max(top_y - 12.0, 0.0), 'text': '{0:.2f} ft'.format(x_ft), 'role': 'dimension', 'anchor': 'middle', 'font_px': 11}) # ---- dimension chain: transverse positions, along the left. ONE # vertical chain (a backbone line the full span of the distinct # positions, one tick + one label per DISTINCT position -- face # lifters' AND end-edge lifters' transverse positions both land in # `all_y_ft` the same way). The chain lives entirely inside the # reserved `left_margin` gutter (see `_left_margin()`), and two labels # whose PIXEL y falls within `_LEFT_LABEL_STAGGER_TOL_PX` of each other # alternate between an inner and an outer column so neither is ever # drawn on top of the other -- the 2026-09-04 THIRD ruling's fix for a # live render that showed this as a squashed, illegible stack of ticks. if all_y_ft: uniq_y = sorted(set(round(y, 3) for y in all_y_ft)) left_x = max(ox - left_margin * 0.5, 4.0) inner_x = max(left_x - 6.0, 4.0) outer_x = max(left_x - 6.0 - _LEFT_LABEL_STAGGER_OFFSET_PX, 4.0) y_top_px = oy + draw_h - (uniq_y[-1] / width_ft) * draw_h y_bot_px = oy + draw_h - (uniq_y[0] / width_ft) * draw_h primitives.append({'kind': 'line', 'x1': left_x, 'y1': y_top_px, 'x2': left_x, 'y2': y_bot_px, 'role': 'dimension'}) prev_py = None outer = False for y_ft in uniq_y: py = oy + draw_h - (y_ft / width_ft) * draw_h primitives.append({'kind': 'line', 'x1': left_x, 'y1': py, 'x2': ox, 'y2': py, 'role': 'dimension'}) if prev_py is not None and \ abs(py - prev_py) < _LEFT_LABEL_STAGGER_TOL_PX: outer = not outer else: outer = False text_x = outer_x if outer else inner_x primitives.append({'kind': 'text', 'x': text_x, 'y': py, 'text': '{0:.2f} ft'.format(y_ft), 'role': 'dimension', 'anchor': 'middle', 'orientation': 'vertical', 'font_px': 11, 'stagger': 'outer' if outer else 'inner'}) prev_py = py # ---- dunnage: dashed transverse lines, only when there is data for one dunnage_x, dunnage_problems = dunnage_positions(values) problems.extend(dunnage_problems) if dunnage_x: for x_ft in dunnage_x: flagged = x_ft < 0.0 or x_ft > length_ft cx = _clamp(x_ft, 0.0, length_ft) px = ox + (cx / length_ft) * draw_w primitives.append({'kind': 'line', 'x1': px, 'y1': oy, 'x2': px, 'y2': oy + draw_h, 'role': 'dunnage', 'dashed': True, 'flagged': flagged}) primitives.append({'kind': 'text', 'x': px, 'y': oy + draw_h + 4.0, 'text': 'Dunnage', 'role': 'dunnage-label', 'anchor': 'middle', 'flagged': flagged}) # ---- centroid marker cg_x, cg_z, _cg_y, _xz_basis, _y_basis, cg_problems = centroid(values) problems.extend(cg_problems) if cg_x is not None and cg_z is not None: cg_px = ox + (cg_x / length_ft) * draw_w cg_py = oy + draw_h - (cg_z / width_ft) * draw_h primitives.append({'kind': 'marker', 'x': cg_px, 'y': cg_py, 'role': 'centroid', 'glyph': 'cross'}) primitives.append({'kind': 'text', 'x': cg_px, 'y': max(cg_py - 12.0, 0.0), 'text': 'CG', 'role': 'annotation', 'anchor': 'middle'}) primitives = [_clamp_primitive(p, float(width_px), float(height_px)) for p in primitives] return primitives, problems # --------------------------------------------------- real-unit geometry (ft) # # Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no # canvas size, no Revit -- built entirely from this module's own real-unit # helpers (`_parse_lifter_lines`, `parse_edge_lifters`, `dunnage_positions`, # `_openings_rows`, `centroid`), the SAME ones `lifter_dunnage()` itself # calls, so the JSON and the still-live WPF picture can never disagree about # what a lifter, a dunnage line, or the centroid marker means. def lifter_dunnage_geometry(values): """`{'length_ft', 'width_ft', 'face_lifters': [...], 'edge_lifters': [...], 'dunnage_positions_ft': [...], 'openings': [...], 'centroid': {...}, 'problems': [...]}` -- real feet, no pixels. `face_lifters` is `[{'x_ft', 'frac'}, ...]`; `edge_lifters` is exactly what `parse_edge_lifters()` returns (`x_ft` / `frac` / `kind` / `side` / `ambiguous` / `source` per row); `dunnage_positions_ft` is a flat list of along-length ft positions (`[]` when there is none to draw, never a fabricated line); `openings` is the same row shape `pa_sketch_extents.outline_geometry_ft()` returns (`along` / `vert` / `cross` ranges in ft). `centroid` is `{'x_ft', 'z_ft', 'y_ft', 'xz_basis', 'y_basis'}` -- any of the three coordinates can be `None` when `centroid()` could not compute it (see that function's own docstring); the two basis sentences say why. `length_ft` / `width_ft` are `None` when not entered -- this function never invents a placeholder rectangle the way `pa_sketch_extents. outline_geometry_ft()` does, because every one of the lists above is only meaningful relative to a real length/width and a fabricated pair would misplace every lifter drawn against it; the page is expected to show "enter length and width" in that case, named in `problems`, rather than a picture that looks plausible and is not. Never raises; `values` is read-only. """ values = values or {} problems = [] length_ft = _num(values.get('length_ft')) width_ft = _num(values.get('width_ft')) if length_ft is None or length_ft <= 0: problems.append( 'Length (ft) is missing or not a positive number; lifter and ' 'dunnage positions cannot be placed yet.') if width_ft is None or width_ft <= 0: problems.append( 'Width (ft) is missing or not a positive number; lifter and ' 'dunnage positions cannot be placed yet.') face_rows, p = _face_lifter_rows(values) problems.extend(p) face_lifters = [{'x_ft': x, 'frac': f} for x, f in face_rows] edge_lifters, p = parse_edge_lifters(values, length_ft, width_ft) problems.extend(p) dunnage, p = dunnage_positions(values) problems.extend(p) openings = [] if length_ft and width_ft: rows, p = _openings_rows(values, length_ft, width_ft) problems.extend(p) for r in (rows or []): openings.append({ 'type': r.get('type'), 'role': r.get('role'), 'label': r.get('label'), 'index': r.get('index'), 'along': list(r['along']) if r.get('along') else None, 'vert': list(r['vert']) if r.get('vert') else None, 'cross': list(r['cross']) if r.get('cross') else None, }) cg_x, cg_z, cg_y, xz_basis, y_basis, p = centroid(values) problems.extend(p) return { 'length_ft': length_ft, 'width_ft': width_ft, 'face_lifters': face_lifters, 'edge_lifters': edge_lifters, 'dunnage_positions_ft': dunnage or [], 'openings': openings, 'centroid': {'x_ft': cg_x, 'z_ft': cg_z, 'y_ft': cg_y, 'xz_basis': xz_basis, 'y_basis': y_basis}, 'problems': problems, }