Files
willtools-demo/WillTools.extension/lib/vibe/panelsketch.py
T
will 52d94303bc
ci / test (push) Has been cancelled
WillTools: precast shop-ticket QC and panel handling tools
Public demo snapshot of an in-house pyRevit extension for precast concrete
shop-ticket QC (37 checks / 46 checklist rows) and a standalone panel
handling solver (PCI Chapter 8).

- No client, job, plant or product data: knowledge layers ship as
  structural templates only.
- Ticket QC's model-read escalation targets a LAN model server endpoint
  (configurable), not a cloud provider.
- Built from the private development repo via tools/make_dist.py.
2026-09-03 16:33:47 -05:00

1551 lines
70 KiB
Python

# -*- coding: utf-8 -*-
"""The Panel Analysis elevation sketch -- pure geometry, both Pythons, no Revit,
no WPF.
WHY THIS EXISTS: the Panel Analysis input form (`panelanalysisform.py`) pops
one collapsible section per selected panel, and today it is all text boxes.
Eriksson Wall -- the vendor tool this feature is modelled on -- draws the
panel elevation right beside its inputs (`docs/eriksson-wall-method.md` A.8.4:
"Cover-sheet strand/reinforcing patterns and stripping-pick geometry ... each
p.1 ... dimensioned drawings"), so the engineer can SEE that what they typed
matches the piece instead of trusting a column of numbers. The owner asked
for that drawing here.
THE SEAM: the WPF rendering (a `Canvas`, brushes, a `Polygon` for the support
triangles) is the button's job, not this module's. This module's job stops at
a flat list of drawing PRIMITIVES in a normalised, already-in-pixels
coordinate space -- the caller only has to walk the list and paint each one
onto a `Canvas` of whatever size it happens to have. That keeps this file
importable and testable on plain CPython the way the rest of the Revit-free
`vibe` is (see `tests/conftest.py`), and runnable under IronPython 2.7 inside
Revit: no f-strings, no `pathlib`, no `dataclasses`, no typing, no `enum`,
standard library only (`math` is not even needed -- plain arithmetic and `re`
for the supports text).
THE INPUT is `values`, the SAME dict `panelanalysisform.py` reads from the
form -- string-valued, one key per `panelanalysisform.FIELDS` row, still in
whatever state the user has typed it to (including blank). This module does
NOT import `panelanalysisform` -- duplicating the handful of parsing rules it
needs (a float, a comma/whitespace list of floats) keeps this file a leaf with
zero project imports, which matters more here than avoiding a few lines of
duplication: `panelanalysisform.py` is free to change its parsing rules for
reasons that have nothing to do with how a panel is drawn, and a leaf module
cannot be broken by a change three layers away. Fields used, verbatim from
`panelanalysisform.FIELDS`:
length_ft the panel's length/height (ft) -- the SPAN direction; this
is the axis `supports_ft` positions are measured along
(FIELDS: "Support positions along length"), so it is drawn
as the drawing's horizontal axis.
width_ft the panel's width (ft) -- drawn as the vertical axis.
thickness_in cannot be shown geometrically in an elevation (a flat
drawing has no third dimension); printed as text instead.
supports_ft free text, comma/whitespace-separated positions in feet
along `length_ft`. `values['supports']`, a pre-parsed list
of numbers (what `panelanalysisform.validate` produces), is
accepted too and preferred when present, so a caller that
already validated the form does not have to re-render text.
openings free text ("count / description" per FIELDS) -- see below.
mark, design_number
used only to label the sketch, if present; never required.
MISSING INPUT IS THE HEADLINE STATE, not a crash and not a default. The form
pre-fills what the model could read and leaves the rest genuinely BLANK
(`panelanalysisform`'s whole reason for existing); a drawing module that
invented a rectangle for a blank field would be lying with a picture instead
of with a number, which is worse, because a picture reads as more trustworthy
than a text box. So: `length_ft` or `width_ft` missing, blank, non-numeric or
<= 0 makes `sketch()` return `([], problems)` -- NO panel rectangle, nothing
letterboxed, nothing guessed -- with one plain sentence per such field saying
which input the drawing is waiting on. Everything else (a missing thickness,
an out-of-range support, unparsed openings prose) still draws what it safely
can and reports the rest through `problems` alongside real primitives; only
the two geometry-defining fields are a hard stop, because there is no honest
rectangle to draw without them.
A support outside `[0, length_ft]` is never silently dropped -- it is real
data the user typed and dropping it would hide a genuine input error. It is
reported in `problems` AND still drawn, clamped to the nearest panel end, with
its primitive carrying `'flagged': True` so the caller can render it in a
different colour.
OPENINGS: this module defines exactly ONE drawable pattern, and it draws
nothing else -- ever. That pattern is: none. `openings` is free "count /
description" prose (FIELDS: "Openings (count / description)") with no
position or size in it at all, so there is no text shape in that field that
could honestly become a rectangle at a specific place on the panel -- drawing
one anyway would put a hole in a spot nobody said it was at, which is a lie a
reader cannot detect by looking. So any non-blank `openings` text always
produces a `text` primitive (`role: 'openings-note'`) saying openings were
described but are not drawn, never a rectangle. The only parsing done on it
is a best-effort leading integer (`'2 openings, ...' -> 2`) purely to phrase
that note ("2 opening(s) described..." vs quoting the raw text); it never
gates whether something is drawn, because nothing here ever is.
PRIMITIVES are plain dicts, coordinates already in pixels, origin top-left,
y increasing downward (WPF's `Canvas` convention), every one of them clamped
so it lies inside `[0, width_px] x [0, height_px]` -- dimension text and
support markers included, which is the whole reason `sketch()` takes a margin
into account rather than filling the box edge-to-edge. The vocabulary:
{'kind': 'rect', 'x','y','w','h', 'role': 'panel'}
the panel elevation itself, exactly one per successful sketch, drawn
to scale (`w / h == length_ft / width_ft`) and letterboxed inside the
box with the aspect ratio preserved -- never stretched.
{'kind': 'support', 'x','y', 'role': 'support', 'flagged': bool}
one per support, at its true proportional position along the panel's
bottom edge; `flagged` is True when the typed position fell outside
`[0, length_ft]` and had to be clamped to draw at all.
{'kind': 'text', 'x','y', 'text', 'role': 'support-label', 'anchor': 'middle'}
the support's value in feet, printed under its marker.
{'kind': 'line', 'x1','y1','x2','y2', 'role': 'dimension'}
the length and width dimension lines (one each).
{'kind': 'text', 'x','y', 'text', 'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical' (width only)}
the length/width values with units; the width one carries
`orientation: 'vertical'` as a hint that the caller may want to
rotate the glyph the way the width dimension line is drawn (vertical).
{'kind': 'text', 'x','y', 'text', 'role': 'annotation', 'anchor': 'middle'}
the thickness, printed as text because an elevation cannot show it
geometrically -- 'Thickness: not entered' when the field is blank,
never a faked value.
{'kind': 'text', 'x','y', 'text', 'role': 'openings-note', 'anchor': 'middle'}
see OPENINGS above.
{'kind': 'text', 'x','y', 'text', 'role': 'note', 'anchor': 'middle'}
minor non-blocking notices (currently: no supports entered).
{'kind': 'text', 'x','y', 'text', 'role': 'title', 'anchor': 'start'}
the piece mark / design number, if either was given.
`sketch()` never mutates the `values` dict it is handed -- it only ever calls
`.get()` on it.
"""
import re
_MARGIN_FRACTION = 0.15 # of the shorter box side
_MARGIN_MIN_PX = 8.0
_MARGIN_MAX_PX = 60.0
# ------------------------------------------------------------------ parsing
# Deliberately small and independent of `panelanalysisform`'s parsing --
# see the module docstring for why this module does not import it.
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; not a number here
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 _supports_of(values):
"""The support positions, in feet, as a plain list. [] if none given.
Prefers `values['supports']` (a pre-parsed list, the shape
`panelanalysisform.validate` produces) when it is a list; otherwise reads
`values['supports_ft']` as comma/whitespace-separated text, the same
split `panelanalysisform.parse_supports` uses. A token that will not
parse as a number is dropped rather than raising -- this module draws
what it can and leaves validation-grade error text to the form.
"""
raw = values.get('supports')
if isinstance(raw, (list, tuple)):
out = []
for item in raw:
v = _num(item)
if v is not None:
out.append(v)
return out
text = values.get('supports_ft')
if text is None:
return []
text = str(text).strip()
if not text:
return []
out = []
for tok in re.split(r'[,\s]+', text):
if not tok:
continue
v = _num(tok)
if v is not None:
out.append(v)
return out
def _openings_count(text):
"""A leading integer read off the openings text, or None. Phrasing only
-- see the module docstring: this never decides whether anything is
drawn, because nothing about openings is ever drawn.
"""
if not text:
return None
text = str(text).strip()
m = re.match(r'^(\d+)\b', text)
if not m:
return None
try:
return int(m.group(1))
except (TypeError, ValueError):
return None
# ------------------------------------------------------------------ layout
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 _clamp(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
def _clamp_primitive(prim, width_px, height_px):
"""Pulls one primitive's coordinates back inside the box, in place.
The layout math already aims everything inside the margin, but a tiny
box (a caller-supplied `width_px`/`height_px` smaller than the margin
budget) or a support clamped at a panel end can still land a coordinate
a hair outside 0..width_px / 0..height_px; this is the one place that
guarantees the invariant rather than trusting the arithmetic above it.
"""
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
# --------------------------------------------------------------------- API
def sketch(values, width_px, height_px):
"""(primitives, problems) -- the panel elevation as drawing primitives.
`values` is the form's dict (see the module docstring for the keys read).
`width_px` / `height_px` is the Canvas size the caller has to fit inside;
the drawing is letterboxed into it preserving `length_ft / width_ft`.
`problems` carries one plain-English sentence per issue found, whether or
not it was survivable:
- `length_ft` / `width_ft` missing, unreadable or <= 0 -- FATAL, this
is the only case where `primitives` comes back `[]`.
- a support outside `[0, length_ft]` -- non-fatal, still drawn flagged.
- `thickness_in` missing -- non-fatal, annotated as "not entered".
- no supports at all -- non-fatal, the elevation still draws.
Nothing here is a defaulted value standing in for a real one; every
problem names the field it is about.
"""
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 / height (ft) is missing or not a positive number; the '
'panel elevation 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 panel '
'elevation is waiting on it.')
if problems:
return [], problems
thickness_in = _num(values.get('thickness_in'))
supports = _supports_of(values)
openings_text = values.get('openings')
mark = (values.get('mark') or '').strip() if values.get('mark') else ''
design_number = ((values.get('design_number') or '').strip()
if values.get('design_number') else '')
primitives = []
margin = _margin(width_px, height_px)
avail_w = width_px - 2.0 * margin
avail_h = height_px - 2.0 * margin
if avail_w < 1.0:
avail_w = 1.0
if avail_h < 1.0:
avail_h = 1.0
panel_aspect = length_ft / width_ft
box_aspect = avail_w / avail_h
if panel_aspect >= box_aspect:
draw_w = avail_w
draw_h = draw_w / panel_aspect
else:
draw_h = avail_h
draw_w = draw_h * panel_aspect
ox = margin + (avail_w - draw_w) / 2.0
oy = margin + (avail_h - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy,
'w': draw_w, 'h': draw_h, 'role': 'panel'})
# ---- title (mark / design number), only if the form gave one
if mark or design_number:
bits = [b for b in (mark, design_number) if b]
primitives.append({'kind': 'text', 'x': 4.0, 'y': 4.0,
'text': ' / '.join(bits), 'role': 'title',
'anchor': 'start'})
# ---- supports, at their true proportional position along the length
if not supports:
problems.append(
'No support positions entered; the elevation cannot show them '
'yet.')
for pos in supports:
flagged = pos < 0.0 or pos > length_ft
if flagged:
problems.append(
'Support at {0:.2f} ft is outside the panel length (0 to '
'{1:.2f} ft); shown clamped to the nearest end.'
.format(pos, length_ft))
clamped = pos
if clamped < 0.0:
clamped = 0.0
elif clamped > length_ft:
clamped = length_ft
frac = clamped / length_ft
sx = ox + frac * draw_w
sy = oy + draw_h
primitives.append({'kind': 'support', 'x': sx, 'y': sy,
'role': 'support', 'flagged': flagged})
primitives.append({'kind': 'text', 'x': sx, 'y': sy + 14.0,
'text': '{0:.2f} ft'.format(pos),
'role': 'support-label', 'anchor': 'middle'})
# ---- length dimension, below the panel
dim_y = oy + draw_h + 30.0
primitives.append({'kind': 'line', 'x1': ox, 'y1': dim_y,
'x2': ox + draw_w, 'y2': dim_y, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': dim_y + 12.0,
'text': 'L = {0:.2f} ft'.format(length_ft),
'role': 'dimension', 'anchor': 'middle'})
# ---- width dimension, left of the panel
dim_x = ox - 30.0
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': dim_x - 12.0,
'y': oy + draw_h / 2.0,
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical'})
# ---- thickness -- an elevation cannot show it geometrically
if thickness_in is None:
t_text = 'Thickness: not entered'
problems.append(
'Thickness (in) is missing; noted on the drawing but not shown '
'-- an elevation cannot show thickness geometrically.')
else:
t_text = 'Thickness: {0:.2f} in'.format(thickness_in)
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(margin / 2.0, 10.0), 'text': t_text,
'role': 'annotation', 'anchor': 'middle'})
# ---- openings -- never drawn as geometry; see module docstring
if openings_text is not None and str(openings_text).strip():
n = _openings_count(openings_text)
if n is not None:
note = ('{0} opening(s) described on the form but not drawn '
'(position / size are not given in a drawable form).'
.format(n))
else:
note = ('Openings described on the form ({0!r}) are not drawn '
'-- position and size are not in a recognised form.'
.format(str(openings_text).strip()))
problems.append(note)
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h / 2.0, 'text': note,
'role': 'openings-note', 'anchor': 'middle'})
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
# ============================================================= extension ===
# Everything below is additive. `sketch()` above is UNCHANGED -- same code,
# same primitives, same problems -- because the button that renders these
# primitives calls it today and nothing here may move that ground. The four
# functions below are new entry points, same contract (pixel coords, origin
# top-left, y down, everything clamped inside the box, scale-true or not
# drawn, missing input is a first-class reported state, `values` is never
# mutated -- see the module docstring above for the discipline all of this
# inherits).
#
# NEW KEYS READ, all optional, all "not entered" when absent (see
# `panelanalysisform.validate()`'s output dict):
# openings_rows -> [{'type','pos_along','pos_along_from','length',
# 'pos_cross','pos_cross_from','width','pos_vert',
# 'height','label'}]. `type` is 'Opening', 'Reveal' or
# 'Solid Zone'. See `_parse_opening_row` for the full
# datum-resolution contract and the axis-assignment
# assumption spelled out there -- it is a judgement call,
# not something this module can verify on its own, and it
# is flagged as such rather than asserted quietly.
# strand_rows -> [{'count','depth_in','pull_pct'}], depth from the
# panel bottom (the rail, in casting terms). `pull_pct`
# is None when absent.
# weight_lb, face_lifters, edge_lifters (free text), stages (list of
# stage-name strings).
#
# LIFTER / DUNNAGE POSITIONS ARE NEVER DRAWN. `face_lifters` / `edge_lifters`
# are free text and carry no position or size. Scattering a triangle/bar
# glyph across the plan for them would be inventing a position nobody typed,
# which the module docstring above already rules out for openings; the same
# rule applies here with no exception. `plan()` below still draws the LEGEND
# (the shape/fill key is static, not tied to any one marker's position) and
# reports the free text as a note; the marker vocabulary (`role` in
# `face-lifter` / `edge-lifter` / `dunnage`, each carrying `stage_fill`) is
# documented and ready to receive row-shaped position data the day a future
# agent adds it -- wire it in next to `_parse_opening_row` below.
# ============================================================================
def _letterbox(a_ft, b_ft, avail_w, avail_h):
"""draw_w, draw_h -- `a_ft` x `b_ft` scaled uniformly (same px/ft on both
axes) to fit inside `avail_w` x `avail_h`, preserving aspect ratio."""
aspect = a_ft / b_ft
box_aspect = avail_w / avail_h
if aspect >= box_aspect:
draw_w = avail_w
draw_h = draw_w / aspect
else:
draw_h = avail_h
draw_w = draw_h * aspect
return draw_w, draw_h
_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 of a `total`-long axis, given a
position/extent that may have been typed measured from the FAR edge
instead (`frm == far_name`). `None` when `pos` or `extent` is missing,
or `extent` is non-positive -- never a guessed span."""
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, thickness_ft):
"""(row, problems) for one `openings_rows` entry.
AXIS ASSIGNMENT (a judgement call, not verifiable from this module --
flagged in the caller's report, not just here):
pos_along / length / pos_along_from (start/end)
-> the length_ft axis (horizontal in every drawing). High
confidence: 'along' unambiguously means along the panel's length.
pos_vert / height (always measured from the BOTTOM -- there is no
`_from` field for it, matching the `strand_rows.depth_in`
convention already in this module)
-> the width_ft axis (vertical in the front elevation; the same
physical dimension is the "down the page" axis in `plan()`,
because a wall panel's width_ft IS its height once erect and the
panel's true footprint width while it is being handled flat --
same number, two drawings).
pos_cross / width / pos_cross_from (left/right)
-> the THROUGH-THICKNESS axis (horizontal in the side elevation
and the cross-section slice, where the two faces of the panel are
naturally "left" and "right"). Medium confidence -- this is the
one call in this module that cannot be checked against anything
else already in the codebase.
A row missing `pos_along`/`length` cannot be placed on ANY drawing and
is reported once, by index, with no primitive. Missing `pos_vert`/
`height` or `pos_cross`/`width` narrows what CAN be drawn (front/plan
vs. side/cross-section respectively) without blocking the rest -- each
caller checks the axis it needs and reports separately when it is
absent, so one row can appear correctly in one drawing and be reported
as not-drawn in another."""
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 on any view.'
.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') # bottom-referenced only; no _from field
pos_cross = item.get('pos_cross')
width_ext = item.get('width')
cross_from = item.get('pos_cross_from') or 'left'
cross = None
if thickness_ft is not None and thickness_ft > 0.0:
cross = _resolve_axis_range(
_num(pos_cross) if pos_cross is not None else None,
_num(width_ext) if width_ext is not None else None,
thickness_ft, cross_from, 'right')
row = {'type': type_, 'role': _OPENING_ROLE_BY_TYPE.get(type_, 'opening'),
'label': label, 'index': index,
'along': along, 'vert': vert, 'cross': cross}
return row, problems
def _openings_rows(values, length_ft, width_ft, thickness_ft=None):
"""(rows, problems). `rows` is `None` when `values['openings_rows']` is
not a list (caller should fall back to the free-text `openings` note
where one exists); otherwise a list of parsed rows, one per entry that
at least resolved an along-length position (see `_parse_opening_row`).
"""
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, thickness_ft)
problems.extend(row_problems)
if row is not None:
rows.append(row)
return rows, problems
def _complement_segments(ranges, total):
"""(complement, covered) -- `covered` is `ranges` clamped to
`[0, total]`, sorted and merged; `complement` is what is left over (the
material NOT covered). Used both ways: as gap segments for a dimension
chain between openings (front elevation) and as the solid segments of a
cross-section slice once the covered ranges are punched out as voids."""
clean = []
for lo, hi in ranges:
lo = _clamp(lo, 0.0, total)
hi = _clamp(hi, 0.0, total)
if hi > lo:
clean.append((lo, hi))
clean.sort()
covered = []
for lo, hi in clean:
if covered and lo <= covered[-1][1]:
covered[-1] = (covered[-1][0], max(covered[-1][1], hi))
else:
covered.append((lo, hi))
complement = []
prev = 0.0
for lo, hi in covered:
if lo > prev:
complement.append((prev, lo))
prev = max(prev, hi)
if total > prev:
complement.append((prev, total))
return complement, covered
def _opening_rect_primitives(rows, length_ft, width_ft, ox, oy, draw_w,
draw_h):
"""Draws each row's `along` x `vert` extent to scale inside an
already-laid-out length_ft x width_ft box (front elevation or plan --
both use this same axis pair). A row with `vert is None` cannot be
placed on THIS drawing (it may still be usable on the side/cross-section
view, which needs `cross` instead) and is reported, not guessed."""
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 on this view.'
.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
def _gap_dimension_chain(rows, total, x1_fn, x2_fn, label_fmt, role='dimension'):
"""Dimension-line + text primitives for the gaps BETWEEN rows' `along`
extents (front elevation's bottom chain / the vertical `vert` chain use
this with different axis mappings supplied by the caller through
`x1_fn`/`x2_fn`, which turn a (lo, hi) ft segment into pixel endpoints).
Only rows with a resolved extent on the relevant axis contribute."""
primitives = []
if not rows:
return primitives
gaps, _covered = _complement_segments(rows, total)
for lo, hi in gaps:
if hi - lo <= 0.0:
continue
x1, y1 = x1_fn(lo)
x2, y2 = x2_fn(hi)
primitives.append({'kind': 'line', 'x1': x1, 'y1': y1,
'x2': x2, 'y2': y2, 'role': role})
mx = (x1 + x2) / 2.0
my = (y1 + y2) / 2.0
primitives.append({'kind': 'text', 'x': mx, 'y': my,
'text': label_fmt.format(hi - lo),
'role': role, 'anchor': 'middle'})
return primitives
def _strand_rows_of(values):
"""(rows, problems). `rows` is `None` when `values['strand_rows']` is
not a list; otherwise a list of `{'count','depth_in','pull_pct','index'}`.
A row with no readable `depth_in` is dropped and reported -- there is no
honest depth to draw it at. `count` is `None` when absent or unreadable
(drawn as a single centred dot, reported). `pull_pct` is `None` when
absent, never defaulted."""
raw = values.get('strand_rows')
if not isinstance(raw, list):
return None, []
rows = []
problems = []
for i, item in enumerate(raw):
if not isinstance(item, dict):
problems.append(
'Strand row {0} is not a recognised row; skipped.'
.format(i + 1))
continue
depth = _num(item.get('depth_in'))
if depth is None:
problems.append(
'Strand row {0} has no depth (in) entered; not drawn.'
.format(i + 1))
continue
count_raw = item.get('count')
count_n = None
if count_raw is not None:
try:
count_n = int(count_raw)
except (TypeError, ValueError):
count_n = None
pull_raw = item.get('pull_pct')
pull_n = _num(pull_raw) if pull_raw is not None else None
rows.append({'count': count_n, 'depth_in': depth, 'pull_pct': pull_n,
'index': i + 1})
return rows, problems
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'}
# --------------------------------------------------------------------- API
def elevation(values, width_px, height_px):
"""(primitives, problems) -- FOUR drawings, matching the vendor
reference this feature is modelled on: FRONT elevation + SIDE elevation
+ SECTION PROPERTIES (top row), and a CROSS SECTION slice (full width,
below). `length_ft` / `width_ft` missing or non-positive is the hard
stop for the whole function -- `([], problems)`, nothing letterboxed.
Each of the other three degrades independently when ITS input is
missing: side elevation and section properties both need
`thickness_in`; the cross section needs it too, for the slice height.
FRONT: the same picture `sketch()` draws, plus openings/reveals/solid
zones drawn to scale from `openings_rows` (new row shape -- see the
module note above `_parse_opening_row`), plus a dimension chain down
the left (the gaps between rows' vertical extents) and across the
bottom (the gaps between rows' along-length extents), drawn only when
there is at least one row with the needed axis resolved.
SIDE: thickness against the SAME px/ft scale as the front's width axis
-- unchanged from the first cut of this function.
SECTION PROPERTIES: computed for the GROSS rectangular section
width_ft(in) x thickness_in -- Area, Cx, Cy, Izz, Iyy, Izy, Sb, St.
Openings are NOT deducted (their effect varies along the length; a
single set of properties cannot honestly represent that), and this
module says so once in `problems` rather than pretending precision it
does not have. Omitted entirely (no primitives) when `thickness_in` is
missing -- there is nothing honest to compute.
CROSS SECTION: a thin length_ft x thickness_ft slice with `Opening` rows
punched out as literal gaps (drawn as separate solid segments, nothing
filling the gap). `Reveal` / `Solid Zone` rows do not create a gap here
(a reveal is partial-depth, not a through-cut; a solid zone by
definition removes nothing) -- their count is reported instead so nothing
is silently dropped."""
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 / height (ft) is missing or not a positive number; the '
'panel elevation 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 panel '
'elevation is waiting on it.')
if problems:
return [], problems
thickness_in = _num(values.get('thickness_in'))
thickness_ft = thickness_in / 12.0 if thickness_in and thickness_in > 0 else None
supports = _supports_of(values)
rows, row_problems = _openings_rows(values, length_ft, width_ft, thickness_ft)
problems.extend(row_problems)
primitives = []
margin = _margin(width_px, height_px)
avail_w_total = width_px - 2.0 * margin
avail_h_total = height_px - 2.0 * margin
if avail_w_total < 2.0:
avail_w_total = 2.0
if avail_h_total < 1.0:
avail_h_total = 1.0
gap = max(margin * 0.5, 10.0)
# ---- vertical split: top row (front + side + section properties),
# bottom band (cross section)
have_thickness = thickness_ft is not None
if have_thickness:
top_h = avail_h_total * 0.62
cross_h = avail_h_total - top_h - gap
else:
top_h = avail_h_total
cross_h = 0.0
problems.append(
'Thickness (in) is missing or not a positive number; the side '
'elevation, section properties and cross section are all '
'waiting on it.')
# ---- horizontal split of the top row: front / side / section props
props_alloc = avail_w_total * 0.22 if have_thickness else 0.0
side_alloc = avail_w_total * 0.20 if have_thickness else 0.0
front_alloc = avail_w_total - props_alloc - side_alloc - 2.0 * gap
if front_alloc < 1.0:
front_alloc = 1.0
draw_w, draw_h = _letterbox(length_ft, width_ft, front_alloc, top_h)
ox = margin + (front_alloc - draw_w) / 2.0
oy = margin + (top_h - draw_h) / 2.0
scale = draw_w / length_ft # px/ft; == draw_h / width_ft
primitives.append({'kind': 'rect', 'x': ox, 'y': oy,
'w': draw_w, 'h': draw_h, 'role': 'panel'})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 46.0, 'text': 'Front Elevation',
'role': 'caption', 'anchor': 'middle'})
title = _title_primitive(values.get('mark'), values.get('design_number'))
if title:
primitives.append(title)
if not supports:
problems.append(
'No support positions entered; the elevation cannot show them '
'yet.')
for pos in supports:
flagged = pos < 0.0 or pos > length_ft
if flagged:
problems.append(
'Support at {0:.2f} ft is outside the panel length (0 to '
'{1:.2f} ft); shown clamped to the nearest end.'
.format(pos, length_ft))
clamped = _clamp(pos, 0.0, length_ft)
frac = clamped / length_ft
sx = ox + frac * draw_w
sy = oy + draw_h
primitives.append({'kind': 'support', 'x': sx, 'y': sy,
'role': 'support', 'flagged': flagged})
primitives.append({'kind': 'text', 'x': sx, 'y': sy + 14.0,
'text': '{0:.2f} ft'.format(pos),
'role': 'support-label', 'anchor': 'middle'})
primitives.append({'kind': 'text', 'x': sx, 'y': sy + 26.0,
'text': '{0:.3f}L'.format(frac),
'role': 'support-fraction', 'anchor': 'middle'})
dim_y = oy + draw_h + 30.0
primitives.append({'kind': 'line', 'x1': ox, 'y1': dim_y,
'x2': ox + draw_w, 'y2': dim_y, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': dim_y + 12.0,
'text': 'L = {0:.2f} ft'.format(length_ft),
'role': 'dimension', 'anchor': 'middle'})
dim_x = ox - 30.0
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': oy,
'x2': dim_x, 'y2': oy + draw_h, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': dim_x - 12.0,
'y': oy + draw_h / 2.0,
'text': 'W = {0:.2f} ft'.format(width_ft),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical'})
if rows is None:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
n = _openings_count(openings_text)
if n is not None:
note = ('{0} opening(s) described on the form but not '
'drawn (position / size are not given in a '
'drawable form).'.format(n))
else:
note = ('Openings described on the form ({0!r}) are not '
'drawn -- position and size are not in a '
'recognised form.'
.format(str(openings_text).strip()))
problems.append(note)
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h / 2.0, 'text': note,
'role': 'openings-note', 'anchor': 'middle'})
else:
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)
along_ranges = [r['along'] for r in rows if r['vert'] is not None]
vert_ranges = [r['vert'] for r in rows if r['vert'] is not None]
if along_ranges:
primitives.extend(_gap_dimension_chain(
along_ranges, length_ft,
lambda lo: (ox + (lo / length_ft) * draw_w, dim_y + 26.0),
lambda hi: (ox + (hi / length_ft) * draw_w, dim_y + 26.0),
'{0:.2f} ft'))
if vert_ranges:
chain_x = dim_x - 30.0
primitives.extend(_gap_dimension_chain(
vert_ranges, width_ft,
lambda lo: (chain_x, oy + draw_h - (lo / width_ft) * draw_h),
lambda hi: (chain_x, oy + draw_h - (hi / width_ft) * draw_h),
'{0:.2f} ft'))
# ---- side elevation: thickness against the SAME width_ft scale
side_x0 = ox + draw_w + gap
if not have_thickness:
pass # already reported once above; nothing honest to draw
else:
side_draw_h = draw_h
side_draw_w = thickness_ft * scale
side_ox = side_x0
side_oy = oy
primitives.append({'kind': 'rect', 'x': side_ox, 'y': side_oy,
'w': side_draw_w, 'h': side_draw_h,
'role': 'panel-side'})
primitives.append({'kind': 'text',
'x': side_ox + max(side_draw_w, 40.0) / 2.0,
'y': side_oy + side_draw_h + 46.0,
'text': 'Side Elevation', 'role': 'caption',
'anchor': 'middle'})
t_dim_y = side_oy + side_draw_h + 20.0
primitives.append({'kind': 'line', 'x1': side_ox, 'y1': t_dim_y,
'x2': side_ox + side_draw_w, 'y2': t_dim_y,
'role': 'dimension'})
primitives.append({'kind': 'text',
'x': side_ox + side_draw_w / 2.0,
'y': t_dim_y + 12.0,
'text': 't = {0:.2f} in'.format(thickness_in),
'role': 'dimension', 'anchor': 'middle'})
# ---- section properties: gross rectangle width_ft(in) x thickness_in
props_x0 = side_ox + max(side_draw_w, 40.0) + gap
w_in = width_ft * 12.0
h_in = thickness_in
area = w_in * h_in
cx = w_in / 2.0
cy = h_in / 2.0
izz = w_in * (h_in ** 3) / 12.0
iyy = h_in * (w_in ** 3) / 12.0
izy = 0.0
sb = izz / (h_in / 2.0)
st = sb
props = [
('Area, in²', area), ('Width, in', w_in),
('Height, in', h_in), ('Cx, in', cx), ('Cy, in', cy),
('Izz, in⁴', izz), ('Iyy, in⁴', iyy),
('Izy, in⁴', izy), ('Sb, in³', sb),
('St, in³', st),
]
props_y = oy
for name, val in props:
primitives.append({'kind': 'text', 'x': props_x0, 'y': props_y,
'text': '{0}: {1:.2f}'.format(name, val),
'role': 'section-property', 'anchor': 'start'})
props_y += 18.0
primitives.append({'kind': 'text',
'x': props_x0 + max(props_alloc - gap, 10.0) / 2.0,
'y': props_y + 28.0, 'text': 'Section Properties',
'role': 'caption', 'anchor': 'middle'})
problems.append(
'Section properties are for the gross rectangular section '
'(width x thickness) with openings NOT deducted -- their '
'effect varies along the length and a single set of numbers '
'cannot represent that honestly.')
t_label = ('Thickness: {0:.2f} in'.format(thickness_in)
if thickness_in is not None else 'Thickness: not entered')
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(margin / 2.0, 10.0), 'text': t_label,
'role': 'annotation', 'anchor': 'middle'})
# ---- cross section: length_ft x thickness_ft slice, Opening rows as gaps
if have_thickness and cross_h > 1.0:
cross_oy0 = margin + top_h + gap
cross_draw_w, cross_draw_h = _letterbox(
length_ft, thickness_ft, avail_w_total, cross_h)
cross_ox = margin + (avail_w_total - cross_draw_w) / 2.0
cross_oy = cross_oy0 + (cross_h - cross_draw_h) / 2.0
opening_along = [r['along'] for r in (rows or []) if r['type'] == 'Opening']
solids, _covered = _complement_segments(opening_along, length_ft) \
if opening_along else ([(0.0, length_ft)], [])
for lo, hi in solids:
sx = cross_ox + (lo / length_ft) * cross_draw_w
sw = ((hi - lo) / length_ft) * cross_draw_w
primitives.append({'kind': 'rect', 'x': sx, 'y': cross_oy,
'w': sw, 'h': cross_draw_h,
'role': 'panel-crosssection'})
primitives.append({'kind': 'line', 'x1': cross_ox,
'y1': cross_oy - 14.0,
'x2': cross_ox + cross_draw_w, 'y2': cross_oy - 14.0,
'role': 'dimension'})
primitives.append({'kind': 'text',
'x': cross_ox + cross_draw_w / 2.0,
'y': cross_oy - 26.0,
'text': 'L = {0:.2f} ft'.format(length_ft),
'role': 'dimension', 'anchor': 'middle'})
primitives.append({'kind': 'text',
'x': cross_ox + cross_draw_w + 14.0,
'y': cross_oy + cross_draw_h / 2.0,
'text': 't = {0:.2f} in'.format(thickness_in),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical'})
primitives.append({'kind': 'text',
'x': cross_ox + cross_draw_w / 2.0,
'y': cross_oy + cross_draw_h + 20.0,
'text': 'Cross Section', 'role': 'caption',
'anchor': 'middle'})
n_other = len([r for r in (rows or []) if r['type'] != 'Opening'])
if n_other:
problems.append(
'{0} reveal/solid-zone row(s) are not shown as gaps on the '
'cross section (a reveal is partial-depth and a solid zone '
'removes nothing, so neither is a through-cut here).'
.format(n_other))
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
# fill vocabulary for plan()'s lifter/dunnage legend and, once row-shaped
# position data exists, for each marker's `stage_fill` field -- verbatim
# names, so a caller can map each one to a distinct swatch.
STAGE_FILLS = ('Not Specified', 'Stripping', 'Erection',
'Stripping + Erection', 'Transportation')
def plan(values, width_px, height_px):
"""(primitives, problems) -- the panel PLAN view ("Lifter and Dunnage
Detail"): outline to scale, openings/reveals/solid zones to scale,
centroid marked (area-weighted against `Opening` rows only -- a reveal
is partial-depth and a solid zone removes nothing, so neither reduces
the area), weight and a 3-axis centroid block printed. A static LEGEND
(shape key + the five `STAGE_FILLS` names, each as its own swatch) is
always drawn -- it explains the vocabulary even before any marker uses
it. Lifter/dunnage MARKER positions are still never invented -- there is
no row-shaped position data for them in the contract yet, only free
text (`face_lifters` / `edge_lifters`) -- so `face_lifters` /
`edge_lifters` become a `lifters-note` instead of a scattered glyph.
The marker vocabulary (`role` in `face-lifter` / `edge-lifter` /
`dunnage`, each carrying a `stage_fill` from `STAGE_FILLS`) is defined
and ready for row-shaped data the day it exists."""
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 / height (ft) is missing or not a positive number; '
'the plan view 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 plan '
'view is waiting on it.')
if problems:
return [], problems
weight_lb = _num(values.get('weight_lb'))
thickness_in = _num(values.get('thickness_in'))
primitives = []
margin = _margin(width_px, height_px)
avail_w = width_px - 2.0 * margin
avail_h = height_px - 2.0 * margin
if avail_w < 1.0:
avail_w = 1.0
if avail_h < 1.0:
avail_h = 1.0
legend_w = min(avail_w * 0.22, 160.0)
panel_avail_w = max(avail_w - legend_w - max(margin * 0.5, 10.0), 1.0)
draw_w, draw_h = _letterbox(length_ft, width_ft, panel_avail_w, avail_h)
ox = margin + legend_w + max(margin * 0.5, 10.0) + \
(panel_avail_w - draw_w) / 2.0
oy = margin + (avail_h - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy,
'w': draw_w, 'h': draw_h, 'role': 'panel-plan'})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(margin / 2.0, 10.0),
'text': 'Lifter and Dunnage Detail',
'role': 'caption', 'anchor': 'middle'})
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, None)
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)
# centroid: geometric center minus each `Opening` row's area-weighted
# contribution -- a Reveal is partial-depth and a Solid Zone removes
# nothing, so neither one reduces the plan-view area.
total_area = length_ft * width_ft
mx = total_area * (length_ft / 2.0)
mz = total_area * (width_ft / 2.0)
net_area = total_area
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
if net_area > 0.0:
cg_x_ft = mx / net_area
cg_z_ft = mz / net_area
else:
cg_x_ft = length_ft / 2.0
cg_z_ft = width_ft / 2.0
problems.append(
'Openings account for the whole panel area; centroid shown at '
'the panel geometric center instead.')
cg_px_x = ox + (cg_x_ft / length_ft) * draw_w
cg_px_y = oy + draw_h - (cg_z_ft / width_ft) * draw_h
primitives.append({'kind': 'marker', 'x': cg_px_x, 'y': cg_px_y,
'role': 'centroid', 'glyph': 'cross'})
primitives.append({'kind': 'text', 'x': cg_px_x, 'y': cg_px_y - 12.0,
'text': 'CG', 'role': 'annotation',
'anchor': 'middle'})
# ---- weight / centroid block (right of the panel)
block_x = ox + draw_w + max(margin * 0.5, 10.0)
block_y = oy
if weight_lb is None:
w_text = 'Panel Weight: not entered'
problems.append(
'Weight (lb) is not entered; shown as not entered on the plan.')
else:
w_text = 'Panel Weight: {0:.1f} k'.format(weight_lb / 1000.0)
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y,
'text': w_text, 'role': 'annotation',
'anchor': 'start'})
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 18.0,
'text': 'Centroid,', 'role': 'annotation',
'anchor': 'start'})
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 36.0,
'text': 'X: {0:.2f} ft from start'.format(cg_x_ft),
'role': 'annotation', 'anchor': 'start'})
if thickness_in is not None and thickness_in > 0:
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 54.0,
'text': 'Y: {0:.2f} ft from bottom'
.format(thickness_in / 24.0),
'role': 'annotation', 'anchor': 'start'})
else:
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 54.0,
'text': 'Y: not entered (thickness unknown)',
'role': 'annotation', 'anchor': 'start'})
problems.append(
'Thickness (in) is not entered; the through-thickness centroid '
'(Y) cannot be shown and assumes a solid section when it is.')
primitives.append({'kind': 'text', 'x': block_x, 'y': block_y + 72.0,
'text': 'Z: {0:.2f} ft from left'.format(cg_z_ft),
'role': 'annotation', 'anchor': 'start'})
# ---- legend: Shapes column + Fills column, left of the panel
leg_x = margin
leg_y = margin
primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y,
'text': 'Shapes', 'role': 'legend-heading',
'anchor': 'start'})
primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y + 16.0,
'text': '△ Face Lifter', 'role': 'legend-glyph',
'anchor': 'start'})
primitives.append({'kind': 'text', 'x': leg_x, 'y': leg_y + 32.0,
'text': '‖ Dunnage', 'role': 'legend-glyph',
'anchor': 'start'})
fill_y = leg_y + 56.0
primitives.append({'kind': 'text', 'x': leg_x, 'y': fill_y,
'text': 'Fills', 'role': 'legend-heading',
'anchor': 'start'})
for i, fill_name in enumerate(STAGE_FILLS):
row_y = fill_y + 16.0 + i * 16.0
primitives.append({'kind': 'swatch', 'x': leg_x, 'y': row_y,
'role': 'legend-fill', 'fill': fill_name})
primitives.append({'kind': 'text', 'x': leg_x + 16.0, 'y': row_y,
'text': fill_name, 'role': 'legend-fill-label',
'anchor': 'start'})
# ---- lifters / dunnage: position is never invented (see module note)
face_text = values.get('face_lifters')
edge_text = values.get('edge_lifters')
bits = []
if face_text is not None and str(face_text).strip():
bits.append('face lifters (△): {0}'.format(str(face_text).strip()))
if edge_text is not None and str(edge_text).strip():
bits.append('edge lifters (○): {0}'.format(str(edge_text).strip()))
if bits:
note = ('Lifter positions are not entered on the form, so they are '
'not drawn on the plan (a guessed position would be wrong): '
+ '; '.join(bits) + '.')
problems.append(note)
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + 20.0, 'text': note,
'role': 'lifters-note', 'anchor': 'middle'})
stages = values.get('stages')
if isinstance(stages, list) and stages:
stg_text = 'Stages: ' + ', '.join([str(s) for s in stages])
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': oy + draw_h + (36.0 if bits else 20.0),
'text': stg_text, 'role': 'note',
'anchor': 'middle'})
problems.append(
'Stage names were entered, but no lifter/dunnage row carries a '
'position yet, so nothing can be tied to a stage_fill; the '
'stage names are listed as text only.')
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
def section(values, width_px, height_px):
"""(primitives, problems) -- the reinforcement CROSS-SECTION (panel
outline to scale in width x thickness, strand rows as centred dot
groups at their true depth from the bottom/rail, a dimension chain from
the bottom to each row) plus, when `length_ft` is also given, a
full-length elevation showing each strand row as a line at its true
depth across the whole length. `width_ft` / `thickness_in` missing or
non-positive is the hard stop -- there is no honest cross-section
without both. Horizontal position within a row is NEVER invented:
`strand_rows` carries only `depth_in`, not a horizontal spacing, so
every row is drawn as a group centred on the panel's horizontal
midline, never spread across the true width as if position were known.
Likewise no debond length is in the data contract, so the full-length
view draws each strand full length and says so in `problems` rather
than implying it knows where bonding stops."""
values = values or {}
problems = []
width_ft = _num(values.get('width_ft'))
thickness_in = _num(values.get('thickness_in'))
if width_ft is None or width_ft <= 0:
problems.append(
'Width (ft) is missing or not a positive number; the '
'reinforcement cross-section is waiting on it.')
if thickness_in is None or thickness_in <= 0:
problems.append(
'Thickness (in) is missing or not a positive number; the '
'reinforcement cross-section is waiting on it.')
if problems:
return [], problems
thickness_ft = thickness_in / 12.0
length_ft = _num(values.get('length_ft')) # optional -- full-length view only
primitives = []
margin = _margin(width_px, height_px)
avail_w = width_px - 2.0 * margin
avail_h_total = height_px - 2.0 * margin
if avail_w < 1.0:
avail_w = 1.0
if avail_h_total < 1.0:
avail_h_total = 1.0
gap = max(margin * 0.5, 10.0)
have_length = length_ft is not None and length_ft > 0
if have_length:
cross_alloc_h = avail_h_total * 0.45
elev_alloc_h = avail_h_total - cross_alloc_h - gap
else:
cross_alloc_h = avail_h_total
elev_alloc_h = 0.0
problems.append(
'Length (ft) is not entered; the full-length strand elevation '
'is not drawn, only the cross-section.')
draw_w, draw_h = _letterbox(width_ft, thickness_ft, avail_w, cross_alloc_h)
ox = margin + (avail_w - draw_w) / 2.0
oy = margin + (cross_alloc_h - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy,
'w': draw_w, 'h': draw_h, 'role': 'panel-section'})
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(oy - 10.0, 0.0), 'text': 'CROSS-SECTION',
'role': 'note', 'anchor': 'middle'})
scale = draw_h / thickness_ft # px/ft along thickness
bottom_y = oy + draw_h # panel bottom == the rail
rows, row_problems = _strand_rows_of(values)
problems.extend(row_problems)
if not rows:
problems.append('No strand data entered; the cross-section is '
'drawn with no strands.')
rows = []
for row in rows:
depth_in = row['depth_in']
depth_ft = depth_in / 12.0
flagged = depth_ft < 0.0 or depth_ft > thickness_ft
if flagged:
problems.append(
'Strand row {0} at {1:.2f} in from the bottom is outside '
'the panel thickness (0 to {2:.2f} in); shown clamped and '
'flagged.'.format(row['index'], depth_in, thickness_in))
clamped_depth_ft = _clamp(depth_ft, 0.0, thickness_ft)
y = bottom_y - clamped_depth_ft * scale
dim_x = ox - 20.0 - (row['index'] - 1) * 14.0
primitives.append({'kind': 'line', 'x1': dim_x, 'y1': bottom_y,
'x2': dim_x, 'y2': y, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': dim_x - 4.0,
'y': (bottom_y + y) / 2.0,
'text': '{0:.2f} in'.format(depth_in),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical'})
count = row['count'] if row['count'] and row['count'] > 0 else 1
if row['count'] is None:
problems.append(
'Strand row {0} has no count entered; drawn as a single '
'centred dot at its depth.'.format(row['index']))
dot_count = count if count <= 20 else 20
if count > 20:
problems.append(
'Strand row {0} has {1} strands; only 20 dots drawn '
'(the label carries the true count) to keep the drawing '
'readable.'.format(row['index'], count))
spacing = 6.0
total_w = (dot_count - 1) * spacing
start_x = ox + draw_w / 2.0 - total_w / 2.0
for i in range(dot_count):
primitives.append({'kind': 'dot', 'x': start_x + i * spacing,
'y': y, 'role': 'strand', 'flagged': flagged})
label_bits = []
if row['count'] is not None:
label_bits.append('{0} strand(s)'.format(row['count']))
label_bits.append('@ {0:.2f} in'.format(depth_in))
if row['pull_pct'] is not None:
label_bits.append('{0:.0f}% pull'.format(row['pull_pct']))
label_y = y - 10.0 if y - 10.0 > oy else y + 10.0
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': label_y, 'text': ', '.join(label_bits),
'role': 'strand-label', 'anchor': 'middle'})
if rows:
problems.append(
'Strand horizontal spacing was not entered; each row is drawn '
'as a centred group at its true depth, not at real positions '
'across the width.')
if have_length and rows:
elev_oy = margin + cross_alloc_h + gap
elev_draw_w, elev_draw_h = _letterbox(
length_ft, thickness_ft, avail_w, elev_alloc_h)
elev_ox = margin + (avail_w - elev_draw_w) / 2.0
elev_oy2 = elev_oy + (elev_alloc_h - elev_draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': elev_ox, 'y': elev_oy2,
'w': elev_draw_w, 'h': elev_draw_h,
'role': 'panel-elevation-thin'})
primitives.append({'kind': 'text', 'x': elev_ox + elev_draw_w / 2.0,
'y': max(elev_oy2 - 10.0, 0.0),
'text': 'STRANDS -- FULL LENGTH '
'(debond not entered)',
'role': 'note', 'anchor': 'middle'})
elev_bottom = elev_oy2 + elev_draw_h
elev_scale = elev_draw_h / thickness_ft
for row in rows:
depth_ft = _clamp(row['depth_in'] / 12.0, 0.0, thickness_ft)
ly = elev_bottom - depth_ft * elev_scale
primitives.append({'kind': 'line', 'x1': elev_ox, 'y1': ly,
'x2': elev_ox + elev_draw_w, 'y2': ly,
'role': 'strand-line'})
problems.append(
'Debond regions are not entered; strands are drawn full '
'length, which assumes fully bonded unless the design says '
'otherwise.')
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
def weight_diagram(values, width_px, height_px):
"""(primitives, problems) -- the beam-sketch load diagram: a horizontal
beam to scale over `length_ft`, support markers at their true
proportional position, the CG marked (area-weighted against any valid
`openings_rows`, else the panel mid-length), and dimensioned moment
arms from each panel end to the CG and from each support to the CG.
`length_ft` missing or non-positive is the hard stop. A missing
`weight_lb` does not block the geometry -- the CG's X position comes
from panel/opening geometry, not from the weight's magnitude -- but is
reported and printed as 'not entered' rather than a faked pound value."""
values = values or {}
problems = []
length_ft = _num(values.get('length_ft'))
if length_ft is None or length_ft <= 0:
problems.append(
'Length / height (ft) is missing or not a positive number; '
'the weight diagram is waiting on it.')
return [], problems
width_ft = _num(values.get('width_ft'))
weight_lb = _num(values.get('weight_lb'))
supports = _supports_of(values)
primitives = []
margin = _margin(width_px, height_px)
avail_w = width_px - 2.0 * margin
avail_h = height_px - 2.0 * margin
if avail_w < 1.0:
avail_w = 1.0
if avail_h < 1.0:
avail_h = 1.0
beam_w = avail_w
beam_x0 = margin
beam_y = margin + avail_h * 0.45
primitives.append({'kind': 'line', 'x1': beam_x0, 'y1': beam_y,
'x2': beam_x0 + beam_w, 'y2': beam_y,
'role': 'beam'})
title = _title_primitive(values.get('mark'), values.get('design_number'))
if title:
primitives.append(title)
if not supports:
problems.append(
'No support positions entered; the weight diagram cannot show '
'them yet.')
support_data = [] # (typed pos, clamped pos, pixel x)
for pos in supports:
flagged = pos < 0.0 or pos > length_ft
if flagged:
problems.append(
'Support at {0:.2f} ft is outside the panel length (0 to '
'{1:.2f} ft); shown clamped to the nearest end.'
.format(pos, length_ft))
clamped = _clamp(pos, 0.0, length_ft)
sx = beam_x0 + (clamped / length_ft) * beam_w
support_data.append((pos, clamped, sx))
primitives.append({'kind': 'support', 'x': sx, 'y': beam_y,
'role': 'support', 'flagged': flagged})
primitives.append({'kind': 'text', 'x': sx, 'y': beam_y + 14.0,
'text': '{0:.2f} ft'.format(pos),
'role': 'support-label', 'anchor': 'middle'})
# ---- CG position along the length
rows, row_problems = _openings_rows(values, length_ft, width_ft, None)
problems.extend(row_problems)
cg_x_ft = length_ft / 2.0
opening_rows = [r for r in (rows or []) if r['type'] == 'Opening']
if opening_rows and width_ft is not None and width_ft > 0:
total_area = length_ft * width_ft
mx = total_area * (length_ft / 2.0)
net_area = total_area
for row in opening_rows:
if 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)
net_area -= area
if net_area > 0.0:
cg_x_ft = mx / net_area
else:
problems.append(
'Openings account for the whole panel area; CG shown at '
'panel mid-length instead.')
elif opening_rows and (width_ft is None or width_ft <= 0):
problems.append(
'Openings are entered but width (ft) is not; the CG shown '
'assumes uniform weight along the length (openings not '
'accounted for).')
cg_x = beam_x0 + (cg_x_ft / length_ft) * beam_w
cg_y = beam_y
primitives.append({'kind': 'marker', 'x': cg_x, 'y': cg_y - 20.0,
'role': 'cg', 'glyph': 'diamond'})
primitives.append({'kind': 'line', 'x1': cg_x, 'y1': cg_y - 20.0,
'x2': cg_x, 'y2': cg_y, 'role': 'cg-lead'})
if weight_lb is None:
w_text = 'Weight: not entered'
problems.append(
'Weight (lb) is not entered; shown as not entered on the '
'diagram.')
else:
w_text = 'Weight: {0:.0f} lb'.format(weight_lb)
primitives.append({'kind': 'text', 'x': cg_x, 'y': cg_y - 32.0,
'text': w_text, 'role': 'annotation',
'anchor': 'middle'})
# ---- moment arms: panel ends to CG
dim_y = beam_y + 34.0
primitives.append({'kind': 'line', 'x1': beam_x0, 'y1': dim_y,
'x2': cg_x, 'y2': dim_y, 'role': 'dimension'})
primitives.append({'kind': 'text', 'x': (beam_x0 + cg_x) / 2.0,
'y': dim_y + 12.0,
'text': '{0:.2f} ft'.format(cg_x_ft),
'role': 'dimension', 'anchor': 'middle'})
dim_y2 = dim_y + 26.0
primitives.append({'kind': 'line', 'x1': cg_x, 'y1': dim_y2,
'x2': beam_x0 + beam_w, 'y2': dim_y2,
'role': 'dimension'})
primitives.append({'kind': 'text', 'x': (cg_x + beam_x0 + beam_w) / 2.0,
'y': dim_y2 + 12.0,
'text': '{0:.2f} ft'.format(length_ft - cg_x_ft),
'role': 'dimension', 'anchor': 'middle'})
# ---- moment arms: each support to CG
for idx, (pos, clamped, sx) in enumerate(support_data):
arm_ft = abs(clamped - cg_x_ft)
ady = beam_y - 20.0 - idx * 14.0
lo, hi = (sx, cg_x) if sx <= cg_x else (cg_x, sx)
primitives.append({'kind': 'line', 'x1': lo, 'y1': ady,
'x2': hi, 'y2': ady, 'role': 'moment-arm'})
primitives.append({'kind': 'text', 'x': (lo + hi) / 2.0,
'y': ady - 8.0,
'text': '{0:.2f} ft'.format(arm_ft),
'role': 'moment-arm-label', 'anchor': 'middle'})
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems