25592fa6c2
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
1286 lines
61 KiB
Python
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,
|
|
}
|