Skip to content

feat: vertical building model — stored level heights, wall inversion, decks#526

Merged
wass08 merged 25 commits into
mainfrom
feat/vertical-building-model
Jul 21, 2026
Merged

feat: vertical building model — stored level heights, wall inversion, decks#526
wass08 merged 25 commits into
mainfrom
feat/vertical-building-model

Conversation

@wass08

@wass08 wass08 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements the vertical building model kernel decided in the private repo's plans/editor-vertical-building-model-research.md, plus five rounds of hands-on refinement. 24 commits, reviewable stage by stage:

Stage 1 — stored level heights + wall-top inversion. LevelNode.height becomes the only vertical truth (per-building prefix sums; six divergent level-height derivations deleted, including MCP metadata.height and the first-person copy). Default walls have no height: their top is pinned to the level plane (resolveWallTop), so slabs lift only the base and top-of-level holes are impossible by construction. wall.height survives as the explicit exception with a "Follows level / Custom height" panel control derived purely from field presence. Load migration writes exact derived heights (never preset-snapped), compacts ordinals per building anchored at zero (basements stay basements), and classifies wall tops against the plane at ε = 0.20 strict < (calibrated by a 4,000-project prod census). Level rows get a height badge + presets popover (unit-aware: 8/9/10 ft imperial).

Stage 2 — persisted support hosts. Floor-placed nodes and walls remember which slab supports them (supportSlabId, written at commit only when overlapping supports disagree); elections prefer a still-valid host and silently re-elect on loss; deletion strips references undo-safely. Item support unified onto rendered slab polygons with a per-level cache.

