8e6ee31596
ci / test (push) Successful in 6m18s
History-free snapshot of the private development repository at cbe45c5 (2026-09-09). Code, docs and public catalogue only; no plant or job knowledge layers, no client or job names (tools/scrub.py clean). The intro deck is left out of this snapshot because its slide images could not be checked for job drawings.
921 lines
42 KiB
Python
921 lines
42 KiB
Python
# -*- 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 `'<table>.<n>.<col>'`, and
|
|
`'<table>.<n>.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<i>' 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<i>'), 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
|
|
"<name>: 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,
|
|
}
|