# Design: JoistArrayModel on stations and canonical nodes **Status:** Implemented **Branch:** `refactor/joist_array_model` **Author:** Connor Ferster **Date:** 2026-09-18 --- ## 1. Problem A joist array turns one drawn **joist prototype** into the joists of a spread. The previous `JoistArrayModel` built each joist by projecting rays from a translated centroid, intersecting them with copies of the supports, adding a fixed cantilever, and then testing the result against the supports again. On the test fixtures this produced: | Symptom | Fixture evidence | |---|---| | A joist lands in a gap between supports and gets the wrong supports, or is silently dropped | `collector_extents.pdf`: `SJ0.0-5` was truncated at WT0.3; `SJ0.0-10` was missing | | Joists at the array edge fail to build | `resi_dormers.pdf`: `RJ0.0-0` missing and `RJ0.0-6` a GeometryError; `sketch_to_scale.pdf`: `J1.1-10` dropped as "only one support" | | The last gap exceeds the spacing (up to 1.5x) | `test_wall_point_load_locations` failed with a 1.114 gap at 1.0 spacing | | Cantilevers projected into the span; arrays generated right to left | Prototypes tilted past vertical (positive-x bias; §3.1) | | Cantilevers could not vary; supports assumed orthogonal | `"horizontal"`/`"vertical"` branches throughout | | Not part of node canonicalization | Supports were per-element copies; crossings were recomputed with `grid_size=1e-3` | The `CollectorTribModel` gave one whole-spread trib area per prototype, so it could not represent any of this either. It has been removed. ## 2. Goals 1. A joist that lands where supports are missing moves to the nearest valid station, and trib areas leave no gaps between adjacent joists. 2. A `joist_container` polygon markup lets cantilevers **and** backspans vary along the array. 3. The model reads supports from, and writes joists into, the shared `GeometryModel` (STRtree plus `NodeRegistry`), so no crossing is computed twice. ## 3. Design The engine lives in `src/papermodels/geometry/joist_array.py`, and `JoistArrayModel` (`datatypes/joist_models.py`) orchestrates it. ### 3.1 One frame, oriented once Work happens in the prototype's own frame. `u` runs along the joist. `n` is the normal, which is the **array axis**. A **station** `s` is a position along `n` and `t` is a position along `u`, so the joist line at station `s` is `origin + s·n + t·u`. Nothing depends on axis alignment. The rest of the library uses the **positive-x bias** (`order_nodes_positive`), which flips its orientation at vertical. Near vertical, the 1e-3 grid snapping produces exact x-ties, and the ascending-y tiebreak then disagrees with the joist's direction. That is how cantilevers ended up projected into the span. The engine avoids it as follows: - `n` is set by a **dominant-axis rule**: `+x` when `|n_x| > |n_y|`, else `+y`. Vertical joists array left to right; horizontal joists array bottom to top. `u = rotate_cw(n)`. Every orientation rule has a discontinuity somewhere; this one puts it at 45°, where drawings rarely sit and grid ties don't occur. - The origin is the prototype's midpoint, so the frame is the same whichever way the prototype was drawn. - Inside the engine, points are ordered **only** by their `t`/`s` projections. End A is always the smaller-`t` end. - The positive-x bias is applied only at the output boundary, where each subelement's LineString is written in positive-x order for downstream code. ### 3.2 Array region and supports | Mode | Triggered by | Region R | Station range | |---|---|---|---| | container | a `joist_container` polygon around the prototype | the container | the container's | | extent | an extent line crossing the prototype | the band swept by the prototype and its supports along the extent line (`prototype_extent_region`, not an axis-aligned bbox) | the extent line's | | plain | neither | the quadrilateral between the two outer supports, plus the cantilevers | where **all** the prototype's supports exist; stops where the outer supports converge (a triangle apex) | The supports are the parent element's graph supports (`intersections_below`), read from the shared model: wall centerlines and beam lines, never copies. A support is **present** at a station if its drawn geometry (the wall polygon) is inside R there, bounded by its centerline's own extent. So a container drawn to a wall's face still catches the wall. The graph build uses the same function (`region_support_crossing`) for the parent element's intersections, so the graph and the array cannot disagree about which supports an array bears on. Graph edges were unchanged on all 9 fixtures when this was switched over. ### 3.3 Breakpoints, stations and gap resolution Projecting every support's endpoints inside R onto `n` gives **breakpoints**. Between two adjacent breakpoints the set of supports is constant, so each interval is classified once. Nothing is re-tested per joist. Target stations start at the array's start edge, at `spacing` apart. No gap exceeds the spacing, and `joist_at_start`, `joist_at_end` and `initial_offset` are all honoured. A target station is **invalid** if its interval has fewer than two supports, or **fewer** supports than both of its neighbours. Fewer than both means a support the neighbours bear on is missing here: a gap, for example in the outer line while an intermediate support still gives the joist two. Having **more** supports than both neighbours, such as a short intermediate beam under that one joist, is a legitimate extra bearing, so the joist stays on it. An invalid station moves to the nearest valid station, found by a `bisect` over the breakpoints followed by an outward scan. The scan stays within half a spacing of the target and never passes a neighbour, and it insets from the edge of the valid interval by `min_bearing`. If nothing valid is in the window, the joist is dropped with a warning. Intermediate supports that come and go (WT0.3 in `collector_extents`) don't trigger relocation, because their neighbours have the same count. ### 3.4 Joists and canonical crossings - **extent and plain modes:** each joist runs between the outer support crossings at its station, plus the prototype's constant signed cantilevers. A cantilever below `cantilever_tolerance`, or a negative one (the prototype drawn to a wall face), becomes 0, so every joist reaches its supports. - **container mode:** each joist is the container cut along its station line, extended to the outer supports if the container stops short of them. Each crossing is interpolated **along the support**, so it lies on the support, and is snapped through the shared `NodeRegistry`. A joist end that sits on a support *is* that node. The subelement's `Intersection` records are built directly from these nodes and are never re-intersected. Each record also carries its precomputed extent along the support, so the graph doesn't call the axis-aligned `get_collector_extents` path for subelements. `GeometryGraph.build_geometry_model()` runs after gravity-frame processing, which moves beam ends onto column centroids. It seeds the registry with every existing intersection coordinate. Generated joists are written back with `add_geometry(..., parent=...)`, and re-assigning collector behaviour replaces them. ### 3.5 Trib areas With final stations `s_0 … s_k`, joist *i*'s trib area is the band between the midpoints to its neighbours (the array's station limits at the two ends), clipped to R. Neighbouring bands share one boundary value, so the bands **tile R exactly**, including around relocated joists. ### 3.6 Span jumps A span can only jump where an outer support changes identity. The new support's crossing is compared with the old support's line extended to the same station. If it moves by more than `span_jump_tol` (default: half the spacing), the model warns with the tag and station and suggests a separate prototype, then continues. ## 4. Joist container markup Add a legend entry whose `Type` contains `container`, for example: ``` Legend Type: Joist Container ``` Then draw a polygon around a joist prototype. The prototype is matched to the container holding most of its length on the same page. The model warns if a container holds zero or several prototypes, and if a prototype has both an extent line and a container (the container wins). ## 5. Helper audit No existing helper was reused on trust. The audit is pinned by `tests/test_helper_characterization.py` across orientations. | Helper | Finding | Outcome | |---|---|---| | `create_extent_polygon` | axis-aligned bbox | replaced by `prototype_extent_region`; removed | | `get_joist_locations` | line-to-line distance; ignored `joist_at_end`; gaps up to 1.5 × spacing | replaced by `target_stations`; removed | | `get_intersection`, extent branch | OBB centerline of the *clipped* wall piece turns perpendicular for narrow clips | replaced by `region_support_crossing` for arrays | | `get_wall_centerline` (OBB) | correct at any angle; tolerates noise; near-square gives an axis | kept | | `rotate_90_vector` | exact quarter turn | kept | | `load_distribution.get_distributed_loads_from_projected_polygons` | **defect:** after rotation a vertical edge keeps a ~1e-17 x-offset, which becomes a ~1e16 slope, so rectangular and trapezoidal bands about a 30° member project to zero load | guarded locally by `geometry.load_projection.project_loading_areas` (same rotation, snap to 1e-9, horizontal member); **fix upstream** (see `load-distribution-bug.md`) | ## 6. Behaviour changes - Horizontal joists now array bottom to top, so load lists on the supports come out in the opposite order. Order-dependent tests were made order-independent. - Plain arrays use only the prototype's own supports, and extent arrays use the graph's supports inside the region. Both warn about in-region supports the graph didn't link. - Point-reaction polygons linked to a prototype are borne along their long axis, with a warning. Drawings made before the `Reaction Type` legend field draw walls this way. - Subelement tags are sequential (`TAG-0 … TAG-n`). Dropped joists no longer leave holes in the numbering. ## 7. Open questions - **Array ends** only require two supports, because the neighbour rule needs two neighbours. A wrong-pair end joist is still caught by the span-jump warning. - **DXF** gained extent and container routing but has no test fixture. - **The Example notebooks** have unrelated API drift (`LoadedElement.dump_toml`, element tags, and a KeyError in Example 5). Those failures also occur on `main`.