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.
769 lines
32 KiB
Python
769 lines
32 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Host tests for `vibe.pa_sketch_freebody` -- the Structural Model free-
|
|
body drawing behind the Panel Analysis form's restraint/support/load
|
|
picture (owner ruling, 2026-09-04: see the module's own docstring for the
|
|
full ruling text). The WPF `Canvas` painting is the button's job and out of
|
|
scope here, same as `test_panelsketch.py`; this pins the primitive list
|
|
`freebody()` produces: the member line always draws, restraints and loads
|
|
land at the right elevation, missing/out-of-range/reversed data is
|
|
reported rather than guessed, and everything stays inside the canvas.
|
|
|
|
Loaded the way `test_panelsketch.py` and `test_pa_structural.py` are --
|
|
`conftest.py` registers a bare `vibe` package pointed at `lib/vibe` so this
|
|
imports on plain host CPython with no Revit and no `pyrevit` on the path.
|
|
"""
|
|
|
|
from vibe import pa_sketch_freebody as F
|
|
|
|
|
|
# --------------------------------------------------------------- fixtures
|
|
|
|
def _rows(table, rows):
|
|
"""`values` fragment for one row-table: `rows` is a list of
|
|
`{col: value}` dicts, written under `'<table>.<n>.<col>'` with
|
|
`'<table>.<n>.present' = 'yes'`, exactly the contract `freebody()`
|
|
reads."""
|
|
values = {}
|
|
for i, row in enumerate(rows, 1):
|
|
n = str(i)
|
|
values['{0}.{1}.present'.format(table, n)] = 'yes'
|
|
for col, val in row.items():
|
|
values['{0}.{1}.{2}'.format(table, n, col)] = val
|
|
return values
|
|
|
|
|
|
def _merge(*dicts):
|
|
out = {}
|
|
for d in dicts:
|
|
out.update(d)
|
|
return out
|
|
|
|
|
|
def _texts(primitives, role=None):
|
|
out = []
|
|
for p in primitives:
|
|
if p['kind'] != 'text':
|
|
continue
|
|
if role is not None and p.get('role') != role:
|
|
continue
|
|
out.append(p['text'])
|
|
return out
|
|
|
|
|
|
def _within_box(primitives, width_px, height_px, tol=1e-6):
|
|
for p in primitives:
|
|
kind = p['kind']
|
|
if kind == 'rect':
|
|
assert -tol <= p['x'] <= width_px + tol, p
|
|
assert -tol <= p['y'] <= height_px + tol, p
|
|
assert -tol <= p['x'] + p['w'] <= width_px + tol, p
|
|
assert -tol <= p['y'] + p['h'] <= height_px + tol, p
|
|
elif kind == 'line':
|
|
for kx, ky in (('x1', 'y1'), ('x2', 'y2')):
|
|
assert -tol <= p[kx] <= width_px + tol, p
|
|
assert -tol <= p[ky] <= height_px + tol, p
|
|
elif kind == 'circle':
|
|
r = p.get('r', 0.0)
|
|
assert -tol <= p['cx'] - r <= width_px + r + tol, p
|
|
assert -tol <= p['cy'] - r <= height_px + r + tol, p
|
|
elif kind == 'polygon':
|
|
for px, py in p['points']:
|
|
assert -tol <= px <= width_px + tol, p
|
|
assert -tol <= py <= height_px + tol, p
|
|
else:
|
|
if 'x' in p:
|
|
assert -tol <= p['x'] <= width_px + tol, p
|
|
if 'y' in p:
|
|
assert -tol <= p['y'] <= height_px + tol, p
|
|
|
|
|
|
_KNOWN_KINDS = set(['line', 'rect', 'text', 'circle', 'polygon'])
|
|
|
|
|
|
# ------------------------------------------------------------------ safety
|
|
|
|
def test_never_raises_on_empty_values():
|
|
primitives, problems = F.freebody({}, 400, 150)
|
|
assert isinstance(primitives, list)
|
|
assert isinstance(problems, list)
|
|
|
|
|
|
def test_never_raises_on_none_values():
|
|
primitives, problems = F.freebody(None, 400, 150)
|
|
assert isinstance(primitives, list)
|
|
|
|
|
|
def test_never_raises_on_garbage_values():
|
|
values = {
|
|
'restraint.1.present': 'yes',
|
|
'restraint.1.elevation_ft': object(),
|
|
'restraint.1.type': 12345,
|
|
'conc_load.x.present': 'yes',
|
|
'wind_zone.1.present': 'yes',
|
|
'wind_zone.1.from_elev_ft': 'not a number',
|
|
'bottom_elevation_ft': [],
|
|
'top_elevation_ft': {},
|
|
3: 'not even a string key',
|
|
}
|
|
primitives, problems = F.freebody(values, 400, 150)
|
|
assert isinstance(primitives, list) and primitives
|
|
|
|
|
|
def test_never_raises_on_zero_or_negative_canvas_size():
|
|
for w, h in ((0, 0), (-10, 50), (50, -10), (None, None)):
|
|
primitives, problems = F.freebody({}, w, h)
|
|
assert isinstance(primitives, list)
|
|
|
|
|
|
def test_values_dict_not_mutated():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '10', 'type': 'Pin'}]))
|
|
before = dict(values)
|
|
F.freebody(values, 400, 150)
|
|
assert values == before
|
|
|
|
|
|
def test_top_level_wrapper_catches_unexpected_exception(monkeypatch):
|
|
def boom(values, w, h):
|
|
raise ValueError('boom')
|
|
monkeypatch.setattr(F, '_freebody', boom)
|
|
primitives, problems = F.freebody({}, 300, 100)
|
|
assert len(primitives) == 1
|
|
assert primitives[0]['kind'] == 'text'
|
|
assert 'could not be produced' in primitives[0]['text'].lower()
|
|
assert problems and 'failed' in problems[0].lower()
|
|
|
|
|
|
# ----------------------------------------------------------- member line
|
|
|
|
def test_empty_values_still_draws_the_member_line():
|
|
primitives, problems = F.freebody({}, 400, 150)
|
|
members = [p for p in primitives if p.get('role') == 'member']
|
|
assert len(members) == 1
|
|
assert members[0]['kind'] == 'line'
|
|
assert problems # missing elevation range is reported
|
|
|
|
|
|
def test_member_line_spans_close_to_the_full_declared_width():
|
|
primitives, _problems = F.freebody(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'}, 500, 150)
|
|
member = [p for p in primitives if p.get('role') == 'member'][0]
|
|
assert member['x2'] - member['x1'] > 500 * 0.5
|
|
|
|
|
|
def test_only_known_primitive_kinds():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '0', 'type': 'Pin'},
|
|
{'elevation_ft': '20', 'type': 'Fixed'},
|
|
{'elevation_ft': '10', 'type': 'Free'}]),
|
|
_rows('conc_load', [{'label': 'Ledger', 'elevation_ft': '12',
|
|
'offset_in': '10', 'dead_k': '9.6',
|
|
'live_k': '6.0'}]),
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18', 'width_ft': '8.4',
|
|
'pressure_psf': '16', 'suction_psf': '16'}]))
|
|
primitives, _problems = F.freebody(values, 600, 200)
|
|
for p in primitives:
|
|
assert p['kind'] in _KNOWN_KINDS, 'unknown primitive kind {0!r}'.format(p)
|
|
|
|
|
|
def test_all_primitives_within_canvas_bounds():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '10'},
|
|
_rows('restraint', [
|
|
{'elevation_ft': '-5', 'type': 'Pin'}, # out of range
|
|
{'elevation_ft': '0', 'type': 'Fixed'},
|
|
{'elevation_ft': '5', 'type': 'Free'},
|
|
{'elevation_ft': '10', 'type': 'Floor tie'},
|
|
{'elevation_ft': '15', 'type': 'Weird'}]), # out of range
|
|
_rows('conc_load', [
|
|
{'label': 'A', 'elevation_ft': '3', 'offset_in': '10',
|
|
'dead_k': '1', 'live_k': '2'},
|
|
{'label': 'B', 'elevation_ft': '7', 'offset_in': '-6',
|
|
'dead_k': '3', 'live_k': '4'}]),
|
|
_rows('wind_zone', [
|
|
{'label': 'W1', 'from_elev_ft': '8', 'to_elev_ft': '1',
|
|
'width_ft': '6', 'pressure_psf': '20', 'suction_psf': '12'}]))
|
|
for w, h in ((600, 200), (200, 600), (60, 60)):
|
|
primitives, _problems = F.freebody(values, w, h)
|
|
_within_box(primitives, w, h)
|
|
|
|
|
|
# --------------------------------------------------------------- restraints
|
|
|
|
def test_restraint_label_count_matches_valid_rows():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [
|
|
{'elevation_ft': '0', 'type': 'Pin'},
|
|
{'elevation_ft': '10', 'type': 'Fixed'},
|
|
{'elevation_ft': '20', 'type': 'Floor tie'},
|
|
{'type': 'Pin'}, # no elevation -- skipped
|
|
]))
|
|
primitives, problems = F.freebody(values, 500, 150)
|
|
labels = _texts(primitives, role='restraint-label')
|
|
assert len(labels) == 3
|
|
assert any('elevation' in p.lower() for p in problems)
|
|
|
|
|
|
def test_restraint_elevation_increases_left_to_right():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '2', 'type': 'Pin'},
|
|
{'elevation_ft': '18', 'type': 'Pin'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
polys = [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role') == 'restraint-pin']
|
|
assert len(polys) == 2
|
|
x0 = sum(pt[0] for pt in polys[0]['points']) / 3.0
|
|
x1 = sum(pt[0] for pt in polys[1]['points']) / 3.0
|
|
assert x0 < x1
|
|
|
|
|
|
def test_pin_and_floor_tie_draw_triangles():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '5', 'type': 'Pin'},
|
|
{'elevation_ft': '15', 'type': 'Floor tie'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
roles = set(p.get('role') for p in primitives if p['kind'] == 'polygon')
|
|
assert 'restraint-pin' in roles
|
|
assert 'restraint-floortie' in roles
|
|
|
|
|
|
def test_fixed_draws_hatch_lines_not_a_triangle():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '10', 'type': 'Fixed'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
fixed_lines = [p for p in primitives if p['kind'] == 'line'
|
|
and p.get('role') == 'restraint-fixed']
|
|
fixed_polys = [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role') == 'restraint-fixed']
|
|
assert len(fixed_lines) >= 2
|
|
assert not fixed_polys
|
|
|
|
|
|
def test_free_and_unrecognised_type_draw_a_circle_never_a_symbol():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '5', 'type': 'Free'},
|
|
{'elevation_ft': '15', 'type': ''}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
circles = [p for p in primitives if p['kind'] == 'circle']
|
|
roles = set(c.get('role') for c in circles)
|
|
assert 'restraint-free' in roles
|
|
assert 'restraint-unknown' in roles
|
|
assert not [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role', '').startswith('restraint')]
|
|
|
|
|
|
def test_restraint_outside_range_is_flagged_and_reported():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '10'},
|
|
_rows('restraint', [{'elevation_ft': '25', 'type': 'Pin'}]))
|
|
primitives, problems = F.freebody(values, 500, 150)
|
|
polys = [p for p in primitives if p['kind'] == 'polygon']
|
|
assert polys and polys[0]['flagged'] is True
|
|
assert any('outside' in p.lower() for p in problems)
|
|
|
|
|
|
def test_restraint_dimension_chain_between_consecutive_restraints():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '4', 'type': 'Pin'},
|
|
{'elevation_ft': '12', 'type': 'Pin'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
dims = _texts(primitives, role='dimension')
|
|
assert any('8.00 ft' in t for t in dims)
|
|
|
|
|
|
# --------------------------------------------------------- concentrated loads
|
|
|
|
def test_load_label_matches_the_owners_example_format():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'Ledger', 'elevation_ft': '12',
|
|
'offset_in': '10', 'dead_k': '9.6',
|
|
'live_k': '6.0'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
labels = _texts(primitives, role='load-label')
|
|
assert labels == ['Ledger: D 9.6 k / L 6.0 k, e +10 in']
|
|
|
|
|
|
def test_load_default_label_is_p_and_index():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [
|
|
{'elevation_ft': '5', 'offset_in': '0', 'dead_k': '1',
|
|
'live_k': '1'},
|
|
{'elevation_ft': '15', 'offset_in': '0', 'dead_k': '2',
|
|
'live_k': '2'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
labels = _texts(primitives, role='load-label')
|
|
assert any(t.startswith('P1:') for t in labels)
|
|
assert any(t.startswith('P2:') for t in labels)
|
|
|
|
|
|
def test_load_missing_elevation_is_skipped_and_reported():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'NoElev', 'dead_k': '1'}]))
|
|
primitives, problems = F.freebody(values, 500, 150)
|
|
labels = _texts(primitives, role='load-label')
|
|
assert not any('NoElev' in t for t in labels)
|
|
assert any('elevation' in p.lower() for p in problems)
|
|
|
|
|
|
def test_load_missing_dead_or_live_shown_as_dash_never_zero():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'X', 'elevation_ft': '10'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
labels = _texts(primitives, role='load-label')
|
|
assert labels
|
|
text = labels[0]
|
|
assert u'—' in text
|
|
assert 'D 0.0' not in text and 'L 0.0' not in text
|
|
|
|
|
|
def test_load_missing_offset_defaults_outside_face_and_reports():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'X', 'elevation_ft': '10'}]))
|
|
primitives, problems = F.freebody(values, 500, 150)
|
|
member = [p for p in primitives if p.get('role') == 'member'][0]
|
|
label = [p for p in primitives if p.get('role') == 'load-label'][0]
|
|
assert label['y'] < member['y1'] # above the line
|
|
assert any('offset' in p.lower() for p in problems)
|
|
|
|
|
|
def test_negative_offset_draws_below_the_line():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'X', 'elevation_ft': '10',
|
|
'offset_in': '-4', 'dead_k': '1',
|
|
'live_k': '1'}]))
|
|
primitives, _problems = F.freebody(values, 500, 150)
|
|
member = [p for p in primitives if p.get('role') == 'member'][0]
|
|
label = [p for p in primitives if p.get('role') == 'load-label'][0]
|
|
assert label['y'] > member['y1'] # below the line
|
|
|
|
|
|
# ----------------------------------------------------------------- wind
|
|
|
|
def test_wind_zone_label_matches_the_owners_example_format():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18', 'width_ft': '8.4',
|
|
'pressure_psf': '16', 'suction_psf': '16'}]))
|
|
primitives, _problems = F.freebody(values, 600, 200)
|
|
labels = _texts(primitives, role='wind-label')
|
|
assert labels == ['W1: +16 / -16 psf, w 8.4 ft']
|
|
|
|
|
|
def test_wind_zone_missing_from_or_to_is_skipped_and_reported():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'Bad', 'from_elev_ft': '2'}]))
|
|
primitives, problems = F.freebody(values, 600, 200)
|
|
labels = _texts(primitives, role='wind-label')
|
|
assert not labels
|
|
assert any('elevation' in p.lower() for p in problems)
|
|
|
|
|
|
def test_wind_zone_reversed_from_to_is_swapped_and_flagged():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '18',
|
|
'to_elev_ft': '2', 'pressure_psf': '10',
|
|
'suction_psf': '10'}]))
|
|
primitives, problems = F.freebody(values, 600, 200)
|
|
bands = [p for p in primitives if p.get('role') == 'wind-zone-band']
|
|
assert bands and bands[0]['flagged'] is True
|
|
assert any('swapped' in p.lower() for p in problems)
|
|
|
|
|
|
def test_wind_zone_draws_a_band_of_arrows():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18', 'pressure_psf': '10',
|
|
'suction_psf': '10'}]))
|
|
primitives, _problems = F.freebody(values, 600, 200)
|
|
arrow_lines = [p for p in primitives if p['kind'] == 'line'
|
|
and p.get('role') == 'wind-arrow']
|
|
arrow_heads = [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role') == 'wind-arrowhead']
|
|
assert len(arrow_lines) >= 3
|
|
assert len(arrow_heads) == len(arrow_lines)
|
|
|
|
|
|
# --------------------------------------------------------------- no rows
|
|
|
|
def test_no_restraints_or_loads_note_when_nothing_entered():
|
|
primitives, _problems = F.freebody(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '10'}, 400, 150)
|
|
notes = _texts(primitives, role='note')
|
|
assert any('no restraints or loads' in t.lower() for t in notes)
|
|
|
|
|
|
def test_elevation_span_falls_back_to_row_extent_when_unentered():
|
|
values = _rows('restraint', [{'elevation_ft': '5', 'type': 'Pin'},
|
|
{'elevation_ft': '25', 'type': 'Pin'}])
|
|
primitives, problems = F.freebody(values, 500, 150)
|
|
ends = _texts(primitives, role='elevation-end')
|
|
assert any('5.00' in t for t in ends)
|
|
assert any('25.00' in t for t in ends)
|
|
assert any('bottom_elevation_ft' in p for p in problems)
|
|
|
|
|
|
def test_elevation_span_falls_back_to_placeholder_with_no_rows_either():
|
|
primitives, problems = F.freebody({}, 400, 150)
|
|
ends = _texts(primitives, role='elevation-end')
|
|
assert any('0.00' in t for t in ends)
|
|
assert any('1.00' in t for t in ends)
|
|
assert any('placeholder' in p.lower() for p in problems)
|
|
|
|
|
|
# --------------------------------------------------------- row_ids/row_key
|
|
|
|
def test_row_key_shape():
|
|
assert F.row_key('wind_zone', '1', 'pressure_psf') == \
|
|
'wind_zone.1.pressure_psf'
|
|
|
|
|
|
def test_row_ids_reads_present_yes_and_sorts_numerically():
|
|
values = {
|
|
'conc_load.2.present': 'yes', 'conc_load.10.present': 'yes',
|
|
'conc_load.1.present': 'yes', 'conc_load.5.present': 'no',
|
|
}
|
|
assert F.row_ids('conc_load', values) == [1, 2, 10]
|
|
|
|
|
|
# ------------------------------------------------------------- destacking
|
|
|
|
def test_destack_pushes_close_labels_apart():
|
|
items = [{'x': 10.0, 'y': 100.0}, {'x': 15.0, 'y': 100.0},
|
|
{'x': 200.0, 'y': 100.0}]
|
|
F._destack(items, direction=1)
|
|
assert items[0]['y'] == 100.0
|
|
assert items[1]['y'] != 100.0
|
|
assert items[2]['y'] == 100.0
|
|
|
|
|
|
def test_destack_leaves_far_apart_labels_alone():
|
|
items = [{'x': 0.0, 'y': 50.0}, {'x': 100.0, 'y': 50.0},
|
|
{'x': 200.0, 'y': 50.0}]
|
|
F._destack(items, direction=1)
|
|
assert all(item['y'] == 50.0 for item in items)
|
|
|
|
|
|
# ------------------------------------------------------------ layout: margin
|
|
|
|
def test_margin_is_never_below_70px_at_realistic_canvas_sizes():
|
|
for w, h in ((400, 88), (600, 132), (900, 198)):
|
|
assert F._margin(w, h) >= 70.0
|
|
|
|
|
|
def test_end_labels_stay_inside_the_canvas_with_room_for_the_text():
|
|
"""The owner's headless render, 2026-09-04: 'Bottom: -2.00 ft' clipped
|
|
down to 'ottom:' because the label was centred on the margin edge with
|
|
too little margin to hold it. The real scenario from that render: a
|
|
37.58 ft wall, bottom/top -2.00 / 35.67 ft."""
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '-2.00', 'top_elevation_ft': '35.67'},
|
|
_rows('restraint', [
|
|
{'elevation_ft': '4.21', 'type': 'Pin'},
|
|
{'elevation_ft': '13.21', 'type': 'Fixed'},
|
|
{'elevation_ft': '16.88', 'type': 'Floor tie'},
|
|
{'elevation_ft': '23.88', 'type': 'Floor tie'},
|
|
{'elevation_ft': '27.54', 'type': 'Floor tie'},
|
|
{'elevation_ft': '34.54', 'type': 'Pin'}]))
|
|
for w, h in ((600, 132), (900, 198)):
|
|
primitives, _problems = F.freebody(values, w, h)
|
|
ends = dict((p['text'], p) for p in primitives
|
|
if p.get('role') == 'elevation-end')
|
|
bottom_label = [t for t in ends if t.startswith('Bottom:')][0]
|
|
top_label = [t for t in ends if t.startswith('Top:')][0]
|
|
margin = F._margin(float(w), float(h))
|
|
# a rough per-character text-width budget matching the WPF
|
|
# renderer's own 'middle'-anchor estimate (`_sketch_text`, 2.6 px
|
|
# per character each side) -- the label's half-width must fit
|
|
# inside the margin reserved for it.
|
|
half_w = len(bottom_label) * 2.6
|
|
assert half_w <= margin, \
|
|
'label {0!r} half-width {1} exceeds the {2} px margin' \
|
|
.format(bottom_label, half_w, margin)
|
|
half_w = len(top_label) * 2.6
|
|
assert half_w <= margin
|
|
|
|
|
|
# --------------------------------------------------- layout: member + ticks
|
|
|
|
def test_member_line_is_a_wide_dark_line_with_end_ticks():
|
|
values = _merge({'bottom_elevation_ft': '0', 'top_elevation_ft': '20'})
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
member = [p for p in primitives if p.get('role') == 'member'][0]
|
|
assert member.get('width', 0) >= 3.0
|
|
ticks = [p for p in primitives if p.get('role') == 'member-end-tick']
|
|
assert len(ticks) == 2
|
|
tick_xs = set([t['x1'] for t in ticks])
|
|
assert tick_xs == set([member['x1'], member['x2']])
|
|
|
|
|
|
def test_restraint_symbols_are_at_least_14px_tall():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '5', 'type': 'Pin'},
|
|
{'elevation_ft': '10', 'type': 'Fixed'},
|
|
{'elevation_ft': '15', 'type': 'Free'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
|
|
pin = [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role') == 'restraint-pin'][0]
|
|
ys = [pt[1] for pt in pin['points']]
|
|
assert max(ys) - min(ys) >= 14.0
|
|
|
|
fixed_lines = [p for p in primitives if p['kind'] == 'line'
|
|
and p.get('role') == 'restraint-fixed']
|
|
drop = max(abs(l['y2'] - l['y1']) for l in fixed_lines)
|
|
assert drop >= 14.0
|
|
|
|
free_dot = [p for p in primitives if p['kind'] == 'circle'
|
|
and p.get('role') == 'restraint-free'][0]
|
|
assert free_dot['r'] * 2.0 >= 14.0
|
|
|
|
|
|
def test_restraint_symbols_are_16px_tall():
|
|
"""Coordinator, live-render follow-up: pin/floor-tie triangles, the
|
|
fixed hatch and the free/unrecognised dot are all 16 px tall, not just
|
|
>= 14."""
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '5', 'type': 'Pin'},
|
|
{'elevation_ft': '10', 'type': 'Fixed'},
|
|
{'elevation_ft': '15', 'type': 'Free'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
|
|
pin = [p for p in primitives if p['kind'] == 'polygon'
|
|
and p.get('role') == 'restraint-pin'][0]
|
|
ys = [pt[1] for pt in pin['points']]
|
|
assert max(ys) - min(ys) == 16.0
|
|
|
|
fixed_lines = [p for p in primitives if p['kind'] == 'line'
|
|
and p.get('role') == 'restraint-fixed']
|
|
drop = max(abs(l['y2'] - l['y1']) for l in fixed_lines)
|
|
assert drop == 16.0
|
|
|
|
free_dot = [p for p in primitives if p['kind'] == 'circle'
|
|
and p.get('role') == 'restraint-free'][0]
|
|
assert free_dot['r'] * 2.0 == 16.0
|
|
|
|
|
|
def test_restraint_triangle_apex_touches_the_line_below_it():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '10', 'type': 'Pin'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
line_y = [p for p in primitives if p.get('role') == 'member'][0]['y1']
|
|
tri = [p for p in primitives if p.get('role') == 'restraint-pin'][0]
|
|
ys = [pt[1] for pt in tri['points']]
|
|
assert min(ys) == line_y # apex on the line
|
|
assert max(ys) == line_y + 16.0 # base 16 px below it
|
|
|
|
|
|
def test_restraint_symbols_carry_dark_stroke_fill_hints():
|
|
"""Coordinator: 'stroke': '#333', 'fill': '#888' -- one shared dark
|
|
styling across all three restraint symbol kinds ('equally dark'),
|
|
even though the WPF button does not draw the polygon/circle kinds at
|
|
all today (see the module docstring's RESTRAINT SYMBOLS note)."""
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '5', 'type': 'Pin'},
|
|
{'elevation_ft': '8', 'type': 'Floor tie'},
|
|
{'elevation_ft': '11', 'type': 'Fixed'},
|
|
{'elevation_ft': '14', 'type': 'Free'},
|
|
{'elevation_ft': '17', 'type': ''}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
restraint_prims = [p for p in primitives
|
|
if str(p.get('role', '')).startswith('restraint-')
|
|
and p['kind'] != 'text']
|
|
assert restraint_prims
|
|
for p in restraint_prims:
|
|
assert p.get('stroke') == '#333'
|
|
assert p.get('width', 0) >= 2.0
|
|
if p['kind'] in ('polygon', 'circle'):
|
|
assert p.get('fill') == '#888'
|
|
|
|
|
|
# ------------------------------------ layout: rows below the member line
|
|
|
|
def test_restraint_label_sits_close_under_the_symbol():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '10', 'type': 'Pin'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
line_y = [p for p in primitives if p.get('role') == 'member'][0]['y1']
|
|
label = [p for p in primitives if p.get('role') == 'restraint-label'][0]
|
|
# symbol bottom is 16 px below the line; the label should sit close
|
|
# under it, not far below (a small, fixed gap -- see
|
|
# `_RESTRAINT_LABEL_GAP`).
|
|
assert label['y'] - (line_y + 16.0) <= 8.0
|
|
|
|
|
|
def test_dimension_row_sits_about_22px_below_the_restraint_label_row():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '4', 'type': 'Pin'},
|
|
{'elevation_ft': '12', 'type': 'Pin'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
label_y = [p for p in primitives
|
|
if p.get('role') == 'restraint-label'][0]['y']
|
|
dim_line = [p for p in primitives if p.get('role') == 'dimension'
|
|
and p['kind'] == 'line'][0]
|
|
assert abs(dim_line['y1'] - label_y - 22.0) < 1e-6
|
|
|
|
|
|
# ------------------------------------------------ layout: no text near line
|
|
|
|
def test_no_text_primitive_within_6px_of_the_member_line():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('restraint', [{'elevation_ft': '0', 'type': 'Pin'},
|
|
{'elevation_ft': '10', 'type': 'Fixed'},
|
|
{'elevation_ft': '20', 'type': 'Free'}]),
|
|
_rows('conc_load', [
|
|
{'label': 'A', 'elevation_ft': '5', 'offset_in': '10',
|
|
'dead_k': '1', 'live_k': '2'},
|
|
{'label': 'B', 'elevation_ft': '15', 'offset_in': '-6',
|
|
'dead_k': '3', 'live_k': '4'}]),
|
|
_rows('wind_zone', [
|
|
{'label': 'W1', 'from_elev_ft': '2', 'to_elev_ft': '18',
|
|
'pressure_psf': '16', 'suction_psf': '16'}]))
|
|
primitives, _problems = F.freebody(values, 700, 160)
|
|
line_y = [p for p in primitives if p.get('role') == 'member'][0]['y1']
|
|
for p in primitives:
|
|
if p['kind'] != 'text':
|
|
continue
|
|
assert abs(p['y'] - line_y) > 6.0, p
|
|
|
|
|
|
# --------------------------------------------------------- layout: loads
|
|
|
|
def test_load_label_sits_to_the_right_of_its_arrow():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'Ledger', 'elevation_ft': '10',
|
|
'offset_in': '10', 'dead_k': '1',
|
|
'live_k': '1'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
arrow = [p for p in primitives if p.get('role') == 'load-arrow'][0]
|
|
label = [p for p in primitives if p.get('role') == 'load-label'][0]
|
|
assert label['x'] > arrow['x1']
|
|
assert abs(label['y'] - arrow['y1']) < 1e-6
|
|
assert label.get('anchor') != 'middle'
|
|
|
|
|
|
def test_load_arrow_is_at_least_30px_long():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'X', 'elevation_ft': '10',
|
|
'offset_in': '0', 'dead_k': '1',
|
|
'live_k': '1'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
arrow = [p for p in primitives if p.get('role') == 'load-arrow'][0]
|
|
assert abs(arrow['y2'] - arrow['y1']) >= 30.0
|
|
|
|
|
|
# ------------------------------------------------------- layout: wind band
|
|
|
|
def test_wind_zone_with_no_pressure_or_suction_shows_only_a_small_label():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
labels = _texts(primitives, role='wind-label')
|
|
assert labels == ['W1: no pressure entered yet']
|
|
assert not [p for p in primitives if p.get('role') == 'wind-zone-band']
|
|
assert not [p for p in primitives if p.get('role') == 'wind-arrow']
|
|
assert not [p for p in primitives if p.get('role') == 'wind-arrowhead']
|
|
for t in labels:
|
|
assert u'—' not in t
|
|
assert '+' not in t
|
|
|
|
|
|
def test_wind_zone_with_only_pressure_still_draws_the_band():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18', 'pressure_psf': '16'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
labels = _texts(primitives, role='wind-label')
|
|
assert labels == ['W1: +16 / -— psf, w — ft']
|
|
assert [p for p in primitives if p.get('role') == 'wind-zone-band']
|
|
arrow_lines = [p for p in primitives if p['kind'] == 'line'
|
|
and p.get('role') == 'wind-arrow']
|
|
assert len(arrow_lines) >= 3
|
|
|
|
|
|
def test_wind_arrows_sit_above_the_load_arrows_not_overlapping():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'top_elevation_ft': '20'},
|
|
_rows('conc_load', [{'label': 'X', 'elevation_ft': '10',
|
|
'offset_in': '0', 'dead_k': '1',
|
|
'live_k': '1'}]),
|
|
_rows('wind_zone', [{'label': 'W1', 'from_elev_ft': '2',
|
|
'to_elev_ft': '18', 'pressure_psf': '10',
|
|
'suction_psf': '10'}]))
|
|
primitives, _problems = F.freebody(values, 600, 150)
|
|
wind_ys = [p['y2'] for p in primitives if p.get('role') == 'wind-arrow']
|
|
load_ys = [p['y1'] for p in primitives if p.get('role') == 'load-arrow']
|
|
# every wind-arrow bottom sits at or above every load-arrow top -- the
|
|
# two rows do not share vertical space.
|
|
assert max(wind_ys) <= min(load_ys) + 1e-6
|
|
|
|
|
|
# ------------------------------------------------- supports (2026-09-05)
|
|
#
|
|
# Owner ruling: bearing positions are the `support` row table (x_ft along
|
|
# the length), converted to an elevation off `bottom_elevation_ft` the same
|
|
# way restraints already are, for the free body's own "supports below,
|
|
# restraints above" drawing rule. `freebody_geometry` is what the HTML page
|
|
# reads (`pa_pagespec` -> `draw.js`'s `renderFreebody`), so this is tested
|
|
# through that entry point rather than the pixel-based `freebody()`.
|
|
|
|
def test_freebody_geometry_converts_support_table_rows_to_elevations():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '-2', 'top_elevation_ft': '20'},
|
|
_rows('support', [{'x_ft': '5'}, {'x_ft': '20'}]))
|
|
geom = F.freebody_geometry(values)
|
|
xs_elevs = sorted((s['x_ft'], s['elevation_ft']) for s in geom['supports'])
|
|
assert xs_elevs == [(5.0, 3.0), (20.0, 18.0)]
|
|
assert geom['problems'] == []
|
|
|
|
|
|
def test_freebody_geometry_falls_back_to_legacy_supports_ft_text():
|
|
values = {'bottom_elevation_ft': '0', 'supports_ft': '2, 22'}
|
|
geom = F.freebody_geometry(values)
|
|
assert sorted(s['elevation_ft'] for s in geom['supports']) == [2.0, 22.0]
|
|
|
|
|
|
def test_freebody_geometry_support_table_wins_over_legacy_text():
|
|
values = _merge(
|
|
{'bottom_elevation_ft': '0', 'supports_ft': '99, 100'},
|
|
_rows('support', [{'x_ft': '3'}]))
|
|
geom = F.freebody_geometry(values)
|
|
assert [s['elevation_ft'] for s in geom['supports']] == [3.0]
|
|
|
|
|
|
def test_freebody_geometry_no_bottom_elevation_reports_and_skips_supports():
|
|
values = _rows('support', [{'x_ft': '3'}])
|
|
geom = F.freebody_geometry(values)
|
|
assert geom['supports'] == []
|
|
assert any('bottom_elevation_ft' in p for p in geom['problems'])
|