From 2815dddb05f07cd2bd790d9c5fa8c40103d3b28e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 21 Jul 2026 12:16:43 -0400 Subject: [PATCH] docs(wiki): vertical-model architecture page; drop stale SlabSystem rows Documents the shipped vertical building model (#526): stored truth table, resolution helpers, clamp rules, pointer-decided placement, the load migration that lives in migrateNodes indefinitely, and the gotchas. The systems tables stop listing the deleted SlabSystem and point at the registry geometry path instead. Co-Authored-By: Claude Fable 5 --- apps/editor/README.md | 1 - wiki/architecture/README.md | 1 + wiki/architecture/systems.md | 3 +- wiki/architecture/vertical-model.md | 81 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 wiki/architecture/vertical-model.md diff --git a/apps/editor/README.md b/apps/editor/README.md index ea6e606614..1329ad6609 100644 --- a/apps/editor/README.md +++ b/apps/editor/README.md @@ -183,7 +183,6 @@ Systems are React components that run in the render loop (`useFrame`) to update | System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | -| `SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 8680b786e4..4b9bb1ce94 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -21,6 +21,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [spatial-queries](spatial-queries.md) | Placement validation (`canPlaceOnFloor`/`Wall`/`Ceiling`) for tools | | [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` | +| [vertical-model](vertical-model.md) | Stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, clamp rules, and the load migration | | [events](events.md) | Typed event bus — emitting and listening to node and grid events | | [creating-rules](creating-rules.md) | How to add or update a page in this folder | diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index c90500bbd9..87ae82c873 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -17,13 +17,14 @@ Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, | System | Responsibility | |---|---| | `WallSystem` | Wall mitering, corner joints | -| `SlabSystem` | Polygon-based floor/roof generation | | `CeilingSystem` | Polygon-based ceiling generation | | `RoofSystem` | Pitched roof shape | | `DoorSystem` | Placement constraints on walls | | `WindowSystem` | Placement constraints on walls | | `ItemSystem` | Item transforms, collision | +Slab geometry has no dedicated system: it renders through the registry `def.geometry` (`packages/nodes/src/slab/geometry.ts`, calling the pure generators in `packages/viewer/src/systems/slab/slab-system.tsx`) with a small `def.system` for dirty tracking. + ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md new file mode 100644 index 0000000000..757aa7760c --- /dev/null +++ b/wiki/architecture/vertical-model.md @@ -0,0 +1,81 @@ +# Vertical Model + +*How buildings stack: stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, and the clamp rules that keep it all coherent.* + +Applies to: anything that reads or writes vertical geometry — levels, walls, slabs, ceilings, stairs, fences, floor-placed items. + +The invariant, in one sentence: + +> Wall tops are pinned to the level plane; floors and platforms move what stands on +> them, never the walls or the storey above; anything that doesn't fit is clamped, +> never asked. + +**Sources**: `packages/core/src/services/storey.ts`, `packages/core/src/systems/wall/wall-top.ts`, `packages/core/src/systems/slab/slab-support.ts`, `packages/core/src/systems/stair/stair-rise.ts`, `packages/core/src/store/use-scene.ts` (migration Pass 3) + +## Stored truth + +| Field | Meaning | Absent means | +|---|---|---| +| `level.height` | Storey height in meters, floor-to-floor. Level world Y = per-building prefix sum of stored heights, ordered by the `level` ordinal (`getLevelElevations`). | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). | +| `wall.height` | Explicit custom height (half wall, parapet). Top = elected base + height. | **Plane-bound** (the default): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. Slabs lift only the base. | +| `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | +| `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | +| `slab.thickness` | Grows **downward**: the solid occupies `[elevation − thickness, elevation]`. | Default 0.05. | +| `slab.recessed` | Pool intent: open shell, floor at (negative) `elevation`, inner walls up to the plane. Excluded from "covering" queries and wall-face adoption. | Solid slab. | +| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `stair.deckSlabId` | Destination deck: rise follows `deck.elevation − the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | +| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | + +Two schema rules protect these semantics: + +- **No Zod defaults on meaning-bearing fields.** `level.height`, `wall.height`, `ceiling.height`, `stair.totalRise` are `.optional()` with no `.default()` — absence is data. Creation sites write values explicitly; `migrateNodes` output is cast, not parsed, so a schema default would never materialize on legacy load anyway. +- **The store deletes explicit-`undefined` keys.** `updateNode(id, { height: undefined })` removes the key (see `mergeNodeUpdate` in `node-actions.ts`); that is how "Follows level/deck" mode switches work. UI mode controls derive state from field presence — no persisted mode enums. + +## Resolution helpers (use these, never `?? 2.5`) + +| Helper | Home | Resolves | +|---|---|---| +| `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, per-building stacking, neighbors | +| `getWallPlaneTop` | `services/storey.ts` | A plane-bound wall's top: level height clamped to covering-slab undersides, span-sampled with boundary-inclusive band overlap | +| `resolveWallTop`, `resolveWallEffectiveHeight`, `MIN_WALL_HEIGHT` | `systems/wall/wall-top.ts` | A wall's top / effective height given plane + elected base | +| `getWallEffectiveHeightForNodes` | spatial-grid manager | The above with the real slab election, for UI overlays | +| `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | +| `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | +| `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | +| `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | +| `clampSlabElevationForWalls`, `applySlabTopChange`, `SLAB_UNSTICK_THRESHOLD` | slab-support + `nodes/slab/elevation-limit.ts` | Slab edit clamps and the adaptive drag/panel rules | + +## Clamp rules (clamp, never ask) + +- A slab under plane-bound walls clamps its elevation to `level height − MIN_WALL_HEIGHT` (0.5). +- Ceilings clamp (at write time, and reactively downward via space-detection) to `min(level top, covering-slab underside) − 0.01`. +- Plane-bound wall tops clamp to covering-slab undersides — a thick or flush upper-level slab shortens the walls below it (Revit's attach-to-floor-bottom, automatic). Explicit-height walls are exempt. +- Slab vertical editing is adaptive: the panel moves placement (thickness untouched); the viewport drag stretches a grounded slab (elevation and thickness together) up to `SLAB_UNSTICK_THRESHOLD` (0.4), then unsticks it into a 0.05-thick deck; floating decks move with thickness preserved and re-ground at underside 0; pools keep the drag-through-zero gesture. +- Wall-face adoption in `getRenderableSlabPolygon` applies only to grounded slabs (`elevation − thickness ≤ 0.01`, not recessed) — floating decks keep their drawn polygon and are skipped as seam candidates. + +## Pointer-decided placement + +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest slab plane the ray crosses inside its rendered polygon plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck; commits persist the capped winner (or `'ground'`). 2D floorplan placement has no camera ray and keeps max-election. + +## Load migration (lives in `migrateNodes` Pass 3, indefinitely) + +Because community autosave only persists after the first post-load edit, the migration must remain in `migrateNodes`: + +- Writes each legacy level's **exact** derived height (a default legacy storey stores 2.55 = 0.05 slab + 2.5 wall) — never snapped to presets. +- Compacts `level` ordinals per building, anchored at zero (non-negatives → 0,1,2…; negatives → −1,−2… — basements stay basements). Runs every load; idempotent. +- Classifies wall tops against the derived plane: `|plane − top| < 0.20` **strict** → plane-bound (height key removed); else explicit (materializing 2.5 on absent-height short walls). ε calibrated by a prod census: intentional 0.20-short walls exist and must not snap. +- Ceilings within ε of the bound (and all `autoFromWalls` ceilings) drop their height → follows mode; stairs drop the legacy blind `totalRise: 2.5`. Both gated on the scene being legacy (some level lacked `height`). +- Slabs get `thickness := elevation` (byte-identical occupied interval, including degenerate zero); negative-elevation pools become `recessed: true` with elevation unchanged. + +## Gotchas + +- **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. +- **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. +- **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). +- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties the level below's walls/ceilings and deck-attached stairs (`spatial-grid-sync.ts`). If a new consumer reads these bounds, wire its dirty rule there. +- **Host lifecycle.** Deleting a slab strips `supportSlabId`/`deckSlabId` from survivors in the same undo commit; a host merely reshaped away falls back silently and resumes if the slab returns. +- **Clone paths differ.** `clone-scene-graph.ts` remaps `supportSlabId`/`deckSlabId`; the editor clipboard (`scene-clipboard.ts`) intentionally does not (it re-elects); room placement remaps them (fixed in the private repo's `room-placement.ts`). When adding a new clone/instantiation path, remap both fields. + +## Deferred by decision (see the private repo's plan archive) + +Persistent Room identity, partial-storey navigation, slab reference-face enums, suspended ceilings, and a site datum for sloped terrain all have named gates in `plans/` — none block this model. Decks ship as catalog rooms/presets; the one-gesture mezzanine/balcony tools were removed (code preserved at editor `e30042db`).