Files
willtools-demo/WillTools.extension/lib/vibe/pa_sketch_handling.py
T
will 8e6ee31596
ci / test (push) Successful in 6m18s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
History-free snapshot of the private development repository at cbe45c5
(2026-09-09). Code, docs and public catalogue only; no plant or job
knowledge layers, no client or job names (tools/scrub.py clean). The
intro deck is left out of this snapshot because its slide images could
not be checked for job drawings.
2026-09-09 08:32:15 -05:00

1286 lines
61 KiB
Python

# -*- 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,
}