Knowledge Base
How Architectural Detail Reaches the Twin Map (Technical Reference)
Scope
The companion to "Reading a Facility on the Twin Map", for anyone who needs to know which IFC entity produced a given line on the plan, and which few lines ReqTwin added itself.
Where the drawing comes from
Every element's position comes from the customer's model. Walls, doors, stairs, lifts, rooms: ReqTwin reads where each one is from the IFC or GeoPackage and never moves it. Nothing on the plan is placed by guesswork.
What ReqTwin supplies is the drafting. A door in an IFC file is a solid with a width and a swing direction; a floorplan needs a door symbol drawn to the usual convention. So the location is the customer's and the standard architectural detail is ours, in the same way a draughtsman renders a surveyed opening using the office's own symbol library.
That is the ordinary case and it covers almost everything the map draws.
A small number of elements are genuinely added, and only where the source model omits something a code requires: guarding on an unprotected stair flight, for example. Those rows carry is_synthesized = true and the detail panel labels them plainly, because an element nobody surveyed must never be mistaken for one that was. They are listed under "Code-compliance guarding" below.
Classification and geometry flow *outward* from the source and never back into it: an added value is a clearly-labelled fallback that must never overwrite an authored one. docs/adr/0007-architectural-detail-realism-rules.md records the full principle and the measurements behind each rule.
IFC entity mapping
Ingest walks the model in app/(app)/lib/server/ifc-ingest.ts. Its meshTypes table maps IFC entities onto floorplan_elements.detail_type:
| IFC entity | detail_type | Extra attributes read |
|---|---|---|
| IfcDoor | door | OverallWidth, OverallHeight |
| IfcStairFlight | stair | NumberOfTreads, RiserHeight, TreadLength |
| IfcRailing | railing | - |
| IfcRamp | ramp | - (incline derived from mesh geometry) |
| IfcSlab with PredefinedType = .LANDING. | landing | - |
IfcWindow is not in this table on purpose. A window imports as a discrete asset, the same path a valve or an air-handling unit takes, not as a floorplan symbol. It has no role in the navigation graph either: a window is an opening, not something a route may or may not cross, so treating it as barrier fabric was never the right model. See "Reading Assets on the Twin Map" for where a window shows up instead. Older imports still carry legacy window rows in floorplan_elements from before this changed; those are not migrated, so a facility ingested before 2026-08-23 may still show them.
Why the landing entry needs a filter
Every other row in that table accepts every instance of its type. IfcSlab is the one entity carrying two genuinely different architectural meanings, so it is the only entry with a filter predicate.
The predicate discriminates on PredefinedType, not Name: the enum is schema-controlled, Name is free authoring text. IFC2X3 exposes PredefinedType on the IfcSlab occurrence directly, so the filter checks that first and falls back to the IfcSlabType reached through IfcRelDefinesByType. An absent or NOT_DEFINED value is treated as not a landing: a floor slab is the far likelier default, and guessing the other way would carpet every storey in a full-footprint "landing".
This was a real ingest gap, not a cosmetic addition. IfcSlab was already walked, but only by the discrete-asset pass, so on the reference model all 85 slabs collapsed into discrete_assets and the 29 that are architecturally landings produced no floorplan row at all.
One entity, two representations
The landing mapping is additive, not a move: the slab stays in discrete_assets and *also* gains a floorplan_elements row. Re-homing it would silently drop asset lines from an already-generated BOM, mirroring the existing treatment of lifts, where one IfcTransportElement is both a discrete asset and a floorplan symbol.
Rendering
Two MapLibre layers on the details-src source, both in twin-map-client.tsx:
landings-fill is registered with "fill-opacity": 0, drawing nothing. It exists because a fill layer is still hit-testable, so queryRenderedFeatures and click handlers keep a landing selectable across its whole surface rather than only on its outline. The colour you see inside a landing is the room fill showing through, which is the point: a landing is continuous walking surface, and any fill of its own made it read as a hole in the floor.
landings-outline draws #0B0B0C at 1.0-3.2px across zoom 16-22.
#0B0B0C is the same near-black the map uses for walls, doors and stair symbols. Railings were moved onto it too, from a dashed mid-grey. The dash mattered: on this map a dashed line means "not real fabric", so drawing IFC-authored railings dashed asserted the opposite of the truth, and over a stair's tread hatching it was invisible anyway. Distinction from wall fabric is now carried by weight rather than dash pattern.
Code-compliance guarding
This is the one place ReqTwin adds an element the model does not contain. Where the source model has no guarding on a stair a code requires it, four migrations add it. All are facility-scoped, idempotent (deterministic source_element_id with an upsert), and write is_synthesized = true.
| Migration | Function | Rule |
|---|---|---|
| 20260726190000_stair_access_opening_and_landings | cut_stair_access_openings() | Trims a guard across the stair's clear width (IBC 1011.2, 44 in = 1.1176 m); adds landings (IBC 1011.6) |
| 20260726210000_synthesize_missing_stair_guarding | synthesize_missing_stair_guarding() | Guards flights with zero real railing within 3 m |
| 20260727020000_guard_unguarded_stair_flights | synthesize_unguarded_stair_flight_guarding() | Guards flights less than 30% guarded within 0.35 m |
| 20260727030000_synthesize_inner_well_handrails | synthesize_inner_well_handrails() | Inner-well handrails on switchback stairs (IBC 1014.9, 1014.6) |
| 20260727040000_stair_guard_per_end_inset | _stair_guard_trim() | Redefines the two above to inset per end rather than uniformly |
The five are order-dependent, not five independent rules run in any sequence:
mermaidflowchart TDA[20260726190000<br/>cut_stair_access_openings<br/>opens the clear width, adds landings] --> B[20260726210000<br/>synthesize_missing_stair_guarding<br/>guards flights with zero real railing]A --> C[20260727020000<br/>synthesize_unguarded_stair_flight_guarding<br/>guards partially-guarded flights]B --> D[20260727030000<br/>synthesize_inner_well_handrails<br/>switchback inner-well rails]C --> DB --> E[20260727040000<br/>_stair_guard_trim<br/>redefines B and C: per-end inset]C --> E
A stair's clear width must exist before anything can guard around it, so both guarding passes depend on the opening pass. The inner-well pass needs guarded flights to pair against. The final migration does not add geometry of its own; it replaces the trim logic inside the two guarding functions, so running it out of order redefines a rule before there is anything for that rule to apply to.
Trim, never delete
The originating defect was a guard running unbroken across a stair's own clear width, leaving no visible way on. An earlier pass "fixed" it by nulling nine railing rows' geometry outright, deleting real fall protection instead of creating an opening, and was reverted from backup.
The replacement rule: a guard must run along every edge of a stairwell void (IBC 1015, never delete) but must stop across the stair's clear width. Implemented as ST_Difference against a rectangle exactly as wide as the blocked width and centred on the stair. The pristine pre-cut polygon is preserved in geometry_uncut, so a future correction to the width formula can re-run from the true original rather than compounding.
The cut is stored, not derived at render time, for two reasons: MapLibre style layers cannot perform polygon boolean operations, so the cut must exist before geometry reaches the map style, and every other opening in this schema (door and window cuts) already works this way.
Thresholds
synthesize_missing_stair_guarding clusters flights per level with ST_ClusterDBSCAN, then uses a minimum clear width of 1.1176 m, a rail half-thickness of 0.035 m, a wall buffer of 0.4 m, and a landing de-duplication radius of 1.5 m. It skips any edge more than 50% wall-abutting within 0.4 m and insets rails 0.25 m from flight corners.
synthesize_unguarded_stair_flight_guarding insets each end by 12% of the edge, so an access end can never be closed by the guard meant to protect it. 20260727040000 refines this: the inset is applied per end and drops to zero (a full run) where that end abuts a landing or wall within 0.35 m.
synthesize_inner_well_handrails targets switchback flights paired with a partner flight 0.15-2.5 m away on the same level, and skips any flight already carrying a railing within 0.25 m.
Limitations
Guard height is not modelled. The added guarding is planar. Rows carry the compliance note IBC 1015 guards, min 42in height (height not modelled -- 2D floorplan), so the height requirement is recorded as metadata, not verified.
No occupant-load data exists anywhere in the schema, so IBC's occupancy-dependent provisions cannot be evaluated, only cited. The stair clear-width check therefore applies the stricter 1118 mm figure unconditionally; the 914 mm low-occupancy exception is documented but not applied.
No "open to above" signal exists. A campus-wide query of room_type for void or atrium vocabulary returned zero rows, so whether a stair is enclosed is inferred from the enclosing room's footprint per level, the only viable real signal.
The stair-access cut has been applied to one facility only (RQT-DCR), the only one with the real stair and railing geometry it needs.
Nothing here ever widens a door or moves a stair. A real building's dimensions are facts, not something to invent a fix for. The rules flag and record; they do not correct.
Several standards citations are marked UNVERIFIED in the ADR where a clause number could not be read against a primary source. Do not add a clause number without reading the real clause.