# -*- coding: utf-8 -*-
"""The Panel Analysis free-body sketch -- pure geometry, both Pythons, no
Revit, no WPF.
WHY THIS EXISTS: the owner's ruling, 2026-09-04, on the Structural Model
tab's headless render: "restrain & support 的那个示意图,本质上是一个
freebody,用一条直线来代替 member,同时显示 restrain 的位置,以及 loading" --
the diagram this section needs is a FREE-BODY, not the four-panel elevation
`panelsketch.elevation()` used to draw here (front + side + section
properties + cross section, three tiny sub-drawings and a block of text
fighting for the same canvas -- the owner's other complaint the same day).
One horizontal line stands in for the member; the panel's elevation range
fills the page width; restraints sit at their true positions along it;
loads are drawn ON it, not beside it.
THE SEAM, same discipline as `panelsketch.py`: this module's job stops at a
flat list of drawing PRIMITIVES in a normalised, already-in-pixels
coordinate space. The WPF `Canvas` painting is the button's job, not this
module's -- see `pa_structural.py`'s `drawing` block, which only NAMES this
module and its `freebody` entry point; the renderer looks the function up
by name and calls it. Standard library only (`re` is not even needed here),
runnable under IronPython 2.7 inside Revit and plain CPython on the host
(see `tests/conftest.py`): no f-strings, no `pathlib`, no `dataclasses`, no
typing, no `enum`.
THE INPUT is `values`, the SAME dict `pa_structural.page()` reads from the
form. Two plain fields anchor the horizontal axis:
bottom_elevation_ft / top_elevation_ft
the elevation range the free-body's full width represents -- x maps
elevation bottom -> top, left -> right. NOT a hard stop the way
`panelsketch.sketch()`'s length/width are: a free-body with no
elevation range typed in yet still has to draw a member line (this
is the diagram restraints and loads get ADDED to as the form fills
in, not a diagram that appears once the form is finished), so a
missing or unreadable range falls back to whatever span the
restraint/load/wind rows already cover, and finally to a bare
0.0-1.0 ft placeholder -- reported in `problems` either way, never
silently accepted as "0 to 1 is the real answer".
The three row-tables read here are the CONTRACT `pa_structural.py`'s module
docstring documents: one row lives at `'
..'`, and
`'..present' == 'yes'` marks the row as existing (`row_ids` /
`row_key` below implement the same key arithmetic `pa_structural.py` does,
independently -- this module does not import `pa_structural` or
`panelanalysisform`, the same leaf-module discipline `panelsketch.py`'s own
docstring explains: a module three layers from Revit should not be breakable
by a change to a sibling's parsing rules).
restraint -> elevation_ft (num), type (one of 'Pin' / 'Fixed' /
'Free' / 'Floor tie', case-insensitive, blank treated as
unrecognised), gravity / thermal (unused here -- nothing
in the free-body content the owner specified draws them),
stiffness_kin (unused here).
conc_load -> label (text, defaults to 'P' by draw order when
blank), elevation_ft (num, required to place the row --
a row missing it is skipped and reported), offset_in
(num; sign picks which face the arrow is drawn on --
>= 0 or blank draws on the outside face, above the
member line; < 0 draws on the inside face, below),
dead_k / live_k (num, shown as '--' when blank -- never
defaulted to 0, which is a real, asserted value).
wind_zone -> label (as above, defaults to 'W'), from_elev_ft /
to_elev_ft (num, both required to place the row; drawn
swapped-and-flagged if `from` sits above `to`),
width_ft (num, shown as typed, '--' when blank),
pressure_psf / suction_psf (num, '--' when blank).
MISSING/UNREADABLE INPUT IS REPORTED, NEVER GUESSED. A row missing what it
needs to be PLACED (an elevation, a from/to pair) is skipped and named in
`problems`; a row missing what it needs to be LABELLED (dead/live/pressure/
suction/width) is still drawn, with '--' standing in for the missing
number -- inventing a load magnitude nobody typed would be lying with a
picture instead of a number, which `panelsketch.py`'s own docstring already
rules out for openings, and the same rule applies here.
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]`. The vocabulary is the
same one `panelsketch.py`'s `_clamp_primitive` and the button's
`_draw_primitive` already speak, restricted here to the five kinds this
drawing actually needs:
{'kind': 'line', 'x1','y1','x2','y2', 'role': ...}
the member line itself (role 'member', carrying a 'width': 3.0 hint
-- see MEMBER LINE below); its two end ticks (role
'member-end-tick'); restraint hatch ticks; load and wind-zone arrow
shafts; the dimension chain between restraints; the wind-zone
extent bracket.
{'kind': 'rect', 'x','y','w','h', 'role': 'wind-zone-band', 'flagged': bool}
a thin band marking a wind zone's elevation extent, behind its row
of small arrows -- only drawn when the zone has a pressure or a
suction typed in (see WIND below).
{'kind': 'text', 'x','y', 'text', 'role': ..., 'anchor': 'middle'|'start'}
every label -- elevation end labels, restraint elevation/type,
load/wind-zone labels, dimension-chain spacings, and the one
"waiting on input" note this module ever draws on the canvas
itself (see NEVER RAISES below). `anchor` is 'middle' for labels
centred on a point (restraints, wind zones, dimension spacings,
elevation ends) and 'start' for a load label, which prints to the
RIGHT of its arrow rather than centred on it.
{'kind': 'circle', 'cx','cy','r', 'role': ..., 'flagged': bool}
a restraint whose type is 'Free' or unrecognised -- there is no
restraint SYMBOL to draw (a Free restraint restrains nothing), but
its position is still real data, marked with a small dot rather
than invented as a triangle or hatch it is not.
{'kind': 'polygon', 'points': [(x,y), ...], 'role': ..., 'flagged': bool}
the Pin / Floor-tie restraint triangle, and every arrowhead (loads,
wind-zone arrows).
MEMBER LINE: full canvas width less the margin (see `_margin` -- floored at
70 px specifically so the elevation end labels have room and never clip),
drawn 3 px dark with a short tick at each end. The end labels sit just
above those ticks, at the member's own end x-coordinates rather than
centred on the canvas edge -- centring "Bottom: -2.00 ft" ON the margin
boundary used to push half the text off the canvas (reported as "Bottom:"
rendering as "ottom:"); anchoring it at the member end and trusting the
70 px margin to hold the whole label is what fixes it.
`flagged` marks a primitive drawn from data that needed clamping or
correcting to be drawn at all (a restraint outside the elevation range, a
wind zone with `from` above `to`) -- present and visibly wrong, never
silently dropped, the same convention `panelsketch.py` uses for an
out-of-range support.
RESTRAINT SYMBOLS, coordinator follow-up on the live render, 2026-09-04:
16 px tall (`_RESTRAINT_TRI_DROP` / `_RESTRAINT_FREE_R`), apex/top touching
the member line, dropping below it. Every one of the three symbol kinds
(Pin/Floor-tie `polygon`, Fixed hatch `line`s, Free/unrecognised `circle`)
now carries `'stroke': '#333'`, `'fill': '#888'` and `'width': 2.5` --
CONFIRMED against the button's `_draw_primitive`, 2026-09-04: it reads
`'width'` on a `line` primitive already (the Fixed hatch's thickened tick
marks are real today), but does not handle `kind == 'circle'` or
`kind == 'polygon'` AT ALL -- not "ignores fill/stroke", the whole branch
is absent, so the Pin/Floor-tie triangle and the Free/unrecognised dot do
not draw regardless of what hints they carry. `'stroke'`/`'fill'`/`'width'`
on those two kinds are written now so nothing has to change here the day
the button's renderer is taught them; until then only the Fixed hatch's
extra thickness is visible. This is a renderer-side gap (the WPF button's
`script.py`, out of this module's ownership), reported rather than worked
around by changing what kind of primitive a restraint symbol is.
`_RESTRAINT_STROKE` / `_RESTRAINT_FILL` / `_RESTRAINT_WIDTH` are shared by
all three symbol kinds so "equally dark" (the coordinator's own phrase for
the Fixed block and Free circle) is one constant, not three copies that
can drift.
WIND, drawn in its own row above the loads (see LAYOUT ROWS below): a zone
with neither `pressure_psf` nor `suction_psf` entered draws no band and no
arrows -- there is nothing to point at yet -- just one small label reading
": no pressure entered yet". Never the em-dash form ("W1: +— / -—
psf...") that used to print when both were blank; a single em-dash for
just one of the two (one entered, one not) is still the ordinary
MISSING/UNREADABLE handling above and prints fine.
LAYOUT ROWS, top to bottom, packed in fixed pixel amounts rather than a
fraction of `height_px` (see the `_TOP_PAD`.. constants): wind band, loads,
the member with its restraints on it, restraint labels, the dimension
chain. Packing tight from y=0 is the fix for the owner's headless-render
finding, 2026-09-04, that the old fraction-of-height midpoint left roughly
40% of the canvas blank above the member -- nothing was actually drawn in
that reserved space. The restraint-label row and the dimension-chain row
below the member are packed the same way: `_RESTRAINT_LABEL_GAP` (4 px)
puts the label directly under the 16 px symbol, `_DIM_ROW_GAP` (22 px)
puts the dimension chain that far below the label row -- a same-day
follow-up on the SAME render, which found roughly 45% of the canvas empty
below those two rows once the top rows were fixed. The canvas HEIGHT
itself is not this module's decision -- `pa_structural._freebody_aspect`
shrinks the `aspect` it passes in when a diagram has few or no
loads/wind zones, rather than this module stretching a fixed row spacing
to fill whatever height it is handed.
LABEL STACKING: two labels within 40 px of each other along x, in the same
channel (restraints below the line, loads/wind above it), would otherwise
print on top of each other -- `_destack` alternates every other close-by
label further from the line so both stay legible. See its docstring.
NEVER RAISES. `freebody()` is wrapped end to end: anything that gets past
the row-parsing degrades (a bad row is skipped and reported, never a
crash), and the one path left that could still raise -- a genuinely
unexpected exception -- is caught at the top level and turned into a single
`text` primitive on the canvas plus a `problems` sentence naming what broke,
so the page never goes blank and never takes the rest of the form down with
it. `values` is never mutated -- every access below is `.get(...)`.
"""
_MARGIN_FRACTION = 0.14
_MARGIN_MIN_PX = 70.0 # end labels must fit inside this, never clipped
_MARGIN_MAX_PX = 110.0
_ARROW_LEN = 32.0 # px, concentrated-load arrow shaft (>= 30 px)
_ARROW_HEAD = 5.0 # px, arrowhead half-width/length
_WIND_ARROW_LEN = 16.0 # px, wind-zone arrow shaft (shorter, banded)
_WIND_ARROWS_MIN = 3
_WIND_ARROWS_MAX = 7
_RESTRAINT_TRI_HALF = 7.0
_RESTRAINT_TRI_DROP = 16.0 # restraint symbol height -- 16 px tall
_RESTRAINT_FREE_R = 8.0 # free/unrecognised dot radius -- 16 px across
_RESTRAINT_STROKE = '#333' # dark outline hint -- see RESTRAINT SYMBOL
_RESTRAINT_FILL = '#888' # mid-grey fill hint, same primitives
_RESTRAINT_WIDTH = 2.5 # px -- outline-thickness hint; also what
# actually renders today (see below)
_STACK_THRESHOLD = 40.0
_STACK_STEP = 13.0
# Vertical layout, stacked from the top -- see `_freebody`'s LAYOUT ROWS
# note: wind band, then loads, then the member (with restraints on it),
# then restraint labels, then the dimension chain. Fixed pixel amounts, not
# a fraction of `height_px`: the owner's headless render, 2026-09-04, found
# the old fraction-of-height midpoint left ~40% of the canvas blank above
# the member because nothing was actually drawn up there -- packing the
# rows tight from y=0 uses that space instead of reserving it and leaving
# it empty.
_TOP_PAD = 6.0 # px above the wind row's own label
_WIND_LABEL_H = 8.0 # px reserved for the wind row's text baseline
_WIND_ROW_GAP = 6.0 # px between the wind label and its arrow row
_LOAD_ROW_GAP = 6.0 # px between the wind arrows and the load arrows
_LOAD_LABEL_GAP = 6.0 # px between a load arrow's shaft and its label
_END_TICK_LEN = 6.0 # px, the member's end ticks above/below the line
_MEMBER_WIDTH = 3.0 # px -- "3-px dark line"; a hint for the WPF
# renderer (not this module's job), same
# convention as `flagged`
_RESTRAINT_LABEL_GAP = 4.0 # px, symbol bottom -> restraint-label row
_DIM_ROW_GAP = 22.0 # px, restraint-label row -> dimension row
# ------------------------------------------------------------------ parsing
def _num(value):
"""A float, or None. Accepts a number already, or text; blank -> None."""
if value is None:
return None
if isinstance(value, bool):
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 _text_of(value):
if value is None:
return ''
try:
value.strip
except AttributeError:
try:
value = str(value)
except Exception:
return ''
return value.strip()
def _fmt_num(value, fmt='{0:.2f}'):
"""A formatted number, or the em-dash stand-in for "not entered" --
never a fabricated 0."""
if value is None:
return u'—'
return fmt.format(value)
def _clamp(v, lo, hi):
if v < lo:
return lo
if v > hi:
return hi
return v
# --------------------------------------------------------------------------
# row_ids / row_key -- the same contract `pa_structural.py` implements,
# independently (see the module docstring for why this module does not
# import that one). Kept identical on purpose.
# --------------------------------------------------------------------------
def row_key(table, n, col):
try:
from vibe import panelanalysisform as paf
return paf.row_key(table, n, col)
except (ImportError, AttributeError):
return '{0}.{1}.{2}'.format(table, n, col)
def row_ids(table, values):
"""Every PRESENT row id, ascending, as INT ids -- mirrors
`vibe.panelanalysisform.row_ids` exactly (see `pa_structural.row_ids`'s
docstring for why the fallback below matches it id-type and all)."""
try:
from vibe import panelanalysisform as paf
return list(paf.row_ids(table, values))
except (ImportError, AttributeError):
pass
values = values or {}
prefix = '{0}.'.format(table)
candidates = set()
for key in values:
if not isinstance(key, str) and not isinstance(key, type(u'')):
continue
if not key.startswith(prefix):
continue
n_txt = key[len(prefix):].split('.', 1)[0]
try:
n = int(n_txt)
except (TypeError, ValueError):
continue
if n > 0:
candidates.add(n)
out = [n for n in candidates
if str(values.get(row_key(table, n, 'present')) or '')
.strip().lower() == 'yes']
out.sort()
return out
def _row(table, n, values, cols):
"""One row's raw cell values, `{col: text}` -- `cols` names which
columns to read; every value comes back a stripped string (possibly
empty), never parsed here."""
out = {}
for col in cols:
out[col] = _text_of(values.get(row_key(table, n, col)))
return out
def _support_rows(values, problems):
"""[{'id', 'x_ft', 'elevation_ft'}, ...] -- the `support` row table
(owner ruling, 2026-09-05: bearing positions along the length, one
editable row each), converted to an ELEVATION the same way restraints
already are on this drawing (`bottom_elevation_ft + x_ft`) so a support
triangle sits on the same axis as everything else here. Falls back to
the legacy `supports_ft` comma-separated text field when the table has
no present rows at all -- a panel saved before this table existed.
`bottom_elevation_ft` missing/unreadable reports once and skips every
support (there is no axis to place them on), never a guessed 0.
"""
rows = row_ids('support', values)
xs = []
if rows:
for n in rows:
raw = _row('support', n, values, ('x_ft',))
x = _num(raw['x_ft'])
if x is None:
problems.append(
'Support row {0} has no readable position; not drawn '
'on the free body.'.format(n))
continue
xs.append(x)
else:
text = _text_of(values.get('supports_ft'))
if text:
for i, tok in enumerate(text.replace(',', ' ').split(), 1):
x = _num(tok)
if x is None:
problems.append(
'Support position {0!r} is not a number; not drawn '
'on the free body.'.format(tok))
continue
xs.append(x)
if not xs:
return []
bottom = _num(values.get('bottom_elevation_ft'))
if bottom is None:
problems.append(
"'bottom_elevation_ft' is missing or not a number; support "
'positions cannot be converted to an elevation and are not '
'drawn on the free body.')
return []
out = []
for i, x in enumerate(xs, 1):
out.append({'id': i, 'x_ft': x, 'elevation_ft': bottom + x})
return out
def _restraint_rows(values, problems):
rows = []
for n in row_ids('restraint', values):
raw = _row('restraint', n, values,
('elevation_ft', 'type'))
elev = _num(raw['elevation_ft'])
if elev is None:
problems.append(
'Restraint row {0} has no readable elevation; not drawn '
'on the free body.'.format(n))
continue
type_ = raw['type'].strip()
rows.append({'id': n, 'elevation_ft': elev, 'type': type_})
return rows
def _conc_load_rows(values, problems):
rows = []
for n in row_ids('conc_load', values):
raw = _row('conc_load', n, values,
('label', 'elevation_ft', 'offset_in', 'dead_k', 'live_k'))
elev = _num(raw['elevation_ft'])
if elev is None:
problems.append(
'Concentrated load row {0} has no readable elevation; not '
'drawn on the free body.'.format(n))
continue
offset = _num(raw['offset_in'])
rows.append({'id': n, 'label': raw['label'].strip(),
'elevation_ft': elev, 'offset_in': offset,
'dead_k': _num(raw['dead_k']),
'live_k': _num(raw['live_k'])})
return rows
def _wind_zone_rows(values, problems):
rows = []
for n in row_ids('wind_zone', values):
raw = _row('wind_zone', n, values,
('label', 'from_elev_ft', 'to_elev_ft', 'width_ft',
'pressure_psf', 'suction_psf'))
frm = _num(raw['from_elev_ft'])
to = _num(raw['to_elev_ft'])
if frm is None or to is None:
problems.append(
'Wind zone row {0} has no readable from/to elevation; not '
'drawn on the free body.'.format(n))
continue
flagged = frm > to
if flagged:
frm, to = to, frm
problems.append(
'Wind zone row {0} has its from-elevation above its '
'to-elevation; shown swapped.'.format(n))
rows.append({'id': n, 'label': raw['label'].strip(),
'from_elev_ft': frm, 'to_elev_ft': to,
'width_ft': _num(raw['width_ft']),
'pressure_psf': _num(raw['pressure_psf']),
'suction_psf': _num(raw['suction_psf']),
'flagged': flagged})
return rows
# ------------------------------------------------------------------ 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 _elevation_span(values, restraint_rows, load_rows, wind_rows, problems):
"""(bottom, top) ft. Prefers `bottom_elevation_ft`/`top_elevation_ft`;
falls back to the span the rows already cover, and finally to a bare
placeholder -- see the module docstring's MISSING/UNREADABLE section."""
bottom = _num(values.get('bottom_elevation_ft'))
top = _num(values.get('top_elevation_ft'))
if bottom is None:
problems.append(
"'bottom_elevation_ft' is missing or not a number; the free "
'body is estimating its range from whatever rows are entered.')
if top is None:
problems.append(
"'top_elevation_ft' is missing or not a number; the free body "
'is estimating its range from whatever rows are entered.')
if bottom is not None and top is not None and top > bottom:
return bottom, top
if bottom is not None and top is not None and top <= bottom:
problems.append(
'Top elevation is not above bottom elevation; the free body '
'is estimating its range from whatever rows are entered '
'instead.')
elevs = []
for r in restraint_rows:
elevs.append(r['elevation_ft'])
for r in load_rows:
elevs.append(r['elevation_ft'])
for r in wind_rows:
elevs.append(r['from_elev_ft'])
elevs.append(r['to_elev_ft'])
if elevs:
lo, hi = min(elevs), max(elevs)
if hi <= lo:
hi = lo + 1.0
return lo, hi
problems.append(
'No elevation range and no rows entered yet; the free body shows '
'a placeholder 0.00-1.00 ft span.')
return 0.0, 1.0
def _destack(items, direction, threshold=_STACK_THRESHOLD,
step=_STACK_STEP):
"""Mutates each item's `'y'` in place so two labels within `threshold`
px of each other along x do not print on top of each other.
`items` is a list of dicts each already carrying `'x'` and a base
`'y'`. Walked in x order (a copy is sorted; the caller's own list order
is untouched); whenever one label sits closer than `threshold` to the
PREVIOUS one in that x order, it is pushed an extra `step` px further
from the member line (`direction` is +1 to push down, -1 to push up),
alternating so a run of three tight labels reads low/high/low rather
than climbing forever. Isolated labels (nothing within `threshold`) are
left at their base `y`.
"""
order = sorted(range(len(items)), key=lambda i: items[i]['x'])
toggle = False
prev_x = None
for idx in order:
item = items[idx]
if prev_x is not None and abs(item['x'] - prev_x) < threshold:
toggle = not toggle
if toggle:
item['y'] = item['y'] + direction * step
else:
toggle = False
prev_x = item['x']
return items
# --------------------------------------------------------------------- API
def freebody(values, width_px, height_px):
"""(primitives, problems) -- the free body: one line for the member,
restraints at their true positions, loads drawn on it. See the module
docstring for the full content and vocabulary. Never raises; always
returns at least the member line (see NEVER RAISES above)."""
try:
return _freebody(values, width_px, height_px)
except Exception as exc: # the backstop of last resort
w = float(width_px) if width_px else 300.0
h = float(height_px) if height_px else 100.0
prim = {'kind': 'text', 'x': _clamp(w / 2.0, 0.0, w),
'y': _clamp(h / 2.0, 0.0, h),
'text': 'Free-body drawing could not be produced: {0}'
.format(exc),
'role': 'note', 'anchor': 'middle'}
return [prim], ['free-body drawing failed: {0}'.format(exc)]
def _freebody(values, width_px, height_px):
values = values or {}
problems = []
try:
width_px = float(width_px)
except (TypeError, ValueError):
width_px = 300.0
try:
height_px = float(height_px)
except (TypeError, ValueError):
height_px = 120.0
if width_px <= 0.0:
width_px = 1.0
if height_px <= 0.0:
height_px = 1.0
restraint_rows = _restraint_rows(values, problems)
load_rows = _conc_load_rows(values, problems)
wind_rows = _wind_zone_rows(values, problems)
bottom, top = _elevation_span(values, restraint_rows, load_rows,
wind_rows, problems)
span = top - bottom
if span <= 0.0:
span = 1.0
top = bottom + 1.0
margin = _margin(width_px, height_px)
x_left = margin
x_right = width_px - margin
if x_right - x_left < 1.0:
x_left = width_px * 0.1
x_right = width_px * 0.9
if x_right <= x_left:
x_right = x_left + 1.0
def elev_x(elev_ft):
frac = (elev_ft - bottom) / span
frac = _clamp(frac, 0.0, 1.0)
return x_left + frac * (x_right - x_left)
# LAYOUT ROWS, stacked from the top (see the module docstring and the
# `_TOP_PAD`.. constants above): [wind band] [loads] [member+restraints]
# [labels] [dimension chain]. Each row's own top-left constant chains
# into the next, so `line_y` falls out of what actually has to be drawn
# above it rather than a fixed fraction of `height_px`.
wind_label_y = _TOP_PAD + _WIND_LABEL_H
wind_arrow_top_y = wind_label_y + _WIND_ROW_GAP
wind_arrow_bottom_y = wind_arrow_top_y + _WIND_ARROW_LEN
load_arrow_top_y = wind_arrow_bottom_y + _LOAD_ROW_GAP
line_y = load_arrow_top_y + _ARROW_LEN
load_base_y = load_arrow_top_y # load arrow-tail row
wind_base_y = wind_arrow_top_y # wind arrow-shaft top
# Restraint labels sit directly under the symbols (a small fixed gap,
# not a large one), and the dimension chain follows ~22 px below that
# -- coordinator, live headless render: with the old wider gaps
# (dim_y = restraint_label_y + 20 off of a smaller symbol) roughly the
# bottom 45% of the canvas sat empty below these two rows. See also
# `pa_structural._freebody_aspect`, which now shrinks the canvas
# itself for a diagram with few/no loads or wind zones instead of
# relying on this module to stretch content to fill a fixed height.
restraint_label_y = line_y + _RESTRAINT_TRI_DROP + _RESTRAINT_LABEL_GAP
dim_y = restraint_label_y + _DIM_ROW_GAP
primitives = []
# ---- the member line itself, full width less margin -- a 3-px dark
# line ('width' is a hint the renderer may not read yet, same
# convention as 'flagged') with a small tick at each end marking where
# the modelled member actually starts and stops.
primitives.append({'kind': 'line', 'x1': x_left, 'y1': line_y,
'x2': x_right, 'y2': line_y, 'role': 'member',
'width': _MEMBER_WIDTH})
for tick_x in (x_left, x_right):
primitives.append({'kind': 'line', 'x1': tick_x,
'y1': line_y - _END_TICK_LEN, 'x2': tick_x,
'y2': line_y + _END_TICK_LEN,
'role': 'member-end-tick'})
# End labels sit just above the ticks, positioned at the member's own
# ends and kept inside the margin reserved for them (`_margin`'s >= 70
# px floor) -- not centred exactly on the canvas edge, which is what
# used to clip "Bottom: -2.00 ft" down to "ottom:".
primitives.append({'kind': 'text', 'x': x_left,
'y': line_y - _END_TICK_LEN - 12.0,
'text': 'Bottom: {0:.2f} ft'.format(bottom),
'role': 'elevation-end', 'anchor': 'middle'})
primitives.append({'kind': 'text', 'x': x_right,
'y': line_y - _END_TICK_LEN - 12.0,
'text': 'Top: {0:.2f} ft'.format(top),
'role': 'elevation-end', 'anchor': 'middle'})
# ---- restraints: symbol at the line, label below
restraint_labels = []
for r in restraint_rows:
elev = r['elevation_ft']
flagged = elev < bottom or elev > top
if flagged:
problems.append(
'Restraint at {0:.2f} ft is outside the {1:.2f}-{2:.2f} ft '
'elevation range; shown clamped to the nearest end.'
.format(elev, bottom, top))
x = elev_x(elev)
type_lower = r['type'].strip().lower()
if type_lower in ('pin', 'floor tie'):
role = 'restraint-pin' if type_lower == 'pin' else \
'restraint-floortie'
points = [(x, line_y),
(x - _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP),
(x + _RESTRAINT_TRI_HALF, line_y + _RESTRAINT_TRI_DROP)]
# 'stroke'/'fill'/'width' are rendering hints, same convention
# as the member line's 'width' -- see RESTRAINT SYMBOLS in the
# module docstring for which of these the current WPF button
# actually reads today.
primitives.append({'kind': 'polygon', 'points': points,
'role': role, 'flagged': flagged,
'stroke': _RESTRAINT_STROKE,
'fill': _RESTRAINT_FILL,
'width': _RESTRAINT_WIDTH})
elif type_lower == 'fixed':
# a fixed-end hatch: three short ticks angled off the line,
# thickened ('width') so it reads as a dark hatched block
# rather than a hairline -- the renderer already honours
# 'width' on a 'line' primitive.
for dx in (-6.0, 0.0, 6.0):
primitives.append({
'kind': 'line', 'x1': x + dx, 'y1': line_y,
'x2': x + dx - 4.0, 'y2': line_y + _RESTRAINT_TRI_DROP,
'role': 'restraint-fixed', 'flagged': flagged,
'stroke': _RESTRAINT_STROKE, 'width': _RESTRAINT_WIDTH})
else:
# 'Free', blank, or unrecognised -- no restraint SYMBOL (there
# is nothing restraining here), just a location dot.
role = 'restraint-free' if type_lower == 'free' else \
'restraint-unknown'
# radius = half of _RESTRAINT_TRI_DROP, so the dot's top edge
# sits ON the line (same "on the line" placement as the
# triangle/hatch symbols) and its full height matches theirs.
primitives.append({'kind': 'circle', 'cx': x,
'cy': line_y + _RESTRAINT_FREE_R,
'r': _RESTRAINT_FREE_R, 'role': role,
'flagged': flagged,
'stroke': _RESTRAINT_STROKE,
'fill': _RESTRAINT_FILL,
'width': _RESTRAINT_WIDTH})
label_text = '{0:.2f} ft'.format(elev)
if r['type']:
label_text += u' — {0}'.format(r['type'])
restraint_labels.append({'x': x, 'y': restraint_label_y,
'text': label_text,
'role': 'restraint-label'})
_destack(restraint_labels, direction=1)
for lab in restraint_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'middle'})
# ---- dimension chain of restraint spacings, under the labels
placed = sorted(restraint_rows, key=lambda r: r['elevation_ft'])
for a, b in zip(placed, placed[1:]):
xa, xb = elev_x(a['elevation_ft']), elev_x(b['elevation_ft'])
primitives.append({'kind': 'line', 'x1': xa, 'y1': dim_y,
'x2': xb, 'y2': dim_y, 'role': 'dimension'})
spacing = b['elevation_ft'] - a['elevation_ft']
primitives.append({'kind': 'text', 'x': (xa + xb) / 2.0,
'y': dim_y + 4.0,
'text': '{0:.2f} ft'.format(spacing),
'role': 'dimension', 'anchor': 'middle'})
# ---- concentrated loads: an arrow onto the line, label at the tail
load_labels = []
for i, ld in enumerate(load_rows, 1):
x = elev_x(ld['elevation_ft'])
offset = ld['offset_in']
above = offset is None or offset >= 0.0
if offset is None:
problems.append(
'Load {0} has no offset entered; shown on the outside '
'face by default.'.format(ld['label'] or 'P{0}'.format(i)))
y_tail = line_y - _ARROW_LEN if above else line_y + _ARROW_LEN
primitives.append({'kind': 'line', 'x1': x, 'y1': y_tail,
'x2': x, 'y2': line_y, 'role': 'load-arrow'})
head_y = line_y - _ARROW_HEAD if above else line_y + _ARROW_HEAD
points = [(x, line_y), (x - _ARROW_HEAD, head_y),
(x + _ARROW_HEAD, head_y)]
primitives.append({'kind': 'polygon', 'points': points,
'role': 'load-arrowhead'})
name = ld['label'] or 'P{0}'.format(i)
offset_text = (u'{0:+.0f} in'.format(offset) if offset is not None
else u'—')
text = '{0}: D {1} k / L {2} k, e {3}'.format(
name, _fmt_num(ld['dead_k'], '{0:.1f}'),
_fmt_num(ld['live_k'], '{0:.1f}'), offset_text)
# To the RIGHT of the arrow shaft, level with its tail -- not
# stacked above/below the tail, which used to fight the arrowhead
# for the same few pixels right next to the member line.
load_labels.append({'x': x + _LOAD_LABEL_GAP, 'y': y_tail,
'text': text, 'role': 'load-label',
'_dir': -1.0 if above else 1.0})
# loads above and below the line stack independently (their base rows
# sit on opposite sides), so each is destacked against only its own
# side to avoid an above-line label being nudged toward a below-line
# neighbour it does not actually overlap.
above_labels = [l for l in load_labels if l['_dir'] < 0]
below_labels = [l for l in load_labels if l['_dir'] > 0]
_destack(above_labels, direction=-1)
_destack(below_labels, direction=1)
for lab in load_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'start'})
# ---- wind zones: a row of small arrows over the from/to extent, in
# its OWN band above the loads (previously the wind arrows shared the
# loads' vertical span and the two rows visually merged into one).
# A zone with neither pressure nor suction typed in yet draws no band
# and no arrows at all -- there is nothing to point at -- just a small
# label saying so; drawing a "+— / -—" arrow row would be inventing a
# pressure nobody entered.
wind_labels = []
for i, wz in enumerate(wind_rows, 1):
x0 = elev_x(wz['from_elev_ft'])
x1 = elev_x(wz['to_elev_ft'])
if x1 < x0:
x0, x1 = x1, x0
name = wz['label'] or 'W{0}'.format(i)
has_pressure = (wz['pressure_psf'] is not None or
wz['suction_psf'] is not None)
if has_pressure:
primitives.append({'kind': 'rect', 'x': x0,
'y': wind_arrow_bottom_y,
'w': max(x1 - x0, 1.0), 'h': 3.0,
'role': 'wind-zone-band',
'flagged': wz['flagged']})
span_px = x1 - x0
n_arrows = _WIND_ARROWS_MIN
if span_px > 0.0:
n_arrows = int(_clamp(round(span_px / 24.0),
_WIND_ARROWS_MIN, _WIND_ARROWS_MAX))
for j in range(n_arrows):
frac = 0.5 if n_arrows == 1 else j / float(n_arrows - 1)
ax = x0 + frac * span_px
primitives.append({'kind': 'line', 'x1': ax,
'y1': wind_arrow_top_y, 'x2': ax,
'y2': wind_arrow_bottom_y,
'role': 'wind-arrow'})
points = [(ax, wind_arrow_bottom_y),
(ax - 3.0, wind_arrow_bottom_y - _ARROW_HEAD),
(ax + 3.0, wind_arrow_bottom_y - _ARROW_HEAD)]
primitives.append({'kind': 'polygon', 'points': points,
'role': 'wind-arrowhead'})
text = '{0}: +{1} / -{2} psf, w {3} ft'.format(
name, _fmt_num(wz['pressure_psf'], '{0:.0f}'),
_fmt_num(wz['suction_psf'], '{0:.0f}'),
_fmt_num(wz['width_ft'], '{0:.1f}'))
else:
text = '{0}: no pressure entered yet'.format(name)
wind_labels.append({'x': (x0 + x1) / 2.0, 'y': wind_label_y,
'text': text, 'role': 'wind-label'})
_destack(wind_labels, direction=-1)
for lab in wind_labels:
primitives.append({'kind': 'text', 'x': lab['x'], 'y': lab['y'],
'text': lab['text'], 'role': lab['role'],
'anchor': 'middle'})
if not restraint_rows and not load_rows and not wind_rows:
primitives.append({'kind': 'text', 'x': (x_left + x_right) / 2.0,
'y': line_y + 30.0,
'text': 'No restraints or loads entered yet.',
'role': 'note', 'anchor': 'middle'})
primitives = [_clamp_primitive(p, width_px, height_px)
for p in primitives]
return primitives, problems
# --------------------------------------------------------------- clamping
def _clamp_primitive(prim, width_px, height_px):
"""Pulls one primitive's coordinates back inside the box, in place --
same discipline as `panelsketch._clamp_primitive`, extended for the
`circle` and `polygon` kinds this drawing also uses."""
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)
elif kind == 'circle':
r = prim.get('r', 0.0)
prim['cx'] = _clamp(prim['cx'], r, max(width_px - r, r))
prim['cy'] = _clamp(prim['cy'], r, max(height_px - r, r))
elif kind == 'polygon':
prim['points'] = [(_clamp(px, 0.0, width_px),
_clamp(py, 0.0, height_px))
for px, py in prim.get('points', ())]
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
# --------------------------------------------------- real-unit geometry (ft)
#
# Added for the HTML-page rewrite (`vibe.pa_pagespec`): no pixels, no
# canvas size, no Revit -- just the same real-unit rows `freebody()` itself
# draws from, gathered under one dict a page with neither WPF nor a live
# model can still lay out. Built ENTIRELY from this module's own
# `_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` / `_elevation_
# span` -- the same four calls `freebody()` makes -- so the JSON and the
# still-live WPF picture can never disagree about what a row means.
def freebody_geometry(values):
"""`{'length_ft', 'bottom_elevation_ft', 'top_elevation_ft',
'restraints': [...], 'supports': [...], 'conc_loads': [...],
'wind_zones': [...], 'problems': [...]}` -- real feet / kips / psf, no
pixels.
`restraints` / `conc_loads` / `wind_zones` are exactly what
`_restraint_rows` / `_conc_load_rows` / `_wind_zone_rows` return (see
those functions' own docstrings for each row's shape); a row missing
the elevation it needs to be placed is skipped and named in `problems`,
the same "not drawn, and says why" rule those functions already follow.
`supports` (owner ruling, 2026-09-05) is `_support_rows`'s own
`{'id', 'x_ft', 'elevation_ft'}` list -- the `support` table's bearing
positions, converted to the same elevation axis restraints already
use, for the page's own "support triangles below the member,
restraints above" drawing rule. `bottom_elevation_ft` /
`top_elevation_ft` come from `_elevation_span`, which falls back to
the rows' own range, then to a bare 0.00-1.00 ft placeholder, when the
panel's own elevations are not entered -- never a crash, never an
empty span. Never raises; `values` is read-only.
"""
values = values or {}
problems = []
restraints = _restraint_rows(values, problems)
conc_loads = _conc_load_rows(values, problems)
wind_zones = _wind_zone_rows(values, problems)
supports = _support_rows(values, problems)
bottom, top = _elevation_span(values, restraints, conc_loads,
wind_zones, problems)
length_ft = _num(values.get('length_ft'))
return {
'length_ft': length_ft,
'bottom_elevation_ft': bottom, 'top_elevation_ft': top,
'restraints': restraints, 'supports': supports,
'conc_loads': conc_loads,
'wind_zones': wind_zones, 'problems': problems,
}