# -*- 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 `'..'` with `'
..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'])