@ahrowe/ui 0.25.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/esm/common/floorPlan/floorPlan.faces.mjs +2 -0
  2. package/dist/esm/common/floorPlan/floorPlan.faces.mjs.map +1 -0
  3. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +2 -0
  4. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -0
  5. package/dist/esm/common/floorPlan/floorPlan.graph.mjs +2 -0
  6. package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -0
  7. package/dist/esm/common/floorPlan/floorPlan.hit.mjs +2 -0
  8. package/dist/esm/common/floorPlan/floorPlan.hit.mjs.map +1 -0
  9. package/dist/esm/common/floorPlan/floorPlan.json.mjs +2 -0
  10. package/dist/esm/common/floorPlan/floorPlan.json.mjs.map +1 -0
  11. package/dist/esm/common/floorPlan/floorPlan.mesh.mjs +2 -0
  12. package/dist/esm/common/floorPlan/floorPlan.mesh.mjs.map +1 -0
  13. package/dist/esm/common/floorPlan/floorPlan.path.mjs +2 -0
  14. package/dist/esm/common/floorPlan/floorPlan.path.mjs.map +1 -0
  15. package/dist/esm/common/floorPlan/floorPlan.plan.mjs +2 -0
  16. package/dist/esm/common/floorPlan/floorPlan.plan.mjs.map +1 -0
  17. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs +2 -0
  18. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs.map +1 -0
  19. package/dist/esm/common/floorPlan/floorPlan.snap.mjs +2 -0
  20. package/dist/esm/common/floorPlan/floorPlan.snap.mjs.map +1 -0
  21. package/dist/esm/common/floorPlan/floorPlan.triangulate.mjs +2 -0
  22. package/dist/esm/common/floorPlan/floorPlan.triangulate.mjs.map +1 -0
  23. package/dist/esm/common/floorPlan/floorPlan.types.mjs +2 -0
  24. package/dist/esm/common/floorPlan/floorPlan.types.mjs.map +1 -0
  25. package/dist/esm/common/floorPlan/legacySvg.mjs +2 -0
  26. package/dist/esm/common/floorPlan/legacySvg.mjs.map +1 -0
  27. package/dist/esm/common/planCanvas/planCanvas.mjs +2 -0
  28. package/dist/esm/common/planCanvas/planCanvas.mjs.map +1 -0
  29. package/dist/esm/common/planCanvas/planCanvas.module.mjs +2 -0
  30. package/dist/esm/common/planCanvas/planCanvas.module.mjs.map +1 -0
  31. package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
  32. package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
  33. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs +1 -1
  34. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
  35. package/dist/esm/common/roomDrawer/usePlanHistory.mjs +2 -0
  36. package/dist/esm/common/roomDrawer/usePlanHistory.mjs.map +1 -0
  37. package/dist/esm/common/roomViewer/roomViewer.mjs +1 -1
  38. package/dist/esm/common/roomViewer/roomViewer.mjs.map +1 -1
  39. package/dist/esm/common/roomViewer/roomViewer.module.mjs +1 -1
  40. package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
  41. package/dist/esm/index.mjs +1 -1
  42. package/dist/index.cjs +3 -7
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/style.css +1 -1
  45. package/dist/types/package/common/configProvider/configProvider.types.d.ts +4 -0
  46. package/dist/types/package/common/floorPlan/floorPlan.faces.d.ts +33 -0
  47. package/dist/types/package/common/floorPlan/floorPlan.fixtures.d.ts +35 -0
  48. package/dist/types/package/common/floorPlan/floorPlan.geometry.d.ts +111 -0
  49. package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +184 -0
  50. package/dist/types/package/common/floorPlan/floorPlan.hit.d.ts +26 -0
  51. package/dist/types/package/common/floorPlan/floorPlan.json.d.ts +23 -0
  52. package/dist/types/package/common/floorPlan/floorPlan.mesh.d.ts +36 -0
  53. package/dist/types/package/common/floorPlan/floorPlan.path.d.ts +59 -0
  54. package/dist/types/package/common/floorPlan/floorPlan.plan.d.ts +98 -0
  55. package/dist/types/package/common/floorPlan/floorPlan.rooms.d.ts +47 -0
  56. package/dist/types/package/common/floorPlan/floorPlan.snap.d.ts +101 -0
  57. package/dist/types/package/common/floorPlan/floorPlan.triangulate.d.ts +11 -0
  58. package/dist/types/package/common/floorPlan/floorPlan.types.d.ts +276 -0
  59. package/dist/types/package/common/floorPlan/index.d.ts +30 -0
  60. package/dist/types/package/common/floorPlan/legacySvg.d.ts +27 -0
  61. package/dist/types/package/common/planCanvas/index.d.ts +2 -0
  62. package/dist/types/package/common/planCanvas/planCanvas.d.ts +7 -0
  63. package/dist/types/package/common/planCanvas/planCanvas.types.d.ts +62 -0
  64. package/dist/types/package/common/roomDrawer/roomDrawer.d.ts +3 -3
  65. package/dist/types/package/common/roomDrawer/roomDrawer.types.d.ts +295 -15
  66. package/dist/types/package/common/roomDrawer/usePlanHistory.d.ts +24 -0
  67. package/dist/types/package/common/roomViewer/index.d.ts +1 -0
  68. package/dist/types/package/common/roomViewer/roomViewer.d.ts +9 -2
  69. package/dist/types/package/common/roomViewer/roomViewer.types.d.ts +88 -9
  70. package/dist/types/package/index.d.ts +1 -0
  71. package/docs/CLAUDE.md +1 -0
  72. package/docs/ConfigProvider.md +1 -0
  73. package/docs/FloorPlan.md +252 -0
  74. package/docs/RoomDrawer.md +344 -30
  75. package/docs/RoomViewer.md +80 -27
  76. package/package.json +3 -1
  77. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs +0 -6
  78. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs.map +0 -1
  79. package/dist/types/package/common/roomDrawer/roomDrawer.utils.d.ts +0 -32
  80. package/docs/room-drawing-analysis.md +0 -337