Stage 3 — slab placement/thickness split. elevation stays the walking surface; new thickness grows downward (solid occupies [e−t, e]); migration is byte-identical for every legacy slab; pools become explicit recessed intent keeping the drag-through-zero gesture. Ceilings mirror walls (optional height = follows the level top live, minus covering-slab intrusion) and clamp to covering-slab undersides via the first cross-level query; plane-bound wall tops clamp to those undersides too (Revit's attach-to-floor-bottom, automatic). Wall-face adoption is gated to grounded slabs so decks keep their drawn polygon. Adaptive vertical editing: panel = placement; viewport drag stretches to 0.4 m then unsticks into a thin deck.

Stage 4 — decks as product surface. Stairs attach to decks (deckSlabId: rise follows the deck live, flights re-converge via a write-sync, flush landing accounting for the stair's own elected base, cutout auto-disabled). Fences host on decks and walls/fences draw on them (pointer-decided placement surface — the same ray-derived cap that fixed under-deck and stacked-slab placement). The one-gesture mezzanine/balcony tools were built, then removed in favor of catalog presets (code preserved at e30042d); the kernel they validated stays. A compact multi-selection panel ("N selected" + kind breakdown + Duplicate/Delete) with a multiSelectionFooter host slot supports the community app's save-selection-as-room flow.

Also fixes latent bugs surfaced along the way: double stair per validation click (node-click + native grid:click double dispatch), the dead mode:'build'+tool:null selection lock (stair + column tools), and the grid-plane feedback loops behind placement flicker.

How to test

  1. bun dev, open a scene with a multi-level building — it loads through the migration: level rows show real stored heights (a legacy default storey reads 2.55 m), no holes at the top of any level.
  2. Click a level's height badge → set 3.0 m: everything above moves, walls fill to the new plane, stairs re-converge, ceilings follow — no manual fixes.
  3. Select a wall → Top: "Follows level" ↔ "Custom height"; drag its top arrow (converts to custom); slab edits under it shorten it, never raise it.
  4. Draw a slab, drag its top arrow: it stretches (step feel) until ~0.4 m, then pops thin and floats as a deck. Draw walls and fences on the deck top; place items under and on it (pointer decides the surface — no flicker, no hopping between stacked slabs).
  5. Add a stair, set its To destination to the deck: rise follows the deck (move the deck, the stair tracks flush), cutout controls hide.
  6. On an upper level, give a slab elevation 0 + thickness 0.5: the walls and ceiling below shorten to its underside instead of clipping.
  7. Multi-select the deck + stair + fences: the compact "N selected" panel docks right with Duplicate/Delete (the community app injects "Save to my catalog" below).
  8. bun test — full suite; migration/census/clamp/election behaviors are unit-covered (≈250 files).

Screenshots / screen recording

N/A here — verified against real scenes via headless capture during development; happy to attach a clip on request. The lab explainer at the community app's /lab/vertical-building-model visualizes the model this implements.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

🤖 Generated with Claude Code


Note

High Risk
Large changes to vertical placement, wall/slab/ceiling height resolution, and scene sync affect core editor behavior and persisted node fields across many types; regressions could show as wrong elevations, clipping, or stale geometry until dirty paths run.

Overview
Adds pointer-decided slab support so placement and drag use a ray-derived walking surface (getPointedSupportSurface) and an optional maxElevation cap, fixing stacked deck/floor flicker and election at stable plan XZ instead of event-plane hits.

Introduces supportSlabId commit/read paths for floor-placed nodes, walls, and fences (resolveSupportSlabPatch, resolveWallSupportSlabPatch, resolveFenceSupportSlabPatch), plus GROUND_SUPPORT_ID when a capped commit chooses level base under an overlapping deck. Item/wall support now uses rendered slab polygons with a per-level cache invalidated on slab/wall changes.

Floating decks keep their drawn footprint (no wall-band adoption); grounded/recessed room floors still seam and adopt. Spatial-grid sync marks dependents when level height, covering slabs above/below, deck elevation, or wall geometry changes.

Auto ceilings are created height-less (follow level top); manual ceilings clamp down under covering-slab undersides from the level above, with reactive re-clamp in space-detection sync. Schema adds thickness / recessed on slabs, optional LevelNode.height and StairNode.deckSlabId, and documents supportSlabId across floor-placed kinds. Wall/slab overlap logic moves to systems/slab/slab-support with electedSlabId and pointer caps on wall election.

Reviewed by Cursor Bugbot for commit a359ecd. Bugbot is set up for automated code reviews on this repo. Configure here.

wass08 and others added 24 commits July 20, 2026 11:17
…le + level height schema

Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free
packages/core/src/systems/slab/slab-support.ts (severs level-height ->
spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure
mesh-free equivalent of the viewer's stacked level height, and add the
optional LevelNode.height field plus the storey service (getStoredLevelHeight,
getLevelElevations per-building prefix sums). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each floating-level-selector row shows its storey height; clicking opens a
popover with 2.5/3.0/3.5 presets and a free slider writing level.height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ompaction, wall-top classification

Pass 3 in migrateNodes: derive and store each legacy level's exact stacked
height (never snapped), compact ordinals per building anchored at zero so
basements stay basements, classify wall tops against the derived plane
(|plane - top| < 0.20 strictly -> plane-bound, else explicit height
materialized), and drop the blind totalRise 2.5 stair default on legacy
scenes only. Epsilon and strictness validated by a prod census.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly vertical truth

Wall-top inversion: a wall without an explicit height now tops out at its
storey plane (resolveWallTop); slabs lift only the base. Window/door caps
resolve the real top through the same slab election instead of Infinity.
All level stacking (viewer, elevator, first-person, stair openings, MCP
scene queries) reads stored LevelNode.height; the four divergent live
derivations and level.metadata.height are deleted. Stair totalRise becomes
optional and derives from the storey height when absent. MCP create-level
stops writing its elevation param into the ordinal. Level creation sites
write explicit heights; templates carry their true derived heights.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fallbacks; wall Top control

Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound
walls elect the slab (pure clampSlabElevationForWalls + registry handle
bounds + shared panels for 2D/3D parity); ceiling heights clamp under the
plane and auto-ceilings derive from resolved wall tops. Every remaining
wall.height ?? 2.5 fallback resolves through resolveWallTop /
resolveWallEffectiveHeight (panels, overlays, measurements, quantities,
spatial grid, MCP reports); template walls matching their storey become
plane-bound. Wall panel gains a Top control (Follows storey / Custom
height) derived purely from height presence; the store update path now
deletes keys passed as explicit undefined so plane-binding round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n, rendered-polygon unification

Floor-placed nodes and walls gain a nullable supportSlabId. Elections
surface the winning slab (getSlabSupportForItem, candidates query) and
prefer a still-valid persisted host, falling back silently when the host
is gone or reshaped away; deleting the host strips references in the same
undo commit. Item-side support now tests the rendered slab polygon (like
walls) through a per-level cache invalidated by the spatial-grid sync.
Also adds the resolveStairTotalRise unit tests from the stage-1 gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erywhere

Floor-placed commits (draft pipeline, per-kind creation tools, registry
move tool) and wall create/move/endpoint commits persist supportSlabId
via shared resolveSupportSlabPatch helpers — only when overlapping
supports disagree on elevation, clearing it otherwise or when the node
leaves the floor. WallSlabSupport surfaces electedSlabId; every wall
support read site passes wall.supportSlabId as the preferred host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cess intent

slab.elevation stays the walking surface; new thickness grows downward so
the solid occupies [elevation − thickness, elevation]. Migration writes
thickness := elevation for solids (byte-identical intervals, including
degenerate zero) and recessed: true for legacy negative pools. Geometry
branches on recessed instead of the elevation sign; presets keep today's
intervals; free elevation edits move the body without coupling thickness
(the deck semantic). Dead viewer SlabSystem component deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getLevelAbove + getCoveringSlabUndersideAt give the first cross-level
query; ceiling writes clamp to min(storey plane, lowest covering
underside) − 0.01, and the space-detection reconcile now clamps manual
ceilings down (never up) when a deck above intrudes — a flush deck
reactively lowers the ceiling below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One-gesture composites over the kernel: draw a deck footprint and commit
deck slab + railings + stair (mezzanine) or deck + railings (balcony) in
a single undo step. Fences gain supportSlabId and lift onto their host
deck; railing runs split around the stair mouth; edges near wall
centerlines are treated as closed. Stairs target the deck via explicit
totalRise with no level-to-level opening sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dragging a grounded slab's top stretches it (elevation and thickness move
together — gaps impossible); floating decks move with thickness preserved
and land grounded at zero; pools keep the drag-through-zero gesture.
User-facing copy says level, not storey (Follows level, Level height).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rtical reactivity

getWallPlaneTop samples the wall span against the level above's slabs, so
a thick or flush upper floor shortens the walls below it instead of
colliding (automatic attach, no dialog). Level-height edits now dirty the
level's walls, stairs, ceilings, and fences; covering-slab changes dirty
the level below. The effective-height helper triplicated across editor
overlays moved to core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ceiling.height becomes optional — absent means the ceiling tracks
min(level top, covering slab underside) − 0.01 live, so level-height
edits no longer require ceiling fixes. Ceiling panel gains the same
Follows level / Custom height control as walls; auto-from-walls ceilings
are created height-less and their height-derivation machinery is deleted;
migration drops stored heights within 0.20 of the bound on legacy scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Auto slabs derive their polygon from wall centerlines, so covering-clamp
samples sat exactly on the boundary where ray-cast point-in-polygon is
side-dependent (min edges in, max edges out) — walls clamped or not by
orientation. getWallPlaneTop now clips the wall's thickness band against
the covering polygon (boundary-inclusive, arc-aware) and the ceiling
bound's point sampling gained an explicit on-boundary test. Verified
against the reported repro scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stairs gain deckSlabId — rise follows the deck's elevation live (straight
flights re-converge via a write-sync mirroring auto-openings), the panel
shows a unified To destination with Follows deck / Custom rise, and the
mezzanine tool attaches instead of baking a stale rise. Item placement
under an elevated deck no longer flickers: grid events fed a feedback
loop (the grid plane rode the ghost's elected height), so the support
election is now capped at the surface the pointer ray actually hits,
with a ground sentinel keeping under-deck commits deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Decks ship as catalog presets instead; the kernel the presets rely on
(fence deck-hosting, stair deck attachment, clone remaps, pointer-decided
placement) all stays. The tool code lives at e30042d for reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…es then unsticks

Floating decks keep their drawn polygon (and stop being seam candidates
for grounded neighbors) instead of growing into nearby walls. Panel
elevation edits are pure placement; the viewport drag stretches a
grounded slab up to 0.4 m then unsticks it into a thin deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rise now subtracts the stair's own elected base (same election the
visual lift uses), so base + rise always equals the deck walking surface;
the auto-sync defers a microtask so it reads a settled spatial grid and
re-converges on both deck and base-slab moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wall and fence draw tools now publish the pointed surface, so the draw
plane rides the deck top (no more perspective-skewed floor hits) and
previews sit on the deck. Fences gain real support election: a pure
resolveFenceSupportSlabPatch persists the deck host at draw and reshape
commits; wall commits thread the pointer cap so aiming under a deck
elects the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Level, ceiling, and slab preset buttons show clean values per display
system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead
of converted metric labels; metric presets unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s the cutout

syncStairRises now converges every follows-mode straight stair (level or
deck) plus deck-attached custom rises — detaching a stair from a deck
re-derives its height, and ordinary stairs finally track level-height
changes. Attaching via the panel writes slabOpeningMode none and hides
the cutout controls; detaching restores the destination cutout and
clears the stale explicit rise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hop was hysteresis: the election consumed the riding grid plane's
perspective-skewed hit, giving two self-consistent fixed points for one
pointer ray. getPointedSupportSurface now returns the ray's crossing of
the pointed surface and both the support cap and the cursor XZ derive
from that single computation, so items stay on the surface the pointer
aims at and sit exactly under the cursor across storeys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stair tool subscribed to both node clicks (synthesized on pointerup)
and the native-click grid event with none of the guards sibling tools
carry — one physical click over any node surface committed twice. A
commit gate + follow-up click swallow fix the double dispatch, and the
stair and column tools now restore select mode on exit instead of
leaving the dead build-mode-without-tool state that ignored every click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Selecting multiple nodes now docks the collapsed-by-default panel on the
right: N selected header, kind breakdown, and Duplicate/Delete mirroring
the floating pill. A new multiSelectionFooter slot lets the host app dock
actions below it, exactly like inspectorFooter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a6bb29f. Configure here.


return {
supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walls miss ground host under decks

High Severity

resolveWallSupportSlabPatch doesn't persist GROUND_SUPPORT_ID when a pointer-capped election targets the ground beneath an overlapping deck. This causes walls to incorrectly lift onto the deck in subsequent uncapped elections. Note that computeWallSlabSupport also lacks a read path for GROUND_SUPPORT_ID.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a6bb29f. Configure here.

Comment thread packages/core/src/lib/space-detection.ts
…l-building-model

use-draft-node conflict resolved by combining both sides: the branch's
supportSlabId commit patches and elevation cap keep their structure, and
main's placement-preview set/clear calls land at their intended points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wass08
wass08 merged commit bf89b5b into main Jul 21, 2026
3 checks passed
@wass08
wass08 deleted the feat/vertical-building-model branch July 21, 2026 16:13
wass08 added a commit that referenced this pull request Jul 21, 2026
…ows (#528)

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 <noreply@anthropic.com>
wass08 added a commit that referenced this pull request Jul 21, 2026
…anel

The wall-panel lint error predates this branch (#526); fixed here to unblock
the quality gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wass08 added a commit that referenced this pull request Jul 21, 2026
* feat(walkthrough): unify first-person and baked walkthrough UI into a shared HUD

The builder first-person overlay (crosshair, Exit Street View button, hints
card) and the baked-GLB walkthrough HUD were two divergent UIs. Extract the
GLB-style HUD (reticle, floor/room labels, Esc pill, interact prompt) into a
shared WalkthroughHud in packages/editor, feed it from FirstPersonControls via
a small useFirstPersonHud store (interact target each frame, floor/zone labels
sampled from the camera), and align FOV/projection handling with the baked
controller. The now-unused WalkthroughControls glide controller is removed
from packages/viewer (WALKTHROUGH_FOV moves to the GLB controller module).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(viewer-ui): shared ViewerControlsBar + ViewerSceneHeader for preview and embedders

Preview mode's ViewerOverlay was an older copy of the community viewer UI
(separate scan/guide/camera buttons, own render/theme/edges menus, 4-state
wall mode). Extract the community design into shared prop-driven components:
ViewerControlsBar (visibility, level/wall modes, display menu, walkthrough,
orbit/top view) and ViewerSceneHeader (back, project info, optional stats
slot, breadcrumb, levels card). ViewerOverlay is now a thin composition of
them. The display menu gains the edges submenu everywhere; the vestigial
translucent wall mode is dropped (a stale value renders as cutaway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(walkthrough): hold-Ctrl crouch + P screenshot pause in both controllers

Crouch swaps the capsule for a short one (shrinks around the centre, so a
mid-jump crouch lowers the head and raises the feet — enough to thread window
openings), lowers the eye with a short lerp, and slows movement; standing back
up is gated on headroom via an upward raycast against the collider world.
Tuning constants live in the GLB controller module and are shared with the
editor first-person controller.

P releases the pointer lock without leaving the walkthrough so the cursor is
free for an OS screenshot (macOS region capture needs a movable pointer);
clicking the canvas re-locks and resumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): crouched profile fits ~1 m openings

The float gap counts toward the effective obstacle height — the capsule rides
floatHeight (0.5 m) above the ground, so the old crouch spanned 0.5–1.3 m and
a 1.14 m opening still blocked it. Crouching now also lowers the float gap
(0.25 m) and uses a shorter capsule (0.7 m), for an effective 0.25–0.95 m
span; the stand-up headroom check grows to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(walkthrough): seamless screenshot pause — auto-release pointer lock on ⌘

Replace the P shortcut: macOS swallows the full ⇧⌘4 but the ⌘-down keystroke
still reaches the page, so the moment ⌘ (or PrintScreen) goes down while
locked the cursor is released without leaving the walkthrough — the native
screenshot flow just works, no user education. The HUD pill flips to "Click
to resume" (click-through, so the resuming click lands on the canvas) via a
new walkthroughSuspended flag on the viewer store, reset on lock/exit/unmount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): screenshot pause is P again, advertised in the HUD

The ⌘ auto-release fired on every command combo and felt broken. Back to an
explicit P toggle, now discoverable: the HUD bottom shows a "P free cursor"
pill next to "Esc to exit", and while paused it flips to "Click or P to
resume · Esc to exit" (click-through so the resuming click lands on the
canvas). P re-locks as well as releasing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(walkthrough): freeze crouch during cursor pause; no editor hints in first person

While the P pause is active, Ctrl no longer toggles crouch — ⌃⇧⌘4 (clipboard
screenshot) was crouching the player mid-capture; the held state stays frozen
until resume. HelperManager now renders nothing in first-person mode, so the
Ctrl multi-select hint no longer pops over the walkthrough HUD (Ctrl is
crouch there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: biome formatting + pre-existing useOptionalChain fix in wall panel

The wall-panel lint error predates this branch (#526); fixed here to unblock
the quality gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant