Files
will 25592fa6c2
ci / test (push) Successful in 6m14s
WillTools 2026-09-09 -- clean snapshot of the development repo for review
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.
2026-09-09 08:35:15 -05:00

1872 lines
86 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 drawing_display_scale(w, h, max_w=360.0, max_h=520.0):
"""The FIXED shrink factor a drawing's own declared `w` x `h` pixel size
gets before it is displayed, so an oversized drawing (some are declared
up to 900x260) fits an ordinary form column without the caller having to
know the column width.
WHY THIS IS PLAIN ARITHMETIC HERE AND NOT A WPF `Viewbox` ANY MORE: the
button used to wrap each drawing's `Canvas` in a `Viewbox`, which computes
its OWN shrink factor from whatever space WPF hands it at MEASURE time.
The page's Ctrl+wheel zoom (`show_form`'s `ScaleTransform` as a
`LayoutTransform` on the whole page) works by DEFLATING the available
size it passes down to its children by `1 / scale` before measuring them,
then INFLATING the resulting size back up by `scale` before reporting it
to ITS OWN parent -- so everything under it renders `scale` times bigger,
which is exactly how the surrounding text and boxes visibly zoom. But a
`Viewbox` fits itself to WHATEVER space it is offered -- so at zoom=2 it
is offered HALF the column width, shrinks the drawing to fit that HALVED
width, and the ancestor transform then blows the (now half-sized) result
back up by 2 -- net effect: exactly the pre-zoom pixel size, every time.
The deflate and the inflate cancel EXACTLY for anything whose own
rendered size is a function of "how much space am I given", which a
`Viewbox` is by definition. Ordinary text does not have this problem
because a `TextBlock`'s glyph size does not depend on the space it is
offered (it wraps instead), so it has nothing to cancel.
The fix is to make the shrink a FIXED transform instead of an adaptive
one: compute the factor here, off the drawing's own w/h -- a plain number
that does not change no matter what space WPF later offers -- and have
the caller apply it as an ordinary `ScaleTransform`. A fixed transform on
a descendant composes MULTIPLICATIVELY with the ancestor's zoom transform
(like any two nested transforms), instead of being an adaptive function
that the ancestor's own deflate/inflate step cancels out.
Never grows a drawing already smaller than the target -- only ever
shrinks (`min(1.0, ...)`), same intent as the `Viewbox`'s old
`StretchDirection.DownOnly`: a small, precise drawing should not be
blown up into something blurry just because there is spare column width.
Uniform (same factor on both axes), matching `Stretch.Uniform`'s old
aspect-preserving behaviour. `w` / `h` <= 0 (or missing) is not this
function's business to judge -- it returns 1.0 (no shrink) rather than
raising or dividing by zero; the caller already has a canvas of that
declared size regardless of whether the size makes sense.
"""
try:
w = float(w)
h = float(h)
except (TypeError, ValueError):
return 1.0
if w <= 0.0 or h <= 0.0:
return 1.0
return min(1.0, float(max_w) / w, float(max_h) / h)
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'})
# -16, not -10: a caption drawn -10px above the outline's top edge, at
# this text's own ~13px line height, still puts its BOTTOM 3px inside
# the outline -- the exact "text overlaps the drawing" the owner named.
# -16 clears it with a couple of pixels to spare.
primitives.append({'kind': 'text', 'x': ox + draw_w / 2.0,
'y': max(oy - 16.0, 0.0), 'text': 'CROSS-SECTION',
'role': 'note', 'anchor': 'middle'})
# A Sandwich draws its layers to scale: the bottom wythe at the rail,
# the insulation band, the top wythe -- each labelled with its
# thickness. Only when the three add up to the panel thickness (within
# 1/8 in); a layering that does not fit the thickness is not one to
# draw, so the section then stays one outline and a problem says why.
section_type = str(values.get('section_type') or '').strip().lower()
sandwich_drawn = False
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 any(v is None or v <= 0 for v in layers):
problems.append(
'Sandwich section: the wythe / insulation / wythe '
'thicknesses are not all entered; the cross-section is '
'drawn as one outline.')
elif abs(sum(layers) - thickness_in) > 0.125:
problems.append(
'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not add '
'up to the {3:.2f} in thickness; drawn as one outline.'
.format(top_in, ins_in, bot_in, thickness_in))
else:
sandwich_drawn = True
px_per_in = draw_h / thickness_in
y_cursor = oy + draw_h
for name, layer_in, role in (('wythe', bot_in, 'wythe'),
('insulation', ins_in, 'insulation'),
('wythe', top_in, 'wythe')):
layer_h = layer_in * px_per_in
y_cursor -= layer_h
primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor,
'w': draw_w, 'h': layer_h, 'role': role})
primitives.append({'kind': 'text', 'x': ox + draw_w + 6.0,
'y': y_cursor + layer_h / 2.0 - 6.0,
'text': '{0} {1:.2f} in'.format(name, layer_in),
'role': 'layer-label'})
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']))
# OUTSIDE the outline, to the right, at the row's own depth height --
# centred-on-the-dot used to put this text ON TOP OF the outline (and
# the dots) whenever a row sat near the top of the section, exactly
# the overlap the owner reported. A further offset when the sandwich
# layers are also drawn keeps this out of THEIR label column (which
# sits at the same `ox + draw_w + 6` x) instead of stacking on it.
label_x = ox + draw_w + (100.0 if sandwich_drawn else 10.0)
primitives.append({'kind': 'text', 'x': label_x,
'y': y - 5.0, 'text': ', '.join(label_bits),
'role': 'strand-label', 'anchor': 'start'})
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 - 16.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
# =========================================================== extents_* ===
# The Concrete Extents section (`vibe.pa_extents`) does not want `elevation`'s
# supports/side-view/section-properties, and it does not want `section`'s
# strand dots and labels -- Concrete Extents collects no reinforcement data
# of its own, but `values` is the WHOLE form's dict, and `section()` draws
# whatever `strand_rows` it finds regardless of which page asked for the
# picture. Calling `section()` from the Concrete Extents page meant that a
# panel with reinforcement entered under a DIFFERENT tab showed strand dots
# and overlapping labels on a page that never asked to see them -- the
# "looks like garbage" the owner reported, 2026-09-03. These two functions
# are the plain, honest picture that page actually asked for: the member's
# own concrete edge, its openings, and nothing it did not enter here.
# ============================================================================
def extents_outline(values, width_px, height_px):
"""(primitives, problems) -- the Concrete Extents "outline" drawing: the
panel's own length x width edge, TWO overall dimensions (length along the
bottom, width along the left), both OUTSIDE the outline, and any
openings/reveals/solid-zones drawn to scale with a dimension chain
locating each one along the length (bottom) and across the width (left)
-- reusing `_opening_rect_primitives` / `_gap_dimension_chain` exactly as
`elevation()`'s front view does. No supports, no side view, no section
properties: "just draw the member's concrete edge" (owner, 2026-09-03),
not the full handling picture `elevation()` draws for the Structural
Model page. `length_ft` / `width_ft` missing or non-positive is the hard
stop, same rule as every other drawing in this module."""
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 outline '
'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 outline '
'is waiting on it.')
if problems:
return [], problems
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
# Room reserved OUTSIDE the outline for the two overall dimensions and,
# when there are openings, their locating chains one step further out --
# the owner's "dimension chains OUTSIDE the outline with an offset".
dim_room = 48.0
draw_w, draw_h = _letterbox(length_ft, width_ft,
max(avail_w - dim_room, 1.0),
max(avail_h - dim_room, 1.0))
ox = margin + dim_room + (avail_w - dim_room - draw_w) / 2.0
oy = margin + (avail_h - dim_room - draw_h) / 2.0
primitives.append({'kind': 'rect', 'x': ox, 'y': oy,
'w': draw_w, 'h': draw_h, 'role': 'panel'})
title = _title_primitive(values.get('mark'), values.get('design_number'))
if title:
primitives.append(title)
# ---- two overall dimensions, OUTSIDE the outline
dim_y = oy + draw_h + 20.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 - 20.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'})
# ---- openings/reveals/solid-zones, to scale, plus locating chains
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)
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'))
else:
openings_text = values.get('openings')
if openings_text is not None and str(openings_text).strip():
note = ('Openings/reveals were entered as plain text, not the '
'structured rows this drawing needs to locate them: '
'{0!r}'.format(str(openings_text).strip()))
problems.append(note)
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems
def extents_section(values, width_px, height_px):
"""(primitives, problems) -- the Concrete Extents "section" drawing: the
panel's width x thickness outline, a thickness dimension OUTSIDE the
outline (to the left), and, for a Sandwich section, the wythe /
insulation layers drawn to scale with their thickness labelled OUTSIDE
the outline (to the right) -- the same layer geometry `section()` draws
for the Reinforcement page, WITHOUT `section()`'s strand dots and labels,
which Concrete Extents has no reinforcement data of its own to draw and
does not want (see the module note above this function).
`width_ft` / `thickness_in` missing or non-positive is the hard stop --
same rule as every other drawing in this module."""
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 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 '
'section is waiting on it.')
if problems:
return [], problems
thickness_ft = thickness_in / 12.0
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
# Room reserved OUTSIDE the outline: the thickness dimension to the
# left, the (possible) sandwich layer labels to the right -- kept in
# separate columns so the two never land on each other.
left_room = 40.0
right_room = 90.0
draw_w, draw_h = _letterbox(
width_ft, thickness_ft,
max(avail_w - left_room - right_room, 1.0), avail_h)
ox = margin + left_room + \
(avail_w - left_room - right_room - 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-section'})
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 any(v is None or v <= 0 for v in layers):
problems.append(
'Sandwich section: the wythe / insulation / wythe '
'thicknesses are not all entered; the section is drawn as '
'one outline.')
elif abs(sum(layers) - thickness_in) > 0.125:
problems.append(
'Sandwich section: {0:.2f} + {1:.2f} + {2:.2f} in do not '
'add up to the {3:.2f} in thickness; drawn as one outline.'
.format(top_in, ins_in, bot_in, thickness_in))
else:
px_per_in = draw_h / thickness_in
y_cursor = oy + draw_h
for name, layer_in, role in (('wythe', bot_in, 'wythe'),
('insulation', ins_in, 'insulation'),
('wythe', top_in, 'wythe')):
layer_h = layer_in * px_per_in
y_cursor -= layer_h
primitives.append({'kind': 'rect', 'x': ox, 'y': y_cursor,
'w': draw_w, 'h': layer_h, 'role': role})
primitives.append({'kind': 'text', 'x': ox + draw_w + 6.0,
'y': y_cursor + layer_h / 2.0 - 6.0,
'text': '{0} {1:.2f} in'
.format(name, layer_in),
'role': 'layer-label'})
# ---- thickness dimension, OUTSIDE the outline, to the LEFT (the wythe
# labels, when drawn, own the right side -- see `right_room` above)
dim_x = ox - 20.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': 't = {0:.2f} in'.format(thickness_in),
'role': 'dimension', 'anchor': 'middle',
'orientation': 'vertical'})
primitives = [_clamp_primitive(p, float(width_px), float(height_px))
for p in primitives]
return primitives, problems