@@ -0,0 +1,252 @@
1
+ # FloorPlan
2
+
3
+ **When to use:** The shared data model behind `RoomDrawer` and `RoomViewer`. Reach for it directly when you need to build, inspect, validate or persist a floor plan in code rather than by drawing one — seeding a plan from a database, computing room areas, or generating a layout programmatically.
4
+
5
+ **Keywords:** blueprint, cad, grundriss, apartment, building, storey, partition, area calculation, planar graph, half-edge, extrusion
6
+
7
+ **Import:** `import { emptyFloorPlan, addWall, derivePlan } from '@ahrowe/ui'`
8
+ **Types:** `import type { FloorPlan, PlanFloor, PlanNode, PlanWall, PlanOpening, PlanRoom, DerivedRoom, PlanPoint, PlanView, PlanSelection } from '@ahrowe/ui'`
9
+
10
+ `PlanPoint`, `PlanView` and `PlanSelection` are the ones that also appear in `RoomDrawer` and `RoomViewer` props. Every exported function's own option and result types come with it — `WallAttrs`, `SplitWallResult`, `WallPathResult` and the rest.
11
+
12
+ ## The model in one paragraph
13
+
14
+ A plan is a **planar wall graph**. `PlanNode`s are junctions, `PlanWall`s are the edges between them, and a wall is **shared**: where two rooms meet there is one wall, not one per room. Rooms are not stored as shapes at all — they are the enclosed faces the walls happen to form, recomputed by `derivePlan`, with a `PlanRoom` carrying only the name and colour. A `PlanOpening` (door, window, archway, gap) belongs to a **wall**, so a door between two rooms is cut once and both rooms open through it.
15
+
16
+ **Every length is in millimetres.** Coordinates, wall thickness, storey height, opening offsets and widths. There is deliberately no per-plan scale factor: it would have to be threaded through every function, and one missed call site is a silent 1000x error. `unitSystem` affects display formatting only and is never read by geometry.
17
+
18
+ ```tsx
19
+ import { emptyFloorPlan, addRect, addOpening, derivePlan, OpeningKind } from '@ahrowe/ui';
20
+
21
+ const plan = emptyFloorPlan({ floorLabel: 'Ground floor' });
22
+ let floor = plan.floors[0];
23
+
24
+ // Two rooms flush against each other. The shared edge becomes ONE wall.
25
+ floor = addRect(floor, { x: 0, y: 0 }, { x: 3000, y: 3000 }).floor;
26
+ floor = addRect(floor, { x: 3000, y: 0 }, { x: 7000, y: 3000 }).floor;
27
+ Object.keys(floor.walls).length; // 7, not 8
28
+
29
+ // Rooms are derived, not drawn.
30
+ const { rooms } = derivePlan(floor);
31
+ rooms.length; // 2
32
+ rooms[0].area; // mm², holes already subtracted
33
+ rooms[0].outer; // the polygon, for rendering
34
+ ```
35
+
36
+ ## Enums
37
+
38
+ - `WallType`: `Exterior` | `Interior` | `Partition` | `Virtual` (a room divider with no physical wall: it still closes a face, but extrudes to nothing in 3D)
39
+ - `OpeningKind`: `Door` | `Window` | `Archway` | `Gap`
40
+ - `DoorSwing`: `None` | `StartLeft` | `StartRight` | `EndLeft` | `EndRight` | `Double` | `Sliding`
41
+ - `PlanUnitSystem`: `Metric` | `Imperial` (display only)
42
+ - `PlanIssueKind`: what `validatePlan` reports
43
+
44
+ ## Building a plan
45
+
46
+ Every operation is pure and returns `PlanEdit<T>` = `{ floor, result, issues }`, so results drop straight into React state.
47
+
48
+ | Function | Purpose |
49
+ |----------|---------|
50
+ | `addWall(floor, a, b, attrs?, tol?)` | Adds a wall, **splitting anything it crosses** so the graph stays planar |
51
+ | `addRect(floor, corner, opposite, attrs?, tol?)` | Four walls at once, welding onto anything already there |
52
+ | `splitWall(floor, wallId, at)` | Cuts a wall at a point or 0..1 parameter; redistributes its openings |
53
+ | `mergeNodes(floor, keepId, removeId)` | Welds two nodes, deduplicating the walls that collapse together |
54
+ | `dissolveNode(floor, nodeId)` | Rejoins two collinear same-construction walls; the inverse of `splitWall` |
55
+ | `detachWall(floor, wallId, delta)` | Pulls a wall off its shared corners and moves it by `delta`, bridging each old corner to the new one |
56
+ | `weldShortWalls(floor)` | Collapses every wall shorter than `MIN_WALL_LENGTH` by merging its two ends, so a squashed wall never leaves two corners stacked on one point |
57
+ | `removeWall(floor, wallId, opts?)` | Removes a wall, its openings, and any node left orphaned |
58
+ | `moveNode` / `transformPlan` / `scalePlan` | Move one node, translate everything, or rescale everything |
59
+ | `addOpening` / `updateOpening` / `removeOpening` | Openings, clamped to fit their wall |
60
+ | `validatePlan(floor)` | Every way the graph currently breaks the planar invariant |
61
+ | `cleanupPlan(floor)` | Removes what can safely be removed. Idempotent |
62
+
63
+ **`addWall` is the one that matters.** It resolves each endpoint (reusing a nearby node, splitting a wall it lands on, or creating one), then splits every wall it crosses and cuts itself at every node it passes through. That is what makes a second rectangle drawn flush against the first share the existing edge instead of laying a duplicate on top of it.
64
+
65
+ ```tsx
66
+ // Drawing across an existing wall splits both, leaving four walls and a new junction.
67
+ floor = addWall(floor, { x: 0, y: 0 }, { x: 4000, y: 0 }).floor;
68
+ floor = addWall(floor, { x: 2000, y: -2000 }, { x: 2000, y: 2000 }).floor;
69
+ Object.keys(floor.walls).length; // 4
70
+ validatePlan(floor); // []
71
+ ```
72
+
73
+ **Tolerances.** `addWall`'s `tol` is the welding distance in millimetres. Pass the value a user's pointer tolerance works out to in world units (see `resolveSnap` below); the default is exact-match only.
74
+
75
+ ## Deriving rooms
76
+
77
+ ```tsx
78
+ const { floor: refreshed, rooms, issues } = derivePlan(floor);
79
+ ```
80
+
81
+ `derivePlan` extracts the faces and attaches stored metadata. It is pure and **never creates or deletes a `PlanRoom`**, so it is safe to call on every render. Memoise it on `plan.revision`, which every mutation bumps.
82
+
83
+ `commitDerivation(floor)` mints a `PlanRoom` for each face that matched nothing — call it once per committed edit, not per render.
84
+
85
+ Each `DerivedRoom` carries the face (`outer`, `holes`, `area`, `perimeter`, `wallIds`), the matched `meta`, and an `anchor` guaranteed to be **inside** the polygon (an area centroid can fall outside a concave room, so it is computed, not averaged).
86
+
87
+ ### How a room keeps its name
88
+
89
+ Identity is primarily the **set of boundary wall ids**, with the anchor point as a secondary signal and area as a tie-break. That ordering is what makes it robust:
90
+
91
+ - **Reshape or drag the whole plan** — the wall set is unchanged, so it matches regardless of where the anchors ended up.
92
+ - **Split a room** — both children share half the old boundary, so the **anchor decides**: the child containing it keeps the name, the other becomes a new room. Predictable beats plausible.
93
+ - **Merge two rooms** — one wins on area (the larger), and **the loser is kept, not deleted**. It is reported as `PlanIssueKind.UnmatchedRoom`, and re-adding the wall restores it to the right child by itself. Call `pruneUnmatchedRooms` only when you actually want them gone.
94
+
95
+ Set `anchorPinned` on a `PlanRoom` to stop `derivePlan` moving its label.
96
+
97
+ ### Free-standing walls
98
+
99
+ A wall with a loose end does not create a room and does not break the one it sits in. The face walk goes out along it and straight back, so the room's fill, area and hit-testing are unaffected. Two consequences: the face polygon contains a zero-width slit (so it is not a *simple* polygon — run `stripSpurs` before triangulating a floor slab), and that wall's id appears **twice** in `wallIds`.
100
+
101
+ ## Rendering walls
102
+
103
+ `buildWallPaths(floor)` returns `{ groups, caps }` for a two-pass stroke. Both passes stroke the **same** path data: the outline pass at `thickness`, the fill pass at `thickness - 2 * outlineWidth`. The visible wall outline is not drawn at all, it is what is left of the first pass after the second covers its middle.
104
+
105
+ ```tsx
106
+ const { groups, caps } = buildWallPaths(floor);
107
+ const ow = 1 * pxToWorld; // outline width, screen-constant
108
+
109
+ <g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
110
+ {groups.map((g) => <path key={g.thickness} d={g.d} strokeWidth={g.thickness} stroke="var(--border-color)" />)}
111
+ </g>
112
+ <g fill="none" strokeLinecap="butt" strokeLinejoin="miter" strokeMiterlimit={4}>
113
+ {groups.map((g) => g.thickness - 2 * ow > 0 && (
114
+ <path key={g.thickness} d={g.d} strokeWidth={g.thickness - 2 * ow} stroke="var(--background-accent)" />
115
+ ))}
116
+ </g>
117
+ <g stroke="var(--border-color)" strokeWidth={ow}>
118
+ {caps.map((c, i) => <line key={i} x1={c.a.x} y1={c.a.y} x2={c.b.x} y2={c.b.y} />)}
119
+ </g>
120
+ ```
121
+
122
+ Rules that make this work:
123
+
124
+ - **Every outline path before every fill path.** One `<g>` each, never interleaved pairs.
125
+ - **`caps` must be drawn.** Nothing is ever drawn across a butt end, because both passes stop at the same coordinate, so opening jambs, free wall ends and thickness changes need explicit lines. Each cap says which (`reason: 'jamb' | 'freeEnd' | 'thicknessStep'`).
126
+ - **`outlineWidth` is screen-constant**, so recompute `thickness - 2 * ow` on zoom. The path data itself never changes. Skip the fill pass entirely when it would go non-positive (zoomed far out, walls become solid lines, which is correct at that scale).
127
+
128
+ - **`strokeLinejoin` and `strokeMiterlimit` must be set**, and set the same on both passes. Corners are filled by the join, not by extra geometry.
129
+
130
+ Walls are emitted as chains through corners so `stroke-linejoin: miter` fills them. Where a chain has to stop, at a junction or a thickness change, most corners still need nothing: both walls' bands start at the node, so across a gap of up to 180 degrees they already reach each other. The exception is a gap **wider** than 180 degrees, which happens when every wall at a node points into the same half-plane; there the two faces diverge and leave a wedge bitten out of the node, and a small hinged subpath is emitted to mitre it. At most one gap at a node can be reflex, so there is never more than one. Different thicknesses become separate groups, since one path can carry only one stroke width.
131
+
132
+ `buildWallPaths(floor, { wallIds })` builds the same paths for a subset of walls, taking node degrees from the whole floor so chains break and corners fill exactly where they do in the full drawing. That is what lets a room's boundary be highlighted by painting over the walls it is bounded by, openings and all.
133
+
134
+ `wallCentrelineSpans(floor, wall)` gives the stretches that are actually wall, in millimetres from node `a`, with openings removed and overlaps merged. `openingGeometry(floor, opening)` gives the centre, direction, normal and endpoints for drawing a door leaf, swing arc or window symbol.
135
+
136
+ ## Addressing a plan
137
+
138
+ `getFloor(plan, floorId?)` reads one floor, defaulting to the lowest. `updateFloor(plan, floorId, edit)` applies a floor-level edit and bumps `revision` for you; `setFloor` replaces one outright. `floorBounds(floor)` gives the bounding box, padded by half the thickest wall so the outer face of every wall is inside it, which is what a fit-to-view needs. `renameFloor`, `wallLength`, `floorIndex` and `DEFAULT_WALL_ATTRS` round out the small helpers.
139
+
140
+ ## Floors
141
+
142
+ `FloorPlan.floors` is a stack of storeys, **bottom first**: index 0 is the lowest, and `elevation` rises with the index.
143
+
144
+ ```ts
145
+ import { addFloor, duplicateFloor, removeFloor, moveFloor, setFloorHeight, floorBelow } from '@ahrowe/ui';
146
+
147
+ const { plan: two, floor } = addFloor(plan); // empty storey on top
148
+ const { plan: three } = duplicateFloor(two, floor.id); // copy of it, above it
149
+ const below = floorBelow(three, floor.id); // what an underlay traces
150
+ ```
151
+
152
+ Every one of these re-stacks the elevations behind you, so each storey sits on the one below: insert a floor and the ones above it rise, remove one and they drop, raise a storey's height with `setFloorHeight` and everything over it lifts, and the building's own ground level stays where it was. This is also why a storey's height is not set with a plain `setFloor`, which would leave the floors above buried in it or floating over it — neither of which a 2D plan would show. Without that, a gap or an overlap only turns up in 3D, long after the edit that caused it.
153
+
154
+ `duplicateFloor` regenerates **every** id and rewires every reference — walls to the copy's nodes, openings to its walls, rooms to its wall ids — so the two storeys share nothing and an edit to one cannot reach into the other. A room's `wallIds` in particular must point at the copy's own walls, or the first `derivePlan` would strand every room in it.
155
+
156
+ `setFloorHeight` clamps to `MIN_FLOOR_HEIGHT` (500mm), low enough for a crawl space and high enough to stop a zero-height storey. `removeFloor` refuses to remove the last storey: a plan with nowhere to draw would need a special case in every consumer.
157
+
158
+ ## Saving and loading
159
+
160
+ JSON is the interchange format. `planToJson` writes it; `planFromJson` reads it back, and does so defensively, because a file off a disk is untrusted and whatever comes out is rendered immediately.
161
+
162
+ ```ts
163
+ import { planToJson, planFromJson } from '@ahrowe/ui';
164
+
165
+ const text = planToJson(plan); // indented; pass { pretty: false } for one line
166
+ const { plan: loaded, issues } = planFromJson(text);
167
+ if (!loaded) console.error(issues); // unreadable
168
+ else if (issues.length) console.warn(issues); // loaded, but repaired
169
+ ```
170
+
171
+ `planFromJson` rebuilds the plan field by field rather than casting the parsed object, so no unchecked field reaches a renderer. It repairs wherever the repair is unambiguous — a numeric string becomes a number, an unknown enum value falls back, a wall with missing corners or an opening with no wall is dropped — and returns a `null` plan only when the text is not JSON, is not an object, has no `floors`, or carries a `version` other than `PLAN_FORMAT_VERSION`. A room whose walls have gone is kept rather than dropped: re-adding the wall re-matches it by name.
172
+
173
+ `revision` is bumped on load, since `derivePlan` memoises on it and the repaired plan is not the one the file described.
174
+
175
+ ## Snapping and pointer input
176
+
177
+ ```tsx
178
+ const world = clientToWorld(e.clientX, e.clientY, svg.getBoundingClientRect(), view);
179
+ const snap = resolveSnap(world, { floor, pxToWorld: 1 / view.zoom, origin, options });
180
+ ```
181
+
182
+ `clientToWorld` **only converts** — snapping is `resolveSnap`'s job alone, which is what makes "hold Alt to disable snapping" possible at all.
183
+
184
+ Every tolerance in `SnapOptions` is in **screen pixels**, converted through `pxToWorld` at the point of use. A fixed world-unit tolerance covers less and less of the screen as you zoom in until it stops working.
185
+
186
+ `resolveSnap` runs in two stages, because half of these are constraints rather than points. A hard snap (`Node`, `Intersection`, `Wall`) fixes both degrees of freedom, so the first match wins. A constraint (`Angle`, `Extension`, `Alignment`) removes only one: two of them intersect, one is projected onto and then slid **along** to the nearest grid position. That last detail is what lets angle lock and grid snap coexist — you get exactly 45° at a round length, rather than a point that is neither.
187
+
188
+ Pass `options.enabled: []` to disable snapping entirely.
189
+
190
+ ## Validation
191
+
192
+ ```tsx
193
+ const issues = validatePlan(floor); // O(walls²) — development and tests, not per keystroke
194
+ ```
195
+
196
+ The invariant that matters: **no two walls may cross without a node at the intersection, and no node may sit on a wall's interior without that wall being split there.** Break either and the face walk silently merges two rooms into one figure-eight face with no error anywhere, which is why `UnsplitCrossing` and `UnsplitNodeOnWall` exist as explicit issue kinds. Everything built through `addWall` maintains this automatically.
197
+
198
+ ## Importing older plans
199
+
200
+ ```tsx
201
+ import { planFromLegacySvg } from '@ahrowe/ui';
202
+
203
+ // The old canvas was unitless; pick the factor that makes it millimetres.
204
+ // A 400-unit-wide export meant to be a 12m house wants scale: 30.
205
+ const plan = planFromLegacySvg(svgText, { scale: 30 });
206
+ ```
207
+
208
+ Feeding every legacy polygon edge through `addWall` is what collapses the old duplicated walls: the second room's copy of a shared edge resolves to the same two nodes and is recognised as already walled. Room names carry across, and doors are recovered where the old swing-arc path is recognisable; anything that is not is skipped rather than guessed at.
209
+
210
+ ## Ready for 3D
211
+
212
+ `buildPlanMesh(floor, options?)` turns a storey into triangles: wall solids with their openings cut out, and a slab under each room.
213
+
214
+ ```ts
215
+ import { buildPlanMesh } from '@ahrowe/ui';
216
+
217
+ const mesh = buildPlanMesh(plan.floors[0]);
218
+ // { positions: Float32Array, normals: Float32Array, indices: Uint32Array, groups }
219
+
220
+ // three.js, for example — no adapter needed
221
+ const g = new THREE.BufferGeometry();
222
+ g.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
223
+ g.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3));
224
+ g.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
225
+ ```
226
+
227
+ There is no renderer and no dependency: plain arrays are what every engine takes, so this feeds three.js, Babylon, a glTF writer or your own rasteriser without the package having an opinion. Call it per storey and concatenate for a whole building; each floor already knows its own `elevation`.
228
+
229
+ **Axes.** Plan x becomes world x, plan y becomes world **z**, and world y is up. A plan is drawn in SVG's y-down space, so that mapping is a handedness flip — faces are wound here to suit, rather than inheriting the 2D rings' winding.
230
+
231
+ **Openings are cut by splitting the wall, not by subtracting solids.** Every opening is an axis-aligned rectangle in its wall's own plane, so the wall falls into at most three prisms: under the sill, over the head, and the full-height stretches between openings. Exact, and no CSG library. A window leaves its upstand and lintel behind, which is what `sill` and `head` are carried for.
232
+
233
+ **Storeys stack wall to wall.** `elevation` is the *finished floor level*, so a slab hangs below it and the walls start at the slab's underside — they clothe the floor plate the way a facade does, rather than stopping at it. A wall stays exactly `floor.height` tall, one storey's walls end precisely where the next storey's begin, and the slab sits inside them instead of showing as a band between the storeys. Build the walls from the level itself and their top face ends up coplanar with the slab above, which z-fights.
234
+
235
+ A wall also drops a millimetre past the slab rather than landing flush on its underside. That is not arbitrary: a wall overlaps the slab it stands over, because the slab reaches the wall centrelines and the wall straddles them, so sharing a plane would leave two downward faces fighting for the same pixels and speckle the whole underside of the building. Wall heights and floor-to-floor spacing are unaffected.
236
+
237
+ `groups` marks which triangles are wall and which are floor, so the two can take different materials in one draw.
238
+
239
+ | Option | Default | |
240
+ |---|---|---|
241
+ | `wallHeight` | the floor's `height` | For walls with no `height` of their own |
242
+ | `includeFloorSlab` | `true` | Lay a slab under each room |
243
+ | `slabThickness` | `200` | Millimetres, hanging below floor level |
244
+ | `applyElevation` | `true` | Lift by the floor's own elevation, so storeys stack |
245
+
246
+ **Corners are mitred.** Two walls meeting at a corner both stop at the shared node, so left square they leave a gap of half a thickness on the outside of the turn — a notch cut through the full height of the wall. Their footprints are extended to where their edges actually cross instead, which is the same join the 2D renderer makes.
247
+
248
+ An end stays square where there is no single corner to mitre to: a junction of three or more walls, a change of thickness across the node, an angle acute enough to send the mitre running away, and the reveals of an opening, which are square faces in their own right. Walls overlap slightly at those joints, which is invisible on an opaque solid but would show in a cutaway or through glass, and would double-count in a volume taken straight off the triangles.
249
+
250
+ These are the fields that do nothing in 2D, and that is expected rather than a gap: a plan is a horizontal cut, so a window 900mm up the wall and a door at floor level are the same hole seen from above. `sill` is the height of an opening's bottom above the floor and `head` the height of its top; the drawer edits `sill` in its selection bar so the value is captured while the plan is being drawn, rather than having to be filled in for every opening afterwards.
251
+
252
+ One thing to know when you build one: `PlanFace.outer` is positively wound under the shoelace formula, which in SVG's y-down space reads as **visually clockwise**. Anything assuming counter-clockwise (slab normals, most triangulators) must flip it, and should run `stripSpurs` first so a free-standing wall's zero-width slit does not produce degenerate triangles.