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

* feat(core): stored storey heights groundwork — pure slab-support module + 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>

* feat(editor): storey height badge + edit popover on level rows

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>

* feat(core): vertical-model load migration — stored heights, ordinal compaction, 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>

* feat: pin wall tops to the storey plane; stored heights become the only 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>

* feat: clamp slabs/ceilings under the storey plane; sweep wall-height 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>

* feat(core): persisted support hosts — schema, host-preferring election, 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>

* feat: persist support hosts at commit; thread wall host preference everywhere

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>

* feat: split slab into placement + thickness; pools become explicit recess 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>

* feat: clamp ceilings to covering-slab undersides across levels

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>

* feat: mezzanine and balcony build-tab tools

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>

* feat: adaptive slab vertical editing; level vocabulary in UI copy

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>

* feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical 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>

* feat: ceilings follow the level top by default

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>

* fix: wall plane clamp missed max-side boundary walls

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>

* feat: stairs attach to decks; pointer-decided placement surface

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>

* chore: remove the mezzanine/balcony composite tools

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 e30042db for reference.

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

* feat: gate wall adoption to grounded slabs; panel moves, drag stretches 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>

* fix: deck-attached stairs land flush with the deck surface

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>

* feat: draw walls and fences on elevated decks

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>

* feat: unit-aware height presets

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>

* feat: stairs converge to their resolved rise; deck attachment disables 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>

* fix: stacked-slab move hopping — one ray, one surface, one XZ

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>

* fix: single stair per click; tools restore select mode on exit

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>

