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