WillTools 2026-09-09 -- clean snapshot of the development repo for review
ci / test (push) Successful in 6m14s
ci / test (push) Successful in 6m14s
History-free snapshot of the private development repository at 77a82eb. Code, docs and the public catalogue only: no plant or job knowledge layers, no client, job or producer names (tools/scrub.py clean against the widened term list; a second word-level grep clean). The intro deck is left out because its slide images cannot be checked for job drawings.
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
# -*- 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'])
|
||||
Reference in New Issue
Block a user