* feat: compact multi-selection panel with host footer slot

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>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-21 09:57:30 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 34b601bb79
commit bf89b5bcf2
166 changed files with 9280 additions and 1526 deletions
@@ -1,13 +1,5 @@
import type {
AnyNode,
AnyNodeId,
CeilingNode,
ElevatorNode,
LevelNode,
WallNode,
} from '../../schema'
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
import { getStoredLevelHeight } from '../../services/storey'
export type ElevatorLevelEntry = {
id: LevelNode['id']
@@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels(
return levels.slice(minIndex, maxIndex + 1)
}
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<string, AnyNode>,
@@ -119,7 +89,7 @@ export function resolveElevatorLevels(
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getElevatorLevelHeight(level.id, nodes)
cumulativeY += getStoredLevelHeight(level)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'bun:test'
import { SlabNode, WallNode } from '../../schema'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
import {
clampSlabElevationForWalls,
computeWallSlabSupport,
getSlabElevationUpperBound,
} from './slab-support'
// 4×3 room slab drawn on the wall centerlines, like an auto-slab.
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const STOREY_HEIGHT = 2.7
const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT
function roomSlab(elevation: number) {
return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true })
}
function roomWalls(height?: number) {
return [
WallNode.parse({ start: [0, 0], end: [4, 0], height }),
WallNode.parse({ start: [4, 0], end: [4, 3], height }),
WallNode.parse({ start: [4, 3], end: [0, 3], height }),
WallNode.parse({ start: [0, 3], end: [0, 0], height }),
]
}
describe('clampSlabElevationForWalls', () => {
it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(true)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('leaves proposals at or below the bound untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('passes negative (recessed-committing) proposals through untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(-0.6)
})
it('does not clamp when the walls all carry explicit heights', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
it('does not clamp a slab covering no walls', () => {
const island = SlabNode.parse({
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.05,
})
const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
})
describe('getSlabElevationUpperBound', () => {
it('bounds a slab electable by plane-bound walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND)
})
it('is unbounded under explicit-height walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe(
Number.POSITIVE_INFINITY,
)
})
})
describe('computeWallSlabSupport preferred host', () => {
const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] }
const low = SlabNode.parse({
id: 'slab_low',
polygon: SQUARE,
elevation: 0.1,
autoFromWalls: true,
})
const high = SlabNode.parse({
id: 'slab_high',
polygon: SQUARE,
elevation: 0.6,
autoFromWalls: true,
})
it('elects the highest supporting elevation without a preference', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [])
expect(support.elevation).toBeCloseTo(0.6)
})
it('pins the elected elevation to a still-supporting preferred slab', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low')
expect(support.elevation).toBeCloseTo(0.1)
// Fill-down machinery still derives from ALL supporting slabs.
expect(support.baseSegments).toHaveLength(1)
expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6)
})
it('ignores a preferred slab that no longer supports the wall', () => {
const island = SlabNode.parse({
id: 'slab_island',
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.9,
})
const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island')
expect(support.elevation).toBeCloseTo(0.6)
})
})
@@ -0,0 +1,688 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import type { SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
export type SlabElevationClamp = {
elevation: number
clamped: boolean
}
/**
* Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall
* (no stored `height`) keeps its top at the storey plane, so a slab that
* rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that
* wall's base would squeeze the wall body below its minimum (and at the
* plane, to nothing). Walls with explicit heights don't constrain — their
* top rides the elected base, not the plane. Negative proposals (the
* drag-through-zero path that commits the `recessed` intent) pass
* through untouched: this is a purely numeric upper bound.
*
* The election runs against `levelSlabs` with `proposedElevation`
* substituted into `slab`, so a slab that would only WIN the election at
* the proposed elevation still clamps, and a slab out-elected by a
* sibling doesn't. Pure.
*/
export function clampSlabElevationForWalls(
proposedElevation: number,
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): SlabElevationClamp {
const bound = storeyHeight - MIN_WALL_HEIGHT
if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false }
if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false }
const substituted = levelSlabs.some((candidate) => candidate.id === slab.id)
? levelSlabs.map((candidate) =>
candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate,
)
: [...levelSlabs, { ...slab, elevation: proposedElevation }]
for (const wall of levelWalls) {
if (wall.height != null) continue
const wallLike: WallOverlapInput = {
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
}
// Cheap pre-filter: a wall that never reaches the slab's footprint
// can't elect it, whatever the election says about sibling slabs.
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
const support = computeWallSlabSupport(wallLike, substituted, levelWalls)
if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) {
return { elevation: bound, clamped: true }
}
}
return { elevation: proposedElevation, clamped: false }
}
/**
* Static upper bound for a slab-elevation drag: probe the election with
* the slab raised above every sibling and the storey plane. If any
* plane-bound wall would elect it there, the drag may not pass
* `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above.
*/
export function getSlabElevationUpperBound(
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): number {
const probe =
Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1
return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped
? storeyHeight - MIN_WALL_HEIGHT
: Number.POSITIVE_INFINITY
}
/**
* Point-in-polygon test using ray casting algorithm.
*/
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0],
zi = polygon[i]![1]
const xj = polygon[j]![0],
zj = polygon[j]![1]
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
function pointSegmentDistance(
px: number,
pz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number {
const dx = bx - ax
const dz = bz - az
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
}
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
// boundary: the answer flips depending on which side of the polygon the edge
// is on. Interval classification below therefore treats "within this distance
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
// edge (the common case — auto-slab polygons derive from wall centerlines)
// classify identically on every side of the slab.
const ON_BOUNDARY_EPSILON = 1e-4
export function pointOnPolygonBoundary(
px: number,
pz: number,
polygon: Array<[number, number]>,
): boolean {
const n = polygon.length
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i]!
const [bx, bz] = polygon[(i + 1) % n]!
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
}
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
for (let i = 0; i < n; i++) {
const [px, pz] = polygon[i]!
const [qx, qz] = polygon[(i + 1) % n]!
const ex = qx - px
const ez = qz - pz
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
const t = ((px - ax) * ez - (pz - az) * ex) / denom
const s = ((px - ax) * dz - (pz - az) * dx) / denom
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
}
ts.sort((a, b) => a - b)
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
if (t1 - t0 < 1e-9) continue
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
thickness?: number
}
// Minimum length of wall that must lie on/inside a slab polygon before the
// wall counts as overlapping it. Point contact (a perpendicular wall butting
// into a room's edge) clips to ~zero length and never reaches this, so such
// walls don't follow the slab's elevation.
const WALL_SLAB_MIN_OVERLAP = 0.05
/**
* Centerline of the wall plus its two face lines (centerline offset by
* ±halfThickness). The face lines catch walls whose centerline sits on or
* just outside the slab boundary but whose body reaches onto the slab —
* e.g. slab polygons drawn to the room's interior faces.
*/
function wallTestPolylines(
start: [number, number],
end: [number, number],
curveOffset: number,
halfThickness: number,
): Array<Array<{ x: number; y: number }>> {
const wallLike = { start, end, curveOffset }
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
const count = 16
const center: Array<{ x: number; y: number }> = []
const left: Array<{ x: number; y: number }> = []
const right: Array<{ x: number; y: number }> = []
for (let i = 0; i <= count; i++) {
const frame = getWallCurveFrameAt(wallLike, i / count)
center.push(frame.point)
left.push({
x: frame.point.x + frame.normal.x * halfThickness,
y: frame.point.y + frame.normal.y * halfThickness,
})
right.push({
x: frame.point.x - frame.normal.x * halfThickness,
y: frame.point.y - frame.normal.y * halfThickness,
})
}
return halfThickness > 0 ? [center, left, right] : [center]
}
const center = [
{ x: start[0], y: start[1] },
{ x: end[0], y: end[1] },
]
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const len = Math.hypot(dx, dz)
if (len < 1e-10 || halfThickness <= 0) return [center]
const nx = (-dz / len) * halfThickness
const nz = (dx / len) * halfThickness
return [
center,
[
{ x: start[0] + nx, y: start[1] + nz },
{ x: end[0] + nx, y: end[1] + nz },
],
[
{ x: start[0] - nx, y: start[1] - nz },
{ x: end[0] - nx, y: end[1] - nz },
],
]
}
/**
* Test whether a wall overlaps a slab polygon along a segment of its length.
*
* The wall's centerline and both face lines are clipped against the polygon;
* the wall overlaps when the longest clipped inside-or-on-boundary length
* exceeds a threshold (5cm, halved for very short walls). Because interval
* midpoints classify "on the boundary" as inside explicitly (never by
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
* identically on every side of the slab.
*
* A wall that only touches the polygon at a point — a perpendicular wall
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
* length and does NOT overlap.
*/
export function wallOverlapsPolygon(
startOrWall: [number, number] | WallOverlapInput,
endOrPolygon: [number, number] | Array<[number, number]>,
polygonArg?: Array<[number, number]>,
): boolean {
// Two call shapes:
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
let start: [number, number]
let end: [number, number]
let polygon: Array<[number, number]>
let curveOffset = 0
let thickness = DEFAULT_WALL_THICKNESS
if (Array.isArray(startOrWall)) {
start = startOrWall as [number, number]
end = endOrPolygon as [number, number]
polygon = polygonArg as Array<[number, number]>
} else {
start = startOrWall.start
end = startOrWall.end
curveOffset = startOrWall.curveOffset ?? 0
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
polygon = endOrPolygon as Array<[number, number]>
}
return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon)
}
/**
* {@link wallOverlapsPolygon} with the slab's stored holes subtracted from
* the covered length: a wall whose band only reaches the polygon inside a
* hole does not overlap. Hole boundaries keep coverage (rim convention —
* see {@link computeWallSlabSupport}). Polygon boundary contact counts as
* covered, so a wall sitting exactly on a slab edge resolves identically
* on every side of the slab. Pure.
*/
export function wallOverlapsSlabFootprint(
wallLike: WallOverlapInput,
polygon: Array<[number, number]>,
holes?: ReadonlyArray<Array<[number, number]>>,
): boolean {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
for (const line of polylines) {
let intervals = polylineInsideIntervals(line, polygon)
for (const hole of holes ?? []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
overlap = Math.max(overlap, intervalsLength(intervals))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
/**
* Tolerance for the pointer-decided support cap: a slab still counts as
* "the surface you're pointing at (or below)" when its walking surface is
* within this many meters ABOVE the pointed elevation. Absorbs elevation
* noise between the ray hit and slab tops without letting a deck hanging
* clearly above the hit point capture the election. Defined here (rather
* than in the spatial-grid manager, which re-exports it) so the wall
* election below can honour the same cap without an import cycle.
*/
export const SUPPORT_ELEVATION_EPSILON = 0.05
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Slab whose elevation won the election, or null when the wall has no support. */
electedSlabId: string | null
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
elevation: number
}
/**
* `preferredSlabId` is a persisted support host (`wall.supportSlabId`):
* while that slab is still in the candidate set (still overlaps the wall
* band with enough covered length), the elected `elevation` is pinned to
* it instead of the majority/best-coverage election. `baseSegments` /
* `baseElevation` (fill-down) still derive from ALL supporting slabs
* unchanged. A preferred slab that no longer qualifies is silently
* ignored — deliberately never cleared here, so the host resumes if the
* slab's polygon returns (only slab deletion strips the stored field).
*
* `maxElevation` is the pointer-decided support cap (level-local Y, same
* semantics as the item election): when set, elevation groups whose
* walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON`
* are excluded from the majority/best election — a deck hanging above the
* surface the cursor ray actually hit never captures the elected base.
* `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
* an explicit `preferredSlabId` still wins over the cap.
*/
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = {
elevation: number
slabIds: string[]
perPolyline: LengthInterval[][]
}
const groups: ElevationGroup[] = []
let preferredElevation: number | null = null
let preferredElectedSlabId: string | null = null
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
if (preferredSlabId != null && slab.id === preferredSlabId) {
preferredElevation = elevation
preferredElectedSlabId = slab.id
}
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) }
groups.push(group)
}
group.slabIds.push(slab.id)
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
const electableGroups =
maxElevation == null
? evaluatedGroups
: evaluatedGroups.filter(
(group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON,
)
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of electableGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
preferredElevation !== null
? preferredElevation
: majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const electedSlabId =
preferredElectedSlabId ??
electableGroups
.find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON)
?.slabIds.slice()
.sort()[0] ??
null
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, electedSlabId, baseElevation, baseSegments }
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
}
@@ -9,8 +9,10 @@ import type {
StairSegmentNode,
SurfaceHoleMetadata,
} from '../../schema'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
import { resolveCeilingHeight } from '../../services/level-height'
import { getLevelElevations } from '../../services/storey'
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
import { resolveStairTotalRise } from './stair-rise'
type SegmentTransform = {
position: [number, number, number]
@@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface(
const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = []
@@ -605,17 +607,16 @@ function getTargetSlabElevationForStair(
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes)
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const slabElevation = elevations.get(slabLevelId)
if (fromLevel === undefined || slabLevel === undefined) {
if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) {
return slab.elevation ?? 0.05
}
return (
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(slab.elevation ?? 0.05) -
(stair.position[1] ?? 0)
slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0)
)
}
@@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair(
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const ceilingElevation = elevations.get(ceilingLevelId)
if (fromLevel === undefined || ceilingLevel === undefined) {
return ceiling.height ?? DEFAULT_WALL_HEIGHT
const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record<AnyNodeId, AnyNode>)
if (
!fromElevation ||
!ceilingElevation ||
fromElevation.buildingId !== ceilingElevation.buildingId
) {
return ceilingHeight
}
return (
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
(stair.position[1] ?? 0)
)
return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0)
}
function shouldApplyStairToSlab(
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef } from 'react'
import type { AnyNode } from '../../schema'
import type { AnyNode, AnyNodeId } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
import useLiveTransforms from '../../store/use-live-transforms'
@@ -12,6 +12,7 @@ import {
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync'
import { syncStairRises } from './stair-rise'
function isOpeningRelevantNode(node: AnyNode | undefined) {
return (
@@ -47,7 +48,7 @@ export const StairOpeningSystem = () => {
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }>) => {
if (updates.length === 0) return
syncingAutoOpeningsRef.current = true
pauseSceneHistory(useScene)
@@ -103,14 +104,40 @@ export const StairOpeningSystem = () => {
)
}
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
refreshLivePreview()
const runAutoSync = () => {
// Rise first: straight stairs converge their flight heights to the
// resolved rise (level height or deck elevation), and the opening pass
// reads those segment heights — so it must run against the post-rise
// nodes.
applyUpdates(syncStairRises(useScene.getState().nodes))
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
}
let disposed = false
let autoSyncQueued = false
const scheduleAutoSync = () => {
if (autoSyncQueued) return
autoSyncQueued = true
// One microtask later so every other scene-store listener for the
// triggering transition (and, at mount, the editor's spatial-grid
// init) runs first — the spatial-grid sync in particular. The
// deck-attached rise elects the stair's floor-stack base elevation
// through the spatial grid; syncing before the grid listener would
// rescale flights against the pre-transition slab state.
queueMicrotask(() => {
autoSyncQueued = false
if (disposed) return
runAutoSync()
refreshLivePreview()
})
}
scheduleAutoSync()
const unsubscribeScene = useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoStairOpenings(state.nodes))
refreshLivePreview()
scheduleAutoSync()
})
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
@@ -122,6 +149,7 @@ export const StairOpeningSystem = () => {
})
return () => {
disposed = true
unsubscribeScene()
unsubscribeLiveTransforms()
unsubscribeLiveOverrides()
@@ -0,0 +1,465 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import { z } from 'zod'
import {
GROUND_SUPPORT_ID,
getFloorPlacedElevation,
} from '../../hooks/spatial-grid/floor-placed-elevation'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, StairNode as StairNodeType } from '../../schema'
import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
import { resolveStairTotalRise, syncStairRises } from './stair-rise'
// The deck branch elects the stair's floor-stack base through the node
// registry + spatial grid singletons — reset them so tests are hermetic
// (base elects 0 unless a test registers a stair footprint and slabs).
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
function buildScene(levelHeight: number | undefined, totalRise: number | undefined) {
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
...(totalRise !== undefined ? { totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
children: ['stair_1'],
...(levelHeight !== undefined ? { height: levelHeight } : {}),
})
return { stair, nodes: { level_1: level, stair_1: stair } }
}
function makeDeck(elevation: number, polygon?: Array<[number, number]>) {
return SlabNode.parse({
id: 'slab_deck',
type: 'slab',
polygon: polygon ?? [
[0, 0],
[2, 0],
[2, 2],
[0, 2],
],
elevation,
thickness: 0.05,
})
}
function buildDeckScene(options: {
deckElevation: number
deckPolygon?: Array<[number, number]>
totalRise?: number
deckSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const deck = makeDeck(options.deckElevation, options.deckPolygon)
const segments = (options.segments ?? []).map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
deckSlabId: options.deckSlabId ?? deck.id,
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: 2.5,
children: ['stair_1', deck.id],
})
const nodes: Record<string, AnyNode> = {
level_1: level,
stair_1: stair,
[deck.id]: deck,
}
for (const segment of segments) nodes[segment.id] = segment
return { deck, stair, nodes }
}
function buildLevelSceneWithSegments(options: {
levelHeight: number
totalRise?: number
segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const segments = options.segments.map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: options.levelHeight,
children: ['stair_1'],
})
const nodes: Record<string, AnyNode> = { level_1: level, stair_1: stair }
for (const segment of segments) nodes[segment.id] = segment
return { level, stair, nodes }
}
describe('resolveStairTotalRise', () => {
it('derives the rise from the containing level stored height when absent', () => {
const { stair, nodes } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(3.2)
})
it('tracks a storey height change without any stair write', () => {
const { stair, nodes } = buildScene(2.55, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.55)
const level = nodes.level_1
if (level.type !== 'level') throw new Error('expected level')
const updated = { ...nodes, level_1: { ...level, height: 3.0 } }
expect(resolveStairTotalRise(stair, updated)).toBe(3.0)
})
it('prefers an explicit totalRise over the storey height', () => {
const { stair, nodes } = buildScene(3.2, 2.5)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
it('falls back to the default when the stair has no containing level', () => {
const { stair } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, {})).toBe(2.5)
})
it('derives the rise from the attached deck elevation', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
expect(resolveStairTotalRise(stair, nodes)).toBe(1.25)
})
it('tracks a deck elevation change without any stair write', () => {
const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } }
expect(resolveStairTotalRise(stair, updated)).toBe(1.6)
})
it('prefers an explicit totalRise over the attached deck', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('falls through a stale deckSlabId to the storey height silently', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
})
describe('syncStairRises', () => {
it('writes the deck elevation into a single flight segment', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }])
})
it('is a no-op when the flights already match the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('scales multiple flights proportionally and leaves landings alone', () => {
const { nodes } = buildDeckScene({
deckElevation: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('distributes an explicit custom rise instead of the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }])
})
it('falls a stale deckSlabId back to the storey height', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a stale-deck stair with an explicit rise untouched', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('converges a level-following straight stair to the storey height', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('converges a level-following stair after a storey height change', () => {
const scene = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }])
})
it('rescales level-following flights proportionally, landings untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('converges back to the storey height after a deck detach', () => {
const scene = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const { deckSlabId: _deckSlabId, ...detached } = scene.stair
const nodes = { ...scene.nodes, stair_1: detached as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a detached explicit-rise stair with hand-set segments untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
totalRise: 2.0,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.9 },
{ id: 'sseg_2', segmentType: 'stair', height: 0.6 },
],
})
expect(syncStairRises(nodes)).toEqual([])
})
})
// The stair stands on a floor slab (the default 0.05 one, or whatever the
// floor-stack elects) — the deck-derived rise must be measured from that
// lifted base so the last step lands flush with the deck's walking surface.
describe('deck-attached rise with a floor-lifted base', () => {
const FLOOR_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
// Away from the stair footprint at the origin so the base election never
// sees the deck itself.
const AWAY_DECK_POLYGON: Array<[number, number]> = [
[8, 8],
[10, 8],
[10, 10],
[8, 10],
]
beforeEach(() => {
registerNode({
kind: 'stair',
schemaVersion: 1,
schema: z.object({ type: z.literal('stair') }) as never,
category: 'structure',
defaults: () => ({}) as never,
capabilities: {
floorPlaced: {
footprints: (node) => [
{
position: (node as StairNodeType).position,
dimensions: [1, 1, 2] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
},
],
},
},
} as AnyNodeDefinition)
})
function makeFloorSlab(elevation: number) {
return SlabNode.parse({
id: 'slab_floor',
type: 'slab',
polygon: FLOOR_POLYGON,
elevation,
thickness: 0.05,
})
}
function buildLiftedDeckScene(options: {
deckElevation: number
floorElevation?: number
totalRise?: number
supportSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const floor = makeFloorSlab(options.floorElevation ?? 0.05)
const scene = buildDeckScene({
deckElevation: options.deckElevation,
deckPolygon: AWAY_DECK_POLYGON,
totalRise: options.totalRise,
segments: options.segments,
})
const stair = options.supportSlabId
? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair)
: scene.stair
const nodes: Record<string, AnyNode> = {
...scene.nodes,
stair_1: stair,
[floor.id]: floor,
}
spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1')
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
return { deck: scene.deck, floor, stair, nodes }
}
it('lands the last step flush: rise = deck elevation elected base', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 })
const base = getFloorPlacedElevation({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: 'level_1',
})
expect(base).toBeCloseTo(0.05)
const rise = resolveStairTotalRise(stair, nodes)
expect(rise).toBeCloseTo(1.2)
// Top surface = visual base + rise = the deck's walking surface, not 1.30.
expect(base + rise).toBeCloseTo(1.25)
})
it('rescales a flight converged under the old rule down to the flush rise', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2)
})
it('keeps the full deck elevation when the stair stands on bare ground', () => {
const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON })
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25)
})
it('lets an explicit totalRise win over the base-adjusted deck rise', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('re-converges to flush after a deck elevation change', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const movedDeck = { ...scene.deck, elevation: 1.6 }
const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode }
spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55)
})
it('re-converges to flush after the base slab elevation changes', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
const movedFloor = { ...scene.floor, elevation: 0.3 }
const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode }
spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95)
})
it('rescales flights proportionally from the lifted base, landings untouched', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 2.15,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
// Target flight rise = 2.15 0.05 (base) 0.1 (landing) = 2.0 → 1.0 each.
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0)
expect(updates[1]?.id).toBe('sseg_3' as never)
expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0)
})
it('honors a persisted ground host over the floor slab election', () => {
const { stair, nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
supportSlabId: GROUND_SUPPORT_ID,
})
expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25)
})
})
@@ -0,0 +1,90 @@
import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height'
import { getStoredLevelHeight } from '../../services/storey'
export function resolveStairTotalRise(stair: StairNode, nodes: Record<string, AnyNode>): number {
if (stair.totalRise !== undefined) return stair.totalRise
const level = Object.values(nodes).find(
(node) => node.type === 'level' && node.children.includes(stair.id),
)
if (stair.deckSlabId) {
const deck = nodes[stair.deckSlabId]
// The deck's `elevation` IS its walking surface (level-local), but the
// stair's own base may be lifted onto a floor slab by the floor-stack
// (`FloorElevationSystem` / `syncStairGroupElevation` put the group at
// `position[1] + elected slab elevation`). The rise is measured from
// that base, so subtract it — electing the base exactly the way the
// visual systems do (persisted `supportSlabId` honored, uncapped
// election otherwise) keeps base + rise landing precisely on the deck's
// walking surface. A stale reference (deck gone) falls through to the
// level-derived rise.
if (deck?.type === 'slab') {
const baseElevation = getFloorStackedPosition({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: level?.id ?? null,
})[1]
return (deck.elevation ?? 0.05) - baseElevation
}
}
return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT
}
const RISE_SYNC_EPSILON = 1e-4
/**
* Keeps straight stairs' flight segments in step with the resolved rise.
* Straight-stair geometry derives from per-segment heights (not from
* `resolveStairTotalRise`), so level-height and deck-elevation changes must
* write through to the flight segments — curved/spiral stairs read the
* resolved rise directly and need no sync.
*
* Scope: stairs whose total the system owns — follows-mode stairs (absent
* `totalRise`, tracking their level or their deck) and deck-attached stairs
* (an explicit rise converges to the typed value). A detached stair with an
* explicit `totalRise` is the one place hand-edited segment chains are
* legitimate, so it is never touched. Flight heights scale proportionally
* (landings keep theirs); returns `updateNodes` patches, empty when every
* stair is already in step.
*/
export function syncStairRises(
nodes: Record<string, AnyNode>,
): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
for (const node of Object.values(nodes)) {
if (node.type !== 'stair' || node.stairType !== 'straight') continue
const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined
if (node.totalRise !== undefined && deck?.type !== 'slab') continue
const segments = (node.children ?? [])
.map((childId) => nodes[childId])
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
const flights = segments.filter((segment) => segment.segmentType === 'stair')
if (flights.length === 0) continue
const landingRise = segments
.filter((segment) => segment.segmentType !== 'stair')
.reduce((sum, segment) => sum + segment.height, 0)
const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0)
const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise
if (targetFlightRise <= 0) continue
if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue
for (const flight of flights) {
const height =
flightRise > RISE_SYNC_EPSILON
? flight.height * (targetFlightRise / flightRise)
: targetFlightRise / flights.length
updates.push({ id: flight.id as AnyNodeId, data: { height } })
}
}
return updates
}
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top'
describe('resolveWallTop', () => {
test('explicit height on zero base keeps the stored top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('explicit height on raised base rides the base', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1)
})
test('explicit height on sunken base keeps the absolute top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5)
})
test('plane-bound wall tops out at the storey plane regardless of base', () => {
expect(resolveWallTop({}, 3, 0)).toBe(3)
expect(resolveWallTop({}, 3, 0.6)).toBe(3)
expect(resolveWallTop({}, 3, -0.4)).toBe(3)
})
})
describe('resolveWallEffectiveHeight', () => {
test('explicit on raised base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5)
})
test('explicit on zero base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('plane-bound on raised base gets shorter, never taller', () => {
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4)
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3)
})
test('plane-bound on zero base spans the full storey', () => {
expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3)
})
test('plane-bound on sunken base fills down while the top stays at the plane', () => {
expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4)
})
})
@@ -0,0 +1,53 @@
import type { WallNode } from '../../schema/nodes/wall'
/**
* Minimum wall body height in meters. Governs both the wall height
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
* elects it as its base, or the wall's extrusion (plane minus base)
* would collapse below this minimum.
*/
export const MIN_WALL_HEIGHT = 0.5
/**
* Wall-top inversion (vertical building model): a wall with no stored
* `height` is plane-bound — its top sits at the storey plane (level-local
* Y = the level's stored height), so a slab lifting the wall's base makes
* the wall shorter, never taller, and no gap can open at the top of a
* level. A wall WITH `height` is an explicit exception (half wall,
* parapet) and keeps the legacy semantics: the top rides a raised elected
* base (`electedBase + height`), while a zero or sunken base leaves the
* top at `height` (the legacy negative-slab constraint).
*
* Returns the top in level-local Y (same frame as `electedBase`).
*/
export function resolveWallTop(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
if (wall.height == null) return storeyHeight
return electedBase > 0 ? electedBase + wall.height : wall.height
}
/**
* Extruded height of the wall body: {@link resolveWallTop} minus the
* elected base. Base convention: the elected slab-support elevation itself
* — the viewer computes `effectiveBaseElevation = min(baseElevation,
* slabElevation)` and defaults `baseElevation` to the elected elevation,
* so with only the election in hand the two coincide. Fill-down below the
* elected base (`baseSegments`) is a geometry detail the extruder handles
* separately and never changes where the top sits.
*
* Equivalently: the wall-local Y of the wall's top, measured from the wall
* mesh origin (which sits at `electedBase`). May be non-positive when a
* slab reaches the storey plane; callers own the degenerate-geometry
* policy.
*/
export function resolveWallEffectiveHeight(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
return resolveWallTop(wall, storeyHeight, electedBase) - electedBase
}