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
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */
const DECK_POLYGON: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
/** Ground floor slab under (and far beyond) the deck. */
const GROUND_POLYGON: Array<[number, number]> = [
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
/** Straight fence fully over the deck footprint. */
function fenceOnDeck(overrides: Partial<FenceSupportInput> = {}): FenceSupportInput {
return {
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.08,
parentId: LEVEL_ID,
...overrides,
}
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
function sceneWith(...slabs: SlabNode[]): Record<string, AnyNode> {
const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[]))
useScene.setState({ nodes })
for (const slab of slabs) addSlab(slab)
return nodes
}
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('resolveFenceSupportSlabPatch', () => {
test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('the pointer cap decides between stacked surfaces', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
// Aiming at the floor under the deck elects (and persists) the floor.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }),
).toEqual({ supportSlabId: 'slab_ground' })
// Aiming at the deck top keeps the deck.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }),
).toEqual({ supportSlabId: 'slab_deck' })
})
test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => {
// Unambiguous single candidate — but fences resolve an absent host to
// the level floor, so an elevated winner must be written or the fence
// renders buried under the deck.
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => {
const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
})
test('capped at bare ground under a deck-only overlap resolves to the floor default', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({
supportSlabId: undefined,
})
})
test('no slabs / off-slab fence persists nothing', () => {
const nodes = sceneWith()
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(
resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck),
).toEqual({ supportSlabId: undefined })
})
test('a spline fence elects through its path band segments', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
const spline = fenceOnDeck({
start: [0.5, 0.5],
end: [3.5, 2.5],
path: [
[0.5, 0.5],
[2, 1.5],
[3.5, 2.5],
],
})
expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a fence not parented to a level persists nothing', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({
supportSlabId: undefined,
})
})
})
@@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab
holes: [],
holeMetadata: [],
elevation,
thickness: Math.max(elevation, 0),
recessed: elevation < 0,
autoFromWalls: false,
} as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
@@ -8,12 +8,29 @@ import type {
import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'
/**
* Sentinel `supportSlabId` meaning "hosted by the level base (ground)".
* Persisted when a pointer-capped commit elects the ground while one or
* more slabs (e.g. an elevated deck) still overlap the footprint above the
* cap — without it, the uncapped per-frame election would lift the
* committed node back onto the deck.
*/
export const GROUND_SUPPORT_ID = 'ground'
export type FloorPlacedElevationArgs = {
node: AnyNode
nodes: Record<string, AnyNode>
position: [number, number, number]
rotation?: unknown
levelId?: string | null
/**
* Pointer-decided support cap (level-local Y): only slabs whose walking
* surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON`
* may be elected, and the persisted `supportSlabId` is bypassed — during
* a drag the pointer, not the stored host, decides the target surface.
* Omit (or pass null) for the uncapped committed-read behavior.
*/
maxElevation?: number | null
}
function finiteSlabElevation(elevation: number): number {
@@ -50,6 +67,7 @@ export function getFloorPlacedElevation({
position,
rotation,
levelId,
maxElevation,
}: FloorPlacedElevationArgs): number {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) return 0
@@ -66,8 +84,31 @@ export function getFloorPlacedElevation({
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
if (!resolvedLevelId) return 0
let maxElevation = Number.NEGATIVE_INFINITY
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })
// A persisted support host pins the elevation while it still exists and
// overlaps a footprint — deterministic across stacked slabs. A stale
// host (deleted or reshaped away) silently falls through to the
// election below; this per-frame read path never writes the field.
// Skipped entirely under a pointer cap: the cursor, not the stored
// host, decides the target surface during a drag.
const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId
if (maxElevation == null && supportSlabId) {
if (supportSlabId === GROUND_SUPPORT_ID) return 0
for (const footprint of footprints) {
const hosted = spatialGridManager.getHostSlabElevationForFootprint(
resolvedLevelId,
supportSlabId,
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
)
if (hosted !== null) return finiteSlabElevation(hosted)
}
}
let elected = Number.NEGATIVE_INFINITY
for (const footprint of footprints) {
const footprintPosition = footprint.position ?? position
const elevation = finiteSlabElevation(
spatialGridManager.getSlabElevationForItem(
@@ -75,14 +116,15 @@ export function getFloorPlacedElevation({
footprintPosition,
footprint.dimensions,
footprint.rotation,
maxElevation,
),
)
if (elevation > maxElevation) {
maxElevation = elevation
if (elevation > elected) {
elected = elevation
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
return elected === Number.NEGATIVE_INFINITY ? 0 : elected
}
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { resolveSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [-1, 1]. */
const DECK_POLYGON: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */
const GROUND_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function addDeckAndFloor() {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('pointer-capped slab support election', () => {
test('hit at the floor under the deck elects the floor, not the deck above', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
FLOOR_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('hit on the deck top still elects the deck', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('no cap keeps the historical max election', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => {
// Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not.
addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0.1, slabId: 'slab_within' })
spatialGridManager.clear()
addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0, slabId: null })
})
})
describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => {
test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => {
addDeckAndFloor()
// Camera in front of the deck (negative z), high up. Aiming at the
// floor point (0, FLOOR, 0) — a point that lies UNDER the deck in
// plan — crosses the deck's elevation plane before reaching the deck
// polygon, so only the floor is hit.
const origin: [number, number, number] = [0, 5, -10]
const toFloorUnderDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
0 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual(
{ elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] },
)
// Aiming at the deck's top surface: the deck plane crossing lands
// inside the deck polygon and is nearer along the ray than the floor.
const toDeckTop: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.5 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({
elevation: DECK_ELEVATION,
slabId: 'slab_deck',
point: [0, 0.5],
})
})
test('a ray through a deck hole falls through to the surface below', () => {
addSlab(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, {
holes: [
[
[-0.5, -0.5],
[0.5, -0.5],
[0.5, 0.5],
[-0.5, 0.5],
],
],
}),
)
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
// Straight down through the hole center.
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 0],
})
})
test('no slab crossing resolves the level base (with the base-plane point)', () => {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({
elevation: 0,
slabId: null,
point: [3, 3],
})
})
test('a ray that cannot reach any surface has no point', () => {
addDeckAndFloor()
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({
elevation: 0,
slabId: null,
point: null,
})
})
})
describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => {
// Manual repro this pins down: deck slab stacked above a floor slab,
// move an item over the deck near its far edge with an angled camera.
// The grid event plane rides at the ghost's LAST surface height, so the
// same screen ray produces hit points whose XZ differ by metres
// depending on which storey the plane rode at. The cap (ray → pointed
// surface) is plane-height independent, but electing at the RAW hit XZ
// is not: the floor-height hit is perspective-skewed past the deck, its
// footprint misses the deck polygon, and the capped election falls to
// the floor — dropping the ghost, which drops the plane, which keeps
// the hit skewed (a second self-consistent state). Transitions between
// the two states are the hop. Electing at the ray-derived `point`
// leaves a single fixed point per pointer ray.
const origin: [number, number, number] = [0, 5, -10]
/** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */
const aimAtDeck: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.8 - origin[2],
]
test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => {
addDeckAndFloor()
// The two grid hits the SAME screen ray produces — one per event-plane
// height (plane riding at the deck vs at the floor slab).
const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1]
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [
origin[0] + aimAtDeck[0] * t,
origin[1] + aimAtDeck[1] * t,
origin[2] + aimAtDeck[2] * t,
])
for (const hit of planeHits) {
const direction: [number, number, number] = [
hit[0] - origin[0],
hit[1] - origin[1],
hit[2] - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction)
expect(pointed.slabId).toBe('slab_deck')
expect(pointed.elevation).toBe(DECK_ELEVATION)
expect(pointed.point?.[0]).toBeCloseTo(0, 10)
expect(pointed.point?.[1]).toBeCloseTo(0.8, 10)
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[pointed.point![0], 0, pointed.point![1]],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
}
})
test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => {
addDeckAndFloor()
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const floorPlaneHit: [number, number, number] = [
origin[0] + aimAtDeck[0] * tFloor,
0,
origin[2] + aimAtDeck[2] * tFloor,
]
// The skew carries the hit metres past the deck's far edge (z = 1)…
expect(floorPlaneHit[2]).toBeGreaterThan(2)
// …so the same pointer ray, elected at the raw hit XZ, picks the
// FLOOR while the cap says the pointer is on the deck.
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
floorPlaneHit,
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('pointer past the deck edge: pointed point lands on the floor and elects it', () => {
addDeckAndFloor()
// Aimed at a floor point far enough out that the deck-plane crossing
// falls outside the deck polygon (the floor there is actually visible).
const aimPastDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
4 - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck)
expect(pointed).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 4],
})
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 4],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
})
describe('getFloorPlacedElevation under a pointer cap', () => {
test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: DECK_ELEVATION,
}),
).toBeCloseTo(DECK_ELEVATION)
// Uncapped read keeps the historical max election.
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(DECK_ELEVATION)
})
test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
})
test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
})
describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => {
test('a commit under the deck persists the elected lower slab', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_floor',
})
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
// Uncapped commits keep the historical rule (max winner on ambiguity).
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a commit on bare ground under the deck persists the ground sentinel', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({
supportSlabId: GROUND_SUPPORT_ID,
})
// Aiming at the deck top with only the deck overlapping stays
// unambiguous — no host persisted, same as the uncapped rule.
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
test('a single floor slab under the cap stays unpersisted (unambiguous)', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
})
@@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import { getWallPlaneTop } from '../../services/storey'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import {
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallSlabSupport,
} from '../../systems/slab/slab-support'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid'
export {
computeWallSlabElevation,
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallOverlapInput,
type WallSlabSupport,
type WallSlabSupportSegment,
wallOverlapsPolygon,
} from '../../systems/slab/slab-support'
// ============================================================================
// GEOMETRY HELPERS
// ============================================================================
/**
* 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
}
/**
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
*/
@@ -295,512 +294,29 @@ export function itemOverlapsPolygon(
return false
}
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
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)
}
function polylineInsideLength(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
): number {
return intervalsLength(polylineInsideIntervals(points, polygon))
}
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]>
}
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) {
overlap = Math.max(overlap, polylineInsideLength(line, polygon))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
// 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
/** 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
/** One slab overlapping a queried footprint, as seen by support election. */
export type SlabSupportCandidate = {
slabId: string
elevation: number
}
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): 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, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] }
const groups: ElevationGroup[] = []
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
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, perPolyline: polylines.map(() => []) }
groups.push(group)
}
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 }
})
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of evaluatedGroups) {
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 =
majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
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, baseElevation, baseSegments }
export type ItemSlabSupport = {
elevation: number
/** The winning slab, or null when no slab overlaps the footprint. */
slabId: string | null
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
export type PointedSupportSurface = ItemSlabSupport & {
/**
* Level-local XZ where the ray meets the pointed surface's plane, or
* null when the ray never reaches it (grazing / aimed above the base).
* This is the plan point the pointer actually indicates: unlike a grid
* event-plane hit — whose XZ shifts with whatever height the event
* plane currently rides at — it depends only on the ray and the
* aimed-at surface, so election/preview at this point cannot flip when
* the event plane changes storey.
*/
point: [number, number] | null
}
export class SpatialGridManager {
@@ -842,7 +358,24 @@ export class SpatialGridManager {
private getWallHeight(wallId: string): number {
const wall = this.walls.get(wallId)
return wall?.height ?? 2.5 // Default wall height
if (!wall) return 0
if (wall.height != null) return wall.height
const nodes = useScene.getState().nodes
const levelId = resolveNodeLevelId(wall, nodes)
const support = this.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(
wall,
getWallPlaneTop(wall, levelId, nodes),
support.elevation,
)
}
private getCeilingGrid(ceilingId: string): SpatialGrid {
@@ -859,15 +392,74 @@ export class SpatialGridManager {
return this.slabsByLevel.get(levelId)!
}
/**
* Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item
* support queries run per frame and the projection scans the level's
* walls + sibling slabs, so the result is cached per slab id and
* dropped for the whole level whenever a slab or wall on that level
* flows through the manager's create/update/delete handlers.
*/
private readonly renderedSlabPolygons = new Map<string, Array<[number, number]>>()
private invalidateRenderedSlabPolygons(levelId: string) {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return
for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId)
}
private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> {
const cached = this.renderedSlabPolygons.get(slab.id)
if (cached) return cached
const siblingSlabs: SlabNode[] = []
for (const other of this.getSlabMap(levelId).values()) {
if (other.id !== slab.id) siblingSlabs.push(other)
}
const polygon = getRenderableSlabPolygon(slab, {
walls: this.getLevelWallNodes(levelId),
siblingSlabs,
})
this.renderedSlabPolygons.set(slab.id, polygon)
return polygon
}
/**
* Support test shared by election, candidate listing, and persisted-host
* validation: the footprint overlaps the slab's RENDERED polygon (what
* users see — matching the wall election in `computeWallSlabSupport`),
* with the center-point hole veto kept against the stored holes (holes
* are data, never render-offset).
*/
private slabSupportsFootprint(
levelId: string,
slab: SlabNode,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): boolean {
if (slab.polygon.length < 3) return false
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false
const [cx, , cz] = position
for (const hole of slab.holes || []) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false
}
return true
}
// Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') {
const wall = node as WallNode
this.walls.set(wall.id, wall)
// Rendered slab polygons adopt wall bands — a new wall can extend them.
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -920,11 +512,13 @@ export class SpatialGridManager {
handleNodeUpdated(node: AnyNode, levelId: string) {
if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') {
const wall = node as WallNode
this.walls.set(wall.id, wall)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -982,12 +576,16 @@ export class SpatialGridManager {
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === 'slab') {
// Invalidate before removal so the deleted slab's own cache entry
// (still keyed in the level map here) is dropped with its siblings'.
this.invalidateRenderedSlabPolygons(levelId)
this.getSlabMap(levelId).delete(nodeId)
} else if (nodeType === 'ceiling') {
this.ceilings.delete(nodeId)
this.ceilingGrids.delete(nodeId)
} else if (nodeType === 'wall') {
this.walls.delete(nodeId)
this.invalidateRenderedSlabPolygons(levelId)
// Remove all items attached to this wall from the spatial grid
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
return removedItemIds // Caller can use this to delete the items from scene
@@ -1201,45 +799,162 @@ export class SpatialGridManager {
/**
* Get the slab elevation for an item using its full footprint (bounding box).
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).
* Returns the highest overlapping slab elevation, or 0 if none.
* Thin wrapper over {@link getSlabSupportForItem} for callers (and tests)
* that only need the number.
*/
getSlabElevationForItem(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
maxElevation?: number | null,
): number {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0
return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation)
.elevation
}
let maxElevation = Number.NEGATIVE_INFINITY
/**
* Elect the supporting slab for a footprint: the highest-elevation slab
* whose RENDERED polygon the footprint overlaps (center-point hole veto
* applies). Returns `{ elevation: 0, slabId: null }` when nothing
* overlaps.
*
* `maxElevation` is the pointer-decided cap: when set, only slabs whose
* walking surface sits at or below `maxElevation +
* SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface
* the cursor ray actually hit never captures the election.
*/
getSlabSupportForItem(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
maxElevation?: number | null,
): ItemSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return { elevation: 0, slabId: null }
let winningElevation = Number.NEGATIVE_INFINITY
let winnerId: string | null = null
for (const slab of slabMap.values()) {
if (
slab.polygon.length >= 3 &&
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
) {
// Check if item is entirely within a hole (if so, ignore this slab)
// We consider it entirely in a hole if the item center is in the hole
const elevation = slab.elevation ?? 0.05
if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
if (elevation > winningElevation) {
winningElevation = elevation
winnerId = slab.id
}
}
return winnerId === null
? { elevation: 0, slabId: null }
: { elevation: winningElevation, slabId: winnerId }
}
/**
* The walking surface the pointer actually points at: the nearest slab
* plane the ray crosses INSIDE that slab's rendered polygon (hole veto
* applies), or the level base (`elevation: 0, slabId: null`) when it
* crosses none. Ray origin/direction are level-local. Deliberately a
* point test, not a footprint test — it answers "which surface is under
* the cursor", which then caps the footprint election so a deck hanging
* above the aimed-at floor never lifts the placement. `point` is the
* ray's crossing of that surface's plane — the stable plan point
* callers should elect/preview at (see {@link PointedSupportSurface}).
*/
getPointedSupportSurface(
levelId: string,
rayOrigin: [number, number, number],
rayDirection: [number, number, number],
): PointedSupportSurface {
const slabMap = this.slabsByLevel.get(levelId)
const [ox, oy, oz] = rayOrigin
const [dx, dy, dz] = rayDirection
if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null }
let best: { t: number; elevation: number; slabId: string } | null = null
if (slabMap) {
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
const elevation = slab.elevation ?? 0.05
const t = (elevation - oy) / dy
if (t <= 0) continue
if (best && t >= best.t) continue
const x = ox + dx * t
const z = oz + dz * t
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue
let inHole = false
const [cx, , cz] = position
const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
for (const hole of slab.holes || []) {
if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
inHole = true
break
}
}
if (!inHole) {
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) {
maxElevation = elevation
}
}
if (inHole) continue
best = { t, elevation, slabId: slab.id }
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
if (best) {
return {
elevation: best.elevation,
slabId: best.slabId,
point: [ox + dx * best.t, oz + dz * best.t],
}
}
const tBase = -oy / dy
return {
elevation: 0,
slabId: null,
point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null,
}
}
/**
* All slabs supporting a footprint, one entry per overlapping slab
* (highest elevation first; slab id breaks ties deterministically).
* Commit-side ambiguity check: persist a `supportSlabId` only when the
* candidates carry ≥ 2 distinct elevations.
*/
getSupportCandidatesForFootprint(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): SlabSupportCandidate[] {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return []
const candidates: SlabSupportCandidate[] = []
for (const slab of slabMap.values()) {
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 })
}
candidates.sort(
(a, b) =>
b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0),
)
return candidates
}
/**
* Elevation of a persisted support host for a footprint, or null when
* the slab no longer exists on the level or no longer overlaps the
* footprint (same overlap test as election). Deliberately read-only: a
* host reshaped away is NOT cleared — callers fall back to election and
* the stale reference resumes hosting if the slab's polygon returns.
* Slab deletion is the only writer (`deleteNodesAction` strips it).
*/
getHostSlabElevationForFootprint(
levelId: string,
slabId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): number | null {
const slab = this.slabsByLevel.get(levelId)?.get(slabId)
if (!slab) return null
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null
return slab.elevation ?? 0.05
}
/**
@@ -1255,8 +970,10 @@ export class SpatialGridManager {
end: [number, number],
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
): number {
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId)
.elevation
}
getSlabSupportForWall(
@@ -1265,11 +982,14 @@ export class SpatialGridManager {
end: [number, number],
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) {
return {
elevation: 0,
electedSlabId: null,
baseElevation: 0,
baseSegments: [{ start: 0, end: 1, elevation: 0 }],
}
@@ -1279,6 +999,8 @@ export class SpatialGridManager {
{ start, end, curveOffset, thickness },
[...slabMap.values()],
this.getLevelWallNodes(levelId),
preferredSlabId,
maxElevation,
)
}
@@ -1387,6 +1109,7 @@ export class SpatialGridManager {
}
clearLevel(levelId: string) {
this.invalidateRenderedSlabPolygons(levelId)
this.floorGrids.delete(levelId)
this.wallGrids.delete(levelId)
this.slabsByLevel.delete(levelId)
@@ -1400,8 +1123,33 @@ export class SpatialGridManager {
this.ceilingGrids.clear()
this.ceilings.clear()
this.itemCeilingMap.clear()
this.renderedSlabPolygons.clear()
}
}
// Singleton instance
export const spatialGridManager = new SpatialGridManager()
/**
* Effective (extruded) height of a wall resolved from a nodes record:
* {@link resolveWallEffectiveHeight} over the covering-clamped plane top
* (`getWallPlaneTop`) and the singleton manager's slab election — so the
* value always agrees with the rendered wall. One shared resolver for the
* editor overlays (measurement label, action menu, side handles) that used
* to copy this derivation locally.
*/
export function getWallEffectiveHeightForNodes(
wall: WallNode,
nodes: Record<string, AnyNode>,
): number {
const levelId = resolveNodeLevelId(wall, nodes)
const support = spatialGridManager.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation)
}
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import {
initSpatialGridSync,
markCoveringDependentsBelow,
markLevelHeightDependents,
} from './spatial-grid-sync'
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode {
return {
id,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: ordinal,
height,
} as AnyNode
}
function makeChild(id: string, type: string, parentId: string): AnyNode {
return {
id,
type,
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
start: [0, 1],
end: [4, 1],
thickness: 0.1,
polygon: SQUARE,
holes: [],
} as unknown as AnyNode
}
function makeSlab(id: string, parentId: string, overrides: Partial<AnyNode> = {}): AnyNode {
return {
id,
type: 'slab',
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
polygon: SQUARE,
holes: [],
holeMetadata: [],
elevation: 0.05,
thickness: 0.05,
autoFromWalls: false,
...overrides,
} as AnyNode
}
function nodesFor(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
function dirtyIds(): string[] {
return [...useScene.getState().dirtyNodes].sort()
}
describe('spatial-grid sync dirty rules (vertical model)', () => {
let stopSync = () => {}
// Two orphan levels sharing the legacy stack: level_0 (below) carries a
// wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab.
const wall = makeChild('wall_a', 'wall', 'level_0')
const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0')
const stair = makeChild('stair_a', 'stair', 'level_0')
const fence = makeChild('fence_a', 'fence', 'level_0')
const zone = makeChild('zone_a', 'zone', 'level_0')
const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 })
const level0 = makeLevel('level_0', 0, 2.5, [
'wall_a',
'ceiling_a',
'stair_a',
'fence_a',
'zone_a',
])
const level1 = makeLevel('level_1', 1, 2.5, ['slab_up'])
function setScene(nodes: Record<AnyNodeId, AnyNode>) {
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes,
readOnly: false,
rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[],
} as never)
clearSceneHistory()
}
beforeEach(() => {
spatialGridManager.clear()
setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab))
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
level_0: { ...level0, height: 3 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('a slab thickness change marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('a slab recessed toggle marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, recessed: true } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('creating a slab on the level above marks the level below, deleting it too', () => {
const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 })
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_new: added,
level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record<string, AnyNode>
useScene.setState({
nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
})
})
describe('spatial-grid sync dirty rules (deck-attached stairs)', () => {
let stopSync = () => {}
const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 })
const attachedStair = {
...makeChild('stair_deck', 'stair', 'level_0'),
deckSlabId: 'slab_deck',
} as AnyNode
const otherStair = makeChild('stair_other', 'stair', 'level_0')
const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other'])
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(deckLevel, deck, attachedStair, otherStair),
readOnly: false,
rootNodeIds: ['level_0'] as AnyNodeId[],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a deck elevation marks its attached stair dirty, not other stairs', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: { ...deck, elevation: 1.6 } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false)
})
test('a deck polygon-only change leaves the attached stair alone', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: {
...deck,
polygon: [
[0, 0],
[5, 0],
[5, 5],
[0, 5],
],
} as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false)
})
})
describe('sync dirty helpers (pure)', () => {
const collect = () => {
const marked: string[] = []
return { marked, markDirty: (id: AnyNodeId) => marked.push(id) }
}
test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => {
const level = makeLevel('level_0', 0, 2.5, [
'wall_a',
'stair_a',
'ceiling_a',
'fence_a',
'zone_a',
'missing',
])
const nodes = nodesFor(
level,
makeChild('wall_a', 'wall', 'level_0'),
makeChild('stair_a', 'stair', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('fence_a', 'fence', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markLevelHeightDependents(level as never, nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']),
makeLevel('level_1', 1, 2.5, []),
makeChild('wall_a', 'wall', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_1', nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'wall_a'])
})
test('markCoveringDependentsBelow is a no-op for the lowest level', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a']),
makeChild('wall_a', 'wall', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_0', nodes, markDirty)
expect(marked).toEqual([])
})
})
@@ -1,6 +1,7 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema'
import { getLevelBelow } from '../../services/storey'
import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import {
@@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is added, mark overlapping items/walls dirty
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
}
}
@@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is removed, mark items/walls that were on it dirty (using current state)
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
}
}
@@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void {
}
}
} else if (node.type === 'slab' && prev.type === 'slab') {
if (
const supportChanged =
node.polygon !== prev.polygon ||
node.elevation !== prev.elevation ||
node.holes !== prev.holes
) {
if (supportChanged) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
@@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void {
markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
}
if (node.elevation !== prev.elevation) {
markDeckAttachedStairs(node.id, state.nodes, markDirty)
}
// The covering bound over the level below also moves with thickness
// (underside = elevation thickness) and recessed (pools never
// cover), which same-level support ignores.
if (
supportChanged ||
node.thickness !== prev.thickness ||
node.recessed !== prev.recessed
) {
markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty)
}
} else if (node.type === 'level' && prev.type === 'level') {
if (node.height !== prev.height) {
markLevelHeightDependents(node as LevelNode, state.nodes, markDirty)
}
} else if (node.type === 'wall' && prev.type === 'wall') {
if (
node.start !== prev.start ||
node.end !== prev.end ||
node.curveOffset !== prev.curveOffset ||
node.thickness !== prev.thickness
) {
// Rendered slab polygons adopt wall bands, so a wall reshape
// must reach the manager to refresh its wall map and drop the
// level's rendered-polygon cache.
spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes))
}
}
}
})
@@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i])
}
/**
* A level's stored height moved: plane-bound walls follow the new plane,
* stair rise re-derives, and ceilings/fences re-resolve their clamp — mark
* them all so their systems rebuild. Restacking the level containers alone
* leaves their geometry stale.
*/
export function markLevelHeightDependents(
level: LevelNode,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const childId of level.children) {
const child = nodes[childId]
if (!child) continue
if (
child.type === 'wall' ||
child.type === 'stair' ||
child.type === 'ceiling' ||
child.type === 'fence'
) {
markDirty(child.id)
}
}
}
/**
* A deck slab's walking surface moved: stairs attached to it via
* `deckSlabId` derive their rise from that elevation, so their geometry
* (and rise-derived affordances) must rebuild.
*/
export function markDeckAttachedStairs(
slabId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const node of Object.values(nodes)) {
if (node.type === 'stair' && node.deckSlabId === slabId) {
markDirty(node.id)
}
}
}
/**
* A slab on `slabLevelId` was created/deleted or changed shape/placement:
* the covering bound (slab underside) over the level BELOW moved, so that
* level's plane-bound walls and clamped ceilings must rebuild.
*/
export function markCoveringDependentsBelow(
slabLevelId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
const below = getLevelBelow(slabLevelId, nodes)
if (!below) return
for (const childId of below.children) {
const child = nodes[childId]
if (child?.type === 'wall' || child?.type === 'ceiling') {
markDirty(child.id)
}
}
}
/**
* Mark all floor items and walls that may be affected by a slab change as dirty.
*/
@@ -190,10 +284,11 @@ function markNodesOverlappingSlab(
if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes)
// Walls follow the slab's RENDERED footprint (band-adopted edges reach
// the wall's outer face), so the dirty gate must test the same polygon
// `getSlabElevationForWall` will re-evaluate — a stored polygon that
// stops short of the wall body would otherwise never re-elevate it.
// Walls AND floor-placed nodes follow the slab's RENDERED footprint
// (band-adopted edges reach the wall's outer face), so the dirty gate
// must test the same polygon the support queries re-evaluate — a stored
// polygon that stops short of the wall body would otherwise never
// re-elevate nodes sitting over the adopted band.
const levelWalls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
@@ -249,7 +344,7 @@ function markNodesOverlappingSlab(
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
slab.polygon,
renderedPolygon,
0.01,
)
) {
@@ -0,0 +1,217 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation'
import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager'
export type SupportSlabPatch = { supportSlabId: string | undefined }
export type SupportSlabPatchOptions = {
/**
* Pointer-decided support cap (level-local Y) — see
* `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host
* reproduces the CAPPED election: the elected lower slab wins over a
* deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the
* ground is elected while capped-out slabs still overlap the footprint.
*/
maxElevation?: number | null
}
export function resolveSupportSlabPatch(
node: AnyNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) {
return { supportSlabId: undefined }
}
const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null
const parent = parentId ? nodes[parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes })
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
let cappedOut = false
for (const footprint of footprints) {
const position = footprint.position ?? (node as { position?: unknown }).position
if (!Array.isArray(position) || position.length !== 3) continue
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
if (maxElevation != null && support.slabId === null && candidates.length > 0) {
cappedOut = true
}
}
if (winner !== null) {
return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined }
}
// Capped election chose the ground while overlapping slabs sit above the
// cap: persist the ground host, or the uncapped per-frame election would
// lift the committed node back onto the deck.
return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined }
}
export function resolveWallSupportSlabPatch(
wall: WallNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = wall.parentId ? nodes[wall.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
// Winner under the pointer cap (when given): a deck hanging above the
// aimed-at surface can't capture the elected base, so a wall drawn at the
// floor underneath it persists the floor slab the user actually targeted.
const support = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
null,
options?.maxElevation,
)
const candidateElevations = new Set<number>()
for (const node of Object.values(nodes)) {
if (node.type !== 'slab' || node.parentId !== parent.id) continue
const candidate = node as SlabNode
const preferred = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
candidate.id,
)
if (preferred.electedSlabId === candidate.id) {
candidateElevations.add(candidate.elevation)
}
}
return {
supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined,
}
}
/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */
export type FenceSupportInput = Pick<
FenceNode,
'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId'
>
/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */
const FENCE_CURVE_SUPPORT_SAMPLES = 16
/** Fallback fence thickness (schema default) when the node carries none. */
const DEFAULT_FENCE_THICKNESS = 0.08
/** Minimum band depth so the footprint survives the election's polygon inset. */
const MIN_FENCE_SUPPORT_BAND = 0.05
function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> {
if (fence.path && fence.path.length >= 2) {
return fence.path.map((point) => [point[0], point[1]])
}
const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 }
if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) {
const points: Array<[number, number]> = []
for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) {
const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES)
points.push([frame.point.x, frame.point.y])
}
return points
}
return [
[fence.start[0], fence.start[1]],
[fence.end[0], fence.end[1]],
]
}
/**
* Support-host patch for a fence: elect the slab the fence line stands on
* and persist it as `supportSlabId` (the fence lift resolves absent =
* level floor — see `packages/nodes/src/fence/lift.ts`).
*
* The centerline (chord, sampled arc, or spline path) is turned into thin
* band footprints and run through the same candidate machinery items use.
* `options.maxElevation` is the pointer-decided cap: aiming at the floor
* under a deck elects the floor, aiming at the deck top elects the deck.
*
* Persist rule: the items ambiguity rule (stacked candidates disagree)
* PLUS the elevated-host case — a winner sitting meaningfully above the
* level floor must be persisted even when unambiguous (a balcony deck with
* nothing underneath), or the commit loses the election entirely since
* fences run no per-frame election. A single default ground slab (its top
* within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so
* plain fences keep sitting at the level base. A capped-out election (all
* overlapping slabs above the aimed-at ground) also resolves to the floor
* via the same absent-host default. Pure; exported for tests.
*/
export function resolveFenceSupportSlabPatch(
fence: FenceSupportInput,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = fence.parentId ? nodes[fence.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND)
const points = fenceCenterlinePoints(fence)
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
for (let i = 1; i < points.length; i++) {
const [ax, az] = points[i - 1]!
const [bx, bz] = points[i]!
const length = Math.hypot(bx - ax, bz - az)
if (length < 1e-6) continue
const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2]
const dimensions: [number, number, number] = [length, 1, band]
// getItemFootprint's rotation convention: local +X maps to
// (cos yRot, sin yRot) in XZ, so the segment angle aligns the band.
const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0]
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position,
dimensions,
rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position,
dimensions,
rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
}
if (winner === null) return { supportSlabId: undefined }
const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON
return { supportSlabId: persist ? winner.slabId : undefined }
}
@@ -0,0 +1,628 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, AnyNodeId, SlabNode } from '../../schema'
import { WallNode } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top'
import { getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { initSpatialGridSync } from './spatial-grid-sync'
import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
const SQUARE: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(children: string[] = []): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
describe('persisted support hosts (items)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('no-host election over stacked slabs keeps returning the highest elevation', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a persisted host wins over the election, whichever slab it names', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node: hostedLow,
nodes: nodesFor(level, hostedLow),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.2)
expect(
getFloorPlacedElevation({
node: hostedHigh,
nodes: nodesFor(level, hostedHigh),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a host reshaped away falls back without clearing the field, and resumes on return', () => {
registerFloorPlacedItem()
const host = makeSlab('slab_low', SQUARE, 0.2)
addSlab(host)
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const args = {
node,
nodes: nodesFor(level, node),
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
// Reshape the host away from the item's footprint.
const movedAway: Array<[number, number]> = [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
]
spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8)
expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low')
// Reshape it back — the stale reference resumes hosting.
spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
})
test('getSlabSupportForItem surfaces the winning slab id', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0.8, slabId: 'slab_high' })
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0, slabId: null })
})
test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
addSlab(
makeSlab(
'slab_far',
[
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
0.5,
),
)
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([
{ slabId: 'slab_high', elevation: 0.8 },
{ slabId: 'slab_low', elevation: 0.2 },
])
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[20, 0, 20],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([])
})
test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => {
registerFloorPlacedItem()
const low = makeSlab('slab_low', SQUARE, 0.2)
const high = makeSlab('slab_high', SQUARE, 0.8)
addSlab(low)
addSlab(high)
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode)
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' })
spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID)
expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({
supportSlabId: undefined,
})
})
test('item support follows the RENDERED slab polygon (wall band adoption)', () => {
registerFloorPlacedItem()
// Room slab drawn on the wall centerlines; the rendered polygon
// extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls).
const roomPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }),
]
const level = makeLevel(walls.map((wall) => wall.id))
const node = makeFloorNode()
useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) })
// Grounded raised floor (thickness = elevation): band adoption only
// applies to grounded slabs — a floating deck keeps its drawn polygon.
addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 }))
// Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a
// 0.01 overlap inset) but inside the rendered band edge at x = 4.05.
const elevation = spatialGridManager.getSlabElevationForItem(
LEVEL_ID,
[4.3, 0, 1.5],
[0.6, 1, 0.6],
[0, 0, 0],
)
expect(elevation).toBeCloseTo(0.4)
// The manager sees wall changes: removing the walls drops the adopted
// band, so the same footprint stops electing the slab.
for (const wall of walls) {
spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID)
}
useScene.setState({ nodes: nodesFor(makeLevel(), node) })
expect(
spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]),
).toBe(0)
})
})
describe('persisted support hosts (walls, via the manager)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('preferred slab pins the elected elevation; invalid preference falls back', () => {
const polygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
addSlab(makeSlab('slab_low', polygon, 0.1))
addSlab(makeSlab('slab_high', polygon, 0.6))
const start: [number, number] = [0, 1.5]
const end: [number, number] = [4, 1.5]
const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end)
expect(elected.elevation).toBeCloseTo(0.6)
expect(elected.electedSlabId).toBe('slab_high')
const preferred = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_low',
)
expect(preferred.elevation).toBeCloseTo(0.1)
expect(preferred.electedSlabId).toBe('slab_low')
const fallback = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_missing',
)
expect(fallback.elevation).toBeCloseTo(0.6)
expect(fallback.electedSlabId).toBe('slab_high')
})
test('resolveWallSupportSlabPatch persists the winner over two elevations', () => {
const low = makeSlab(
'slab_low',
[
[-2, -1],
[0, -1],
[0, 1],
[-2, 1],
],
0.2,
)
const high = makeSlab(
'slab_high',
[
[0, -1],
[2, -1],
[2, 1],
[0, 1],
],
0.8,
)
const wall = WallNode.parse({
id: 'wall_test',
parentId: LEVEL_ID,
start: [-2, 0],
end: [2, 0],
thickness: 0.1,
})
const level = makeLevel([low.id, high.id, wall.id])
const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(low)
addSlab(high)
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({
supportSlabId: 'slab_high',
})
})
// Elevated deck stacked over a ground floor slab — the "wall on a deck"
// fixture (both slabs cover the wall band; the deck sits above).
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDeckOverFloorFixture() {
const deck = makeSlab(
'slab_deck',
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
DECK_ELEVATION,
)
const ground = makeSlab(
'slab_ground',
[
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
],
FLOOR_ELEVATION,
)
const wall = WallNode.parse({
id: 'wall_on_deck',
parentId: LEVEL_ID,
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.1,
})
const level = makeLevel([deck.id, ground.id, wall.id])
const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(deck)
addSlab(ground)
return { wall, nodes }
}
test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const support = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
)
expect(support.electedSlabId).toBe('slab_deck')
expect(support.elevation).toBeCloseTo(DECK_ELEVATION)
// Wall-top inversion: no stored height → the top stays at the storey
// plane, so the extruded body is the plane minus the deck base.
const storeyHeight = 2.7
expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight)
expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo(
storeyHeight - DECK_ELEVATION,
)
// Commit persists the deck deterministically (two candidate elevations).
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const capped = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
null,
FLOOR_ELEVATION,
)
expect(capped.electedSlabId).toBe('slab_ground')
expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION)
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_ground',
})
// Aiming at the deck top keeps the deck.
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
})
})
describe('deleteNodesAction strips supportSlabId references', () => {
let stopSync = () => {}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
registerFloorPlacedItem()
const slabLow = makeSlab('slab_low', SQUARE, 0.2)
const slabHigh = makeSlab('slab_high', SQUARE, 0.8)
const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const level = makeLevel(['slab_low', 'slab_high', item.id])
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item),
readOnly: false,
rootNodeIds: [LEVEL_ID as AnyNodeId],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
function itemElevation(): number {
const nodes = useScene.getState().nodes
const item = nodes['item_test' as AnyNodeId]!
return getFloorPlacedElevation({
node: item,
nodes,
position: [0, 0, 0],
rotation: [0, 0, 0],
})
}
test('deleting the host slab clears the reference and re-elects; undo restores both', () => {
expect(itemElevation()).toBeCloseTo(0.2)
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined()
expect(
(afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId,
).toBeUndefined()
expect(itemElevation()).toBeCloseTo(0.8)
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe(
'slab_low',
)
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting a non-host slab leaves the reference alone', () => {
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string })
.supportSlabId,
).toBe('slab_low')
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: {
...useScene.getState().nodes,
stair_test: stair,
[LEVEL_ID]: {
...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!,
children: ['slab_low', 'slab_high', 'item_test', 'stair_test'],
} as AnyNode,
} as never,
})
clearSceneHistory()
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(
(afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBeUndefined()
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe(
'slab_low',
)
})
test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: { ...useScene.getState().nodes, stair_test: stair } as never,
})
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBe('slab_low')
})
})
@@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
const bottom = walls[0]!
expect(
@@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => {
).toBeCloseTo(0.1)
})
it('elects a floating deck for a wall standing on its drawn footprint', () => {
// Wall ON a deck: no band adoption needed — the wall body lies inside
// the deck's drawn polygon, which is exactly what a floating slab
// renders.
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
const wallOnDeck = parseWall([1, 2], [3, 2])
expect(
computeWallSlabElevation(
{ start: [1, 2], end: [3, 2], thickness: 0.1 },
[deck],
[wallOnDeck],
),
).toBeCloseTo(1.5)
})
it('a wall in the adoption band beside a floating deck does not stand on it', () => {
// Centerline 6cm below the deck's bottom edge — inside the adoption
// band (half-thickness + 0.06) but the body never reaches the drawn
// footprint. A grounded slab adopts the band and carries the wall; the
// deck keeps its drawn polygon and offers no support.
const bandWall = parseWall([0, -0.06], [4, -0.06])
const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness }
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0)
const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1)
})
it('lifts a wall whose body a legacy stored polygon falls short of', () => {
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
// 1cm short of even the inner faces, so the STORED polygon never
@@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
// Grounded (thickness = elevation): band adoption only applies to
// grounded room floors under the vertical model.
const slab = SlabNode.parse({
polygon: [
[0.06, 0.06],
@@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => {
[0.06, 3.94],
],
elevation: 0.1,
thickness: 0.1,
})
const bottom = walls[0]!
@@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => {
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
).toEqual({
elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
@@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 2 / 3, elevation: 0.6 },
@@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => {
it('keeps a shared wall on the higher slab that carries the full wall band', () => {
const sharedWall = parseWall([4, 0], [4, 4])
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
// Raised room floor: grounded (thickness = elevation) so the band-carry
// rule applies — a floating deck would keep its drawn polygon instead.
const high = SlabNode.parse({
polygon: [
[4, 0],
@@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => {
[4, 4],
],
elevation: 0.6,
thickness: 0.6,
})
expect(
@@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
@@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => {
parseWall([8, 1.5], [8, 4.5]),
parseWall([8, 4.5], [4, 4.5]),
]
// Grounded raised room floor (see the shared-wall test above).
const high = SlabNode.parse({
polygon: [
[0, 0],
@@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => {
[0, 3],
],
elevation: 0.6,
thickness: 0.6,
})
const low = SlabNode.parse({
polygon: [
@@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 },
+23 -3
View File
@@ -46,12 +46,16 @@ export {
} from './hooks/scene-registry/scene-registry'
export {
type FloorPlacedElevationArgs,
GROUND_SUPPORT_ID,
getFloorPlacedElevation,
getFloorPlacedFootprints,
getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation'
export {
getWallEffectiveHeightForNodes,
type PointedSupportSurface,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
spatialGridManager,
type WallSlabSupportSegment,
} from './hooks/spatial-grid/spatial-grid-manager'
@@ -61,6 +65,14 @@ export {
resolveBuildingForLevel,
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export {
type FenceSupportInput,
resolveFenceSupportSlabPatch,
resolveSupportSlabPatch,
resolveWallSupportSlabPatch,
type SupportSlabPatch,
type SupportSlabPatchOptions,
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export {
@@ -123,7 +135,6 @@ export {
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
projectAutoSlabsForPlan,
resolveAutoZonePolygon,
resumeSpaceDetection,
type Space,
@@ -245,9 +256,7 @@ export {
} from './systems/elevator/elevator-runtime'
export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system'
export {
DEFAULT_ELEVATOR_LEVEL_HEIGHT,
type ElevatorLevelEntry,
getElevatorLevelHeight,
resolveElevatorBuildingLevels,
resolveElevatorLevels,
resolveElevatorServiceLevelIds,
@@ -266,10 +275,16 @@ export {
isSplineFence,
sampleFenceSpline,
} from './systems/fence/fence-spline'
export {
clampSlabElevationForWalls,
getSlabElevationUpperBound,
type SlabElevationClamp,
} from './systems/slab/slab-support'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { resolveStairTotalRise } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
@@ -310,6 +325,11 @@ export {
type WallMoveLinkedWallTargetPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export {
MIN_WALL_HEIGHT,
resolveWallEffectiveHeight,
resolveWallTop,
} from './systems/wall/wall-top'
export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
+94 -5
View File
@@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1)
return WallNode.parse({ start, end, thickness })
}
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) {
return SlabNode.parse(
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation },
)
function slabOf(
polygon: Array<[number, number]>,
autoFromWalls = true,
elevation?: number,
thickness?: number,
) {
return SlabNode.parse({
polygon,
autoFromWalls,
...(elevation === undefined ? {} : { elevation }),
// Raised ROOM FLOOR fixtures pass thickness = elevation so the slab
// stays grounded (underside 0) — adoption/seam rules only apply to
// grounded slabs; the schema-default 0.05 thickness would make an
// elevated fixture a floating deck.
...(thickness === undefined ? {} : { thickness }),
})
}
function xs(polygon: Array<[number, number]>) {
@@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.34,
0.34,
)
const low = slabOf(
[
@@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.4,
0.4,
)
const legacyLow = slabOf(
[
@@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => {
})
test('stacked slabs are not mistaken for rooms across a wall', () => {
// The platform is a grounded raised floor (thickness = elevation); the
// floating-deck variant of this shape is covered by the adoption-gate
// tests below.
const floor = slabOf(roomA, false, 0.05)
const platform = slabOf(roomA, false, 0.4)
const platform = slabOf(roomA, false, 0.4, 0.4)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
@@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.3,
0.3,
)
const stepLow = slabOf(
[
@@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => {
],
true,
0.4,
0.4,
)
const low = slabOf(
[
@@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => {
})
})
describe('grounded adoption gate', () => {
// Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS
// tile with the walls standing on them. It applies only to grounded
// slabs (underside ≈ 0) and recessed pools; a floating deck keeps its
// drawn polygon exactly.
test('a floating deck near walls keeps its drawn polygon exactly', () => {
// Same footprint as roomA — every edge inside a wall adoption band —
// but floating at 1.5m: no edge may extend to a wall face.
const deck = slabOf(roomA, false, 1.5, 0.05)
const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] })
expect(poly).toEqual(roomA)
})
test('boundary case: underside 0.005 still counts as grounded and adopts', () => {
const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05)
const poly = getRenderableSlabPolygon(nearlyGrounded, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a slab floated just past the epsilon stops adopting', () => {
// Underside 0.02 > 0.01 epsilon — already a deck.
const justFloating = slabOf(roomA, false, 0.07, 0.05)
const poly = getRenderableSlabPolygon(justFloating, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(0)
})
test('a recessed pool keeps band adoption (unchanged)', () => {
// Recessed slabs are sunk into the ground, never floating — their
// negative elevation encodes depth, so the gate must not strip the
// wall-face extension a sunken room floor relies on.
const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true })
const poly = getRenderableSlabPolygon(pool, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a grounded floor ignores a floating deck sibling as a seam target', () => {
// Deck butted across the x=4 wall band: were it a room floor, the
// grounded (lower) floor would terminate at its own wall face (3.95).
// As a deck it is no seam partner — the floor adopts the wall's outer
// face (4.05) as if alone, and the deck itself stays as drawn.
const floor = slabOf(roomA, false, 0.05)
const deck = slabOf(roomB, false, 1.5, 0.05)
const walls = [wallOf([4, 0], [4, 3])]
const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] })
const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] })
expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05)
expect(deckPoly).toEqual(roomB)
})
})
describe('snapSlabEdgeToWallBand', () => {
test('an edge inside the band snaps onto the wall centerline', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])])
+32 -1
View File
@@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint'
* render offsets.
* - FREE — no neighbour, no wall. Rendered exactly as drawn.
*
* The whole machinery exists to make ROOM FLOORS tile with the walls
* standing on them, so it only applies to GROUNDED slabs (underside on
* the level plane) and recessed pools. A floating deck keeps its drawn
* polygon exactly — it must not grow into a wall it happens to float
* beside — and is symmetrically ignored as a seam target by its
* grounded siblings.
*
* Sub-edges of one edge with different projections are joined by a
* perpendicular STEP connector at the breakpoint. Breakpoints sit on
* candidate span boundaries — wall junctions — so the step's vertical
@@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02
const CURVED_WALL_SAMPLE_SEGMENTS = 32
const SLAB_SEAM_ELEVATION_EPSILON = 1e-4
const DEFAULT_SLAB_ELEVATION = 0.05
const DEFAULT_SLAB_THICKNESS = 0.05
/**
* A non-recessed slab whose underside (`elevation thickness`) rises
* above the level plane by more than this is a floating deck: it keeps
* its drawn polygon (no wall adoption, no seam projection) and grounded
* siblings don't seam toward it.
*/
const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01
/** Prevent near-parallel offset lines from producing unbounded corner spikes. */
const MAX_CORNER_MITER_RATIO = 10
/**
* Floating deck test — see the module header. Recessed pools are never
* floating: their negative elevation encodes depth, not placement.
*/
function isFloatingSlab(slab: SlabNode): boolean {
if (slab.recessed) return false
const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION
const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS
return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON
}
export type SlabPolygonContext = {
/** Walls on the slab's level. */
walls: WallNode[]
@@ -138,7 +164,7 @@ export function getRenderableSlabPolygon(
context: SlabPolygonContext,
): Array<[number, number]> {
const polygon = slabNode.polygon
if (polygon.length < 3) {
if (polygon.length < 3 || isFloatingSlab(slabNode)) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
@@ -368,6 +394,11 @@ function computeEdgeSubSpans(
const neighborSegments: NeighborSegment[] = []
for (const sibling of context.siblingSlabs) {
// A floating deck keeps its drawn polygon, so it can't be a seam
// partner: projecting toward it would move this slab's edge while the
// deck's stays put (asymmetric seam), and the higher/lower band rules
// only describe room floors meeting under a wall.
if (isFloatingSlab(sibling)) continue
const siblingPolygon = sibling.polygon
if (siblingPolygon.length < 2) continue
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
+252 -32
View File
@@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resolveCeilingHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import {
detectSpacesForLevel,
initSpaceDetectionSync,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
@@ -38,16 +42,35 @@ function slab(elevation: number) {
}
describe('planAutoCeilingsForLevel', () => {
test('creates auto ceilings at the top of the room walls', () => {
test('creates auto ceilings height-less so they follow the level top', () => {
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
walls: squareWalls(),
slabs: [slab(0.05)],
storeyHeight: 2.7,
}).create[0]
expect(created?.height).toBeCloseTo(2.55)
expect(created).toBeDefined()
// Follows-mode: no stored height — the effective height derives from
// the clamp bound at read time via resolveCeilingHeight.
expect('height' in created!).toBe(false)
expect(created?.autoFromWalls).toBe(true)
})
test('updates existing auto ceiling height when the slab elevation changes', () => {
test('never writes a height onto a matched auto ceiling', () => {
const ceiling = CeilingNode.parse({
polygon: square,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
storeyHeight: 3,
})
// Same polygon, follows-mode height — nothing to update.
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
test('a leftover explicit height on a matched auto ceiling is not rewritten', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
@@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => {
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
storeyHeight: 3,
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(ceiling.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.9)
})
test('updates existing auto ceiling height when wall height changes', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(3),
slabs: [slab(0.05)],
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.data.height).toBeCloseTo(3.05)
// The sync no longer re-derives auto heights; a user-set explicit
// height survives (still under the bound, so no clamp either).
expect(plan.update).toHaveLength(0)
})
test('does not replace a manual ceiling with an auto ceiling', () => {
@@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => {
autoFromWalls: false,
})
// Storey plane above the stored 2.5 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
storeyHeight: 2.7,
})
expect(plan.create).toHaveLength(0)
@@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => {
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data })
expect(demoted.autoFromWalls).toBe(false)
// Storey plane above the stored 2.55 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], {
walls: squareWalls(),
slabs: [slab(0.05)],
storeyHeight: 2.7,
})
expect(plan.create).toHaveLength(0)
@@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => {
})
})
// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper
// level's plane) covers the queried level below, so the clamp bound is
// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck).
function stackedDeckNodes(): Record<AnyNodeId, AnyNode> {
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const list: AnyNode[] = [
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_1',
level: 1,
height: 2.5,
parentId: 'building_a',
children: ['slab_deck'],
}),
deck,
]
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('stage 3-B ceiling clamp bound', () => {
test('height-less auto ceilings resolve under the covering-slab bound at read time', () => {
const nodes = stackedDeckNodes()
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
}).create[0]
expect(created).toBeDefined()
expect('height' in created!).toBe(false)
// Follows-mode: the effective height is the deck-limited bound.
expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19)
})
test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.49)
})
test('never raises a manual ceiling sitting below the bound', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(0)
})
test('skips follows-mode manual ceilings (never converts them to explicit)', () => {
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.update).toHaveLength(0)
})
test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => {
// Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck
// bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01.
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.height).toBeCloseTo(2.19)
})
})
// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped
// scene store (getState/subscribe/temporal) whose write methods mutate the
// nodes record and re-notify, and an editor store carrying `spaces`.
function createSceneStoreStub(initialNodes: Record<string, AnyNode>) {
const listeners = new Set<(state: unknown) => void>()
const state: Record<string, unknown> & { nodes: Record<string, AnyNode> } = {
nodes: initialNodes,
}
const notify = () => {
for (const listener of [...listeners]) listener(state)
}
state.updateNodes = (updates: Array<{ id: string; data: Record<string, unknown> }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { id, data } of updates) {
const existing = next[id]
if (existing) next[id] = { ...existing, ...data } as AnyNode
}
state.nodes = next
notify()
}
state.deleteNodes = (ids: string[]) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const id of ids) delete next[id]
state.nodes = next
notify()
}
state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { node, parentId } of entries) {
next[node.id] = { ...node, parentId } as AnyNode
const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined
if (parent) {
next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode
}
}
state.nodes = next
notify()
}
return {
getState: () => state,
subscribe: (listener: (state: unknown) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
temporal: { getState: () => ({ pause() {}, resume() {} }) },
setNodes(next: Record<string, AnyNode>) {
state.nodes = next
notify()
},
}
}
function createEditorStoreStub() {
const state = {
spaces: {} as Record<string, unknown>,
setSpaces(next: Record<string, unknown>) {
state.spaces = next
},
}
return { getState: () => state }
}
describe('reactive ceiling re-clamp through the detection sync', () => {
test('a flush deck created on the level above clamps the existing manual ceiling below', () => {
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }),
WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }),
WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }),
WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }),
]
const manualCeiling = CeilingNode.parse({
id: 'ceiling_main',
parentId: 'level_0',
polygon: square,
height: 2.49,
autoFromWalls: false,
})
const initialNodes = Object.fromEntries(
[
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({
id: 'level_0',
level: 0,
height: 2.5,
parentId: 'building_a',
children: [...walls.map((wall) => wall.id), 'ceiling_main'],
}),
LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }),
...walls,
manualCeiling,
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const sceneStore = createSceneStoreStub(initialNodes)
const editorStore = createEditorStoreStub()
const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore)
try {
// Scenario gate 11's reactive half: the deck lands on the level
// ABOVE, so only the covering-underside part of level_0's structure
// snapshot changes — the sync must still re-run and clamp down.
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const current = sceneStore.getState().nodes
const levelAbove = current.level_1 as AnyNode
sceneStore.setNodes({
...current,
slab_deck: deck,
level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode,
})
const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode
expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01)
} finally {
unsubscribe()
}
})
})
describe('detectSpacesForLevel', () => {
const areaOf = (polygon: Array<{ x: number; y: number }>) => {
let area = 0
+112 -112
View File
@@ -2,12 +2,21 @@ import {
type AnyNodeId,
CeilingNode,
type CeilingNode as CeilingNodeType,
type LevelNode,
SlabNode,
type SlabNode as SlabNodeType,
type WallNode,
ZoneNode,
type ZoneNode as ZoneNodeType,
} from '../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
import {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
getCeilingClampBound,
getLevelElevations,
getStoredLevelHeight,
} from '../services/storey'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
@@ -56,10 +65,6 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf>
}
type DetectedCeilingRoom = DetectedRoom & {
ceilingHeight: number
}
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
@@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = {
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6
const ROOM_CURVE_TOLERANCE = 0.04
const MAX_CURVE_SUBDIVISION_DEPTH = 6
@@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6
const COVERAGE_SAMPLE_STEPS = 12
// Auto ceilings are created height-less (follows-mode: they track the
// clamp bound live through `resolveCeilingHeight`), so the planner needs
// no wall/slab inputs anymore — only the bound for the explicit-height
// reactive re-clamp below.
export type AutoCeilingPlanningContext = {
walls?: WallNode[]
slabs?: SlabNodeType[]
/** Stored storey height of the level being planned (floor-to-floor). */
storeyHeight?: number
/**
* Stage 3-B clamp-bound resolver for a polygon on the planned level:
* `min(storey plane, lowest covering-slab underside from the level
* above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent
* (pure-planner callers without a nodes record), the bound degrades to
* the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`.
*/
ceilingClampBound?: (polygon: Array<[number, number]>) => number
}
function pointFromTuple(point: [number, number]): Point2D {
@@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) {
return matchingPoints.length >= 2
}
function pointIsOnSlab(point: Point2D, slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
const slabPolygon = slab.polygon.map(pointFromTuple)
if (!pointInPolygon(point, slabPolygon)) return false
for (const hole of slab.holes ?? []) {
if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
return false
}
}
return true
}
function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
return true
}
return pointIsOnSlab(polygonCentroid(roomPolygon), slab)
}
function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) {
let maxElevation = 0
for (const slab of slabs) {
if (!slabSupportsRoom(roomPolygon, slab)) continue
maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION)
}
return maxElevation
}
function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) {
let maxHeight = 0
for (const wall of walls) {
if (!wallBoundsRoom(wall, roomPolygon)) continue
const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT
if (Number.isFinite(height)) {
maxHeight = Math.max(maxHeight, height)
}
}
return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT
}
function resolveAutoCeilingHeight(
roomPolygon: Point2D[],
context: AutoCeilingPlanningContext = {},
/**
* The clamp bound for a ceiling polygon under this planning context —
* the context's cross-level resolver when provided, else the plane-only
* `storeyHeight - CEILING_CLAMP_MARGIN` degradation.
*/
function resolveCeilingClampBound(
polygon: Array<[number, number]>,
context: AutoCeilingPlanningContext,
) {
return (
resolveRoomSlabElevation(roomPolygon, context.slabs) +
resolveRoomWallHeight(roomPolygon, context.walls)
)
if (context.ceilingClampBound) return context.ceilingClampBound(polygon)
return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN
}
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
@@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) {
wall.end[0].toFixed(4),
wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4),
(wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
// Plane-bound (no stored height) is a distinct state, not a default
// value: it resolves to the storey plane, so it must not alias an
// explicit height of the same magnitude in the trigger signature.
wall.height == null ? 'plane' : wall.height.toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4),
].join('|')
}
@@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) {
].join('|')
}
// Slabs and ceilings stay out of the trigger signature: including generated
// surfaces caused delete/recreate feedback. Zones are included only so a newly
// traced room footprint can adopt its enclosing walls without waiting for the
// next remodel.
// Slab/ceiling POLYGONS stay out of the trigger signature: including
// generated footprints caused delete/recreate feedback. Zones are included
// only so a newly traced room footprint can adopt its enclosing walls
// without waiting for the next remodel. Slab ELEVATIONS and the level's
// stored storey height ARE included — both feed the explicit-ceiling
// re-clamp bound (the storey plane), and neither is rewritten by
// the sync, so regeneration triggers when they change without feedback.
// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation
// thickness, recessed pools excluded): a deck created, lowered, or
// thickened above must re-run the sync below so ceilings re-clamp under
// it. Same polygon exclusion applies — the level-above's own auto sync
// rewrites its slab footprints, and hashing them here would re-trigger
// this level on every remodel above.
function levelStructureSnapshots(nodes: Record<string, any>) {
const wallsByLevel = new Map<string, WallNode[]>()
const zonesByLevel = new Map<string, ZoneNodeType[]>()
const slabElevationsByLevel = new Map<string, string[]>()
const coveringUndersidesByLevel = new Map<string, string[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
@@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record<string, any>) {
const zones = zonesByLevel.get(levelId) ?? []
zones.push(ZoneNode.parse(node))
zonesByLevel.set(levelId, zones)
} else if ((node as any).type === 'slab') {
const elevations = slabElevationsByLevel.get(levelId) ?? []
elevations.push(
`${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`,
)
slabElevationsByLevel.set(levelId, elevations)
if ((node as any).recessed !== true) {
const undersides = coveringUndersidesByLevel.get(levelId) ?? []
const elevation = ((node as any).elevation as number | undefined) ?? 0.05
const thickness = ((node as any).thickness as number | undefined) ?? 0.05
undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`)
coveringUndersidesByLevel.set(levelId, undersides)
}
}
}
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, any>)
const snapshots = new Map<string, string>()
const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()])
for (const levelId of levelIds) {
const walls = wallsByLevel.get(levelId) ?? []
const zones = zonesByLevel.get(levelId) ?? []
const level = nodes[levelId]
const storeyKey =
level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : ''
const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';')
const aboveId = findLevelAboveId(levelId, levelElevations)
const aboveSlabKey = aboveId
? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';')
: ''
snapshots.set(
levelId,
`${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`,
`${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`,
)
}
@@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel(
return plan
}
export function projectAutoSlabsForPlan(
existingSlabs: SlabNodeType[],
plan: AutoSlabSyncPlan,
): SlabNodeType[] {
const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]))
for (const id of plan.delete) {
slabsById.delete(id)
}
for (const update of plan.update) {
const slab = slabsById.get(update.id)
if (!slab) continue
slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }))
}
for (const slab of plan.create) {
slabsById.set(slab.id, slab)
}
return [...slabsById.values()]
}
export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
@@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel(
)
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple))
const detectedAll: DetectedCeilingRoom[] = roomPolygons
const detectedAll: DetectedRoom[] = roomPolygons
.map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple,
@@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel(
centroid: polygonCentroid(room.poly),
area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
}))
const detected = detectedAll.filter(
@@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const updatesById = new Map<string, { polygon: [number, number][] }>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
@@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(existing.ceiling.id)
updatesById.set(existing.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
})
@@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
updatesById.set(bestMatch.entry.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
}
@@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel(
}
}
// Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
// created, moved, or thickened on the level above can leave an EXISTING
// manual explicit-height ceiling poking into its solid. Clamp explicit
// heights down to the bound; never raise them — a user-lowered ceiling
// is intent, only an over-bound one is a conflict. Follows-mode
// ceilings (absent height) derive under the bound by construction and
// are skipped, so the clamp can never convert one to an explicit
// height.
const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => {
if (ceiling.height == null) return []
const bound = resolveCeilingClampBound(ceiling.polygon, context)
if (!Number.isFinite(bound)) return []
return ceiling.height > bound + CEILING_HEIGHT_EPSILON
? [{ id: ceiling.id, data: { height: bound } }]
: []
})
const ceilingsToUpdate = [
// Auto ceilings only track their room's POLYGON here — their height is
// follows-mode (absent) and derives from the level top at read time.
...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const update = updatesById.get(ceiling.id)
if (!update) return []
const data: Partial<CeilingNodeType> = {}
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
if (sameTuplePolygon(ceiling.polygon, update.polygon)) return []
return [{ id: ceiling.id, data: { polygon: update.polygon } }]
}),
...ceilingDemotions,
...manualClamps,
]
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
@@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel(
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
plannedCeilingsForNaming.push({ name })
// Height-less on purpose: auto ceilings follow the level top (the
// clamp bound) through `resolveCeilingHeight` instead of baking a
// derived height that would go stale on level-height edits.
ceilingsToCreate.push(
CeilingNode.parse({
name,
polygon: room.poly.map(pointToTuple),
holes: [],
height: room.ceilingHeight,
autoFromWalls: true,
}),
)
@@ -1402,14 +1395,21 @@ function runSpaceDetection(
}
const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan)
syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const levelNode = nodes[levelId]
const storeyHeight =
levelNode?.type === 'level'
? getStoredLevelHeight(levelNode as LevelNode)
: DEFAULT_LEVEL_HEIGHT
syncAutoCeilingsForLevel(
levelId,
roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore,
{ walls, slabs: projectedSlabs },
{
storeyHeight,
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
},
)
const zonePlan = planAutoZonesForLevel(
spaces,
@@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record<string, AnyNode> {
function roomNodes() {
const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon })
const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({
id: 'ceiling_room',
parentId: 'level_main',
polygon,
height: 2.5,
})
const walls = polygon.map((start, index) =>
WallNode.parse({
id: `wall_${index}`,
+14 -3
View File
@@ -1,6 +1,11 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNodeId } from '../schema/types'
import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height'
import { getWallPlaneTop } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../systems/wall/wall-top'
import { detectSpacesForLevel, type Space } from './space-detection'
type Point2D = readonly [number, number]
@@ -473,6 +478,12 @@ export function deriveZoneQuantityReport(
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
: []
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab')
const wallEffectiveHeight = (wall: WallNode) => {
const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId)
const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT
return resolveWallEffectiveHeight(wall, planeTop, support.elevation)
}
const edgeLengths = zone.polygon.map((start, index) => {
const end = zone.polygon[(index + 1) % zone.polygon.length]
return end ? pointDistance(start, end) : 0
@@ -488,7 +499,7 @@ export function deriveZoneQuantityReport(
const ceilingCoverage = proveSurfaceCoverage(
zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
(node) => node.height,
(node) => resolveCeilingHeight(node, sceneNodes as Record<AnyNodeId, AnyNode>),
{ singular: 'ceiling', plural: 'Ceilings', datum: 'heights' },
)
@@ -517,7 +528,7 @@ export function deriveZoneQuantityReport(
? {
status: 'available' as const,
value: wallSpans!.reduce(
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
(sum, span) => sum + span.length * wallEffectiveHeight(span.wall),
0,
),
note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
+1 -1
View File
@@ -184,7 +184,7 @@ export {
SkylightType,
type SkylightTypePreset,
} from './nodes/skylight'
export { SlabNode } from './nodes/slab'
export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab'
export {
SolarPanelMaterialRole,
SolarPanelNode,
@@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
+7 -1
View File
@@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
height: z.number().default(2.5), // Height in meters
// Height in meters. Absent = the ceiling follows the level top: its
// effective height is the same bound its write-clamp uses —
// min(storey plane, lowest covering-slab underside over the polygon)
// CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an
// explicit custom height, still write-clamped under that bound.
height: z.number().optional(),
autoFromWalls: z.boolean().default(false),
}).describe(
dedent`
@@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({
- polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- height: explicit height in meters; absent = follows the level top automatically
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`,
)
+2
View File
@@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({
type: nodeType('column'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
style: ColumnStyle.default('plain'),
crossSection: ColumnCrossSection.default('round'),
height: z.number().positive().default(2.5),
@@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians.
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'),
// Which surface the terminal mounts on. Drives face orientation and
// which way the collar (and its port) points.
+4
View File
@@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
// Persisted slab-support host — the fence sits on that slab's walking
// surface (see ItemNode.supportSlabId for the host rules).
supportSlabId: z.string().optional(),
curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2),
@@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({
- path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
- height/thickness: overall fence dimensions in meters
- supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation)
- curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set)
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
@@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians.
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'),
// Cabinet dimensions in meters. Defaults match a typical upflow
// furnace cabinet (~22" × 28" footprint, ~43" tall).
+14
View File
@@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Persisted floor-support host (canonical doc — the same field on other
// floor-placed kinds and walls follows these rules). Written at
// placement/commit ONLY when overlapping slabs disagree on elevation
// (ambiguity); absent/null means "elect the support fresh on every
// read", which is the historical behavior. Read paths PREFER this slab
// while it still exists and still overlaps the node's footprint, and
// silently fall back to election otherwise. Deleting the host slab
// strips the field (deleteNodesAction); a host merely reshaped away is
// deliberately kept so hosting resumes if the slab's polygon returns.
// The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the
// level base — written when a pointer-capped commit elected the ground
// while a slab (e.g. an elevated deck) still overlapped the footprint.
supportSlabId: z.string().optional(),
// Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -48,4 +48,9 @@ describe('LevelNode', () => {
nodes.map((node) => node.id),
)
})
test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
expect('height' in LevelNode.parse({})).toBe(false)
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
})
})
+7
View File
@@ -59,11 +59,18 @@ export const LevelNode = BaseNode.extend({
.default([]),
// Specific props
level: z.number().default(0),
/**
* Stored storey height in meters (floor-to-floor). No zod default on
* purpose: absence marks unmigrated legacy data and gates the load-time
* migration; a schema default would materialize silently through .parse().
*/
height: z.number().optional(),
}).describe(
dedent`
Level node - used to represent a level in the building
- children: array of architectural, equipment, and MEP distribution nodes
- level: level number
- height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data
`,
)
+2
View File
@@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({
children: z.array(ItemNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
// Dimensions (meters). Schema-level defaults intentionally reproduce
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
+11 -2
View File
@@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's
// 0.01 underside offset. Applies to edits only; migration writes legacy
// intervals verbatim (including degenerate zero-thickness slabs).
export const MIN_SLAB_THICKNESS = 0.02
export const SlabNode = BaseNode.extend({
id: objectId('slab'),
type: nodeType('slab'),
@@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
elevation: z.number().default(0.05), // Elevation in meters
elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane
thickness: z.number().default(0.05), // Grows downward from the surface
recessed: z.boolean().default(false),
autoFromWalls: z.boolean().default(false),
}).describe(
dedent`
@@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({
- polygon: array of [x, z] points defining the slab boundary
- holes: array of [x, z] polygons representing cutouts in the slab
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- elevation: elevation in meters
- elevation: the walking surface (slab top), in meters above the level plane
- thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation]
- recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
`,
)
+2
View File
@@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({
type: nodeType('spawn'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
})
export type SpawnNode = z.infer<typeof SpawnNode>
+8 -1
View File
@@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
stairType: StairType.default('straight'),
fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null),
// Destination deck (a slab id). When set, the stair's rise follows that
// slab's elevation live. An explicit `totalRise` still wins when BOTH are
// set (edge case — the panel clears the custom rise when attaching).
deckSlabId: z.string().optional(),
slabOpeningMode: StairSlabOpeningMode.default('none'),
openingOffset: z.number().default(0),
width: z.number().default(1.0),
totalRise: z.number().default(2.5),
totalRise: z.number().optional(),
stepCount: z.number().default(10),
thickness: z.number().default(0.25),
fillToFloor: z.boolean().default(true),
@@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({
- rotation: rotation around Y axis
- stairType: straight (segment-based), curved (arc-based), or spiral
- fromLevelId / toLevelId: source and destination levels used for auto slab cutouts
- deckSlabId: destination deck (slab) — the rise derives from its elevation while set
- slabOpeningMode: whether a destination-level slab opening is generated for this stair
- openingOffset: extra opening expansion applied after the cutout polygon is computed
- width: stair width
+24 -18
View File
@@ -41,16 +41,19 @@ describe('wall face bands', () => {
})
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
getWallFaceBandConfig(
{
height: 2.5,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
},
},
}),
2.5,
),
).toMatchObject({
count: 3,
lowerTop: 0.84,
@@ -60,16 +63,19 @@ describe('wall face bands', () => {
test('four bands adds an upper split below the final top band', () => {
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.6,
upperHeight: 0.7,
getWallFaceBandConfig(
{
height: 2.5,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.6,
upperHeight: 0.7,
},
},
}),
2.5,
),
).toMatchObject({
count: 4,
lowerTop: 0.5,
+9 -3
View File
@@ -151,6 +151,8 @@ export const WallNode = BaseNode.extend({
thickness: z.number().optional(),
height: z.number().optional(),
curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
faceBands: WallFaceBandConfig.optional(),
skirting: WallTrimConfig.optional(),
crown: WallTrimConfig.optional(),
@@ -198,8 +200,11 @@ export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
}
export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands'>) {
const wallHeight = wall.height ?? 2.5
export function getWallFaceBandConfig(
wall: Pick<WallNode, 'height' | 'faceBands'>,
effectiveWallHeight: number,
) {
const wallHeight = Math.max(0, effectiveWallHeight)
const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) }
const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1
const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0
@@ -223,8 +228,9 @@ export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands
export function getWallFaceBandForHeight(
wall: Pick<WallNode, 'height' | 'faceBands'>,
y: number,
effectiveWallHeight: number,
): WallFaceBand {
const bands = getWallFaceBandConfig(wall)
const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
if (!bands.enabled) return 'upper'
if (y < bands.lowerTop) return 'lower'
if (y < bands.middleTop) return 'middle'
@@ -0,0 +1,89 @@
// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4).
// Auto slab polygon derives from wall centerlines, putting wall samples exactly on
// the polygon boundary — the shape that exposed ray-cast side dependence.
export const wallPlaneTopBoundaryRepro = {
building_rr4rx7weux2fpdbh: {
id: 'building_rr4rx7weux2fpdbh',
type: 'building',
object: 'node',
visible: true,
children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'],
metadata: {},
parentId: null,
position: [0, 0, 0],
rotation: [0, 0, 0],
},
level_pomuk0sbwec15mf3: {
id: 'level_pomuk0sbwec15mf3',
type: 'level',
level: 0,
height: 2.7,
object: 'node',
visible: true,
children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'],
metadata: {},
parentId: null,
},
level_5msog1z8hy2lyvxr: {
id: 'level_5msog1z8hy2lyvxr',
type: 'level',
level: 1,
height: 2.5,
object: 'node',
visible: true,
children: ['slab_j3i4ebjg4nsu8xk7'],
metadata: {},
parentId: 'building_rr4rx7weux2fpdbh',
},
wall_39bnnq29h824ryy0: {
id: 'wall_39bnnq29h824ryy0',
end: [-1, 4],
name: 'Wall 1',
type: 'wall',
start: [3, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
wall_on4rj410n69n3rzf: {
id: 'wall_on4rj410n69n3rzf',
end: [-1, -1],
name: 'Wall 2',
type: 'wall',
start: [-1, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
slab_j3i4ebjg4nsu8xk7: {
id: 'slab_j3i4ebjg4nsu8xk7',
name: 'Room 1 Slab',
type: 'slab',
holes: [],
object: 'node',
polygon: [
[-1, 4],
[-1, -1],
[4, -1],
[4, 1],
[3, 1],
[3, 4],
],
visible: true,
metadata: {},
parentId: 'level_5msog1z8hy2lyvxr',
recessed: false,
elevation: 0.19757210573188194,
thickness: 0.5,
holeMetadata: [],
autoFromWalls: true,
},
}
+14 -1
View File
@@ -46,7 +46,7 @@ export {
DEFAULT_LEVEL_HEIGHT,
getCeilingAt,
getCeilingHeightAt,
getLevelHeight,
resolveCeilingHeight,
} from './level-height'
export {
type AxisLock,
@@ -102,6 +102,19 @@ export {
snapVec3ToGrid,
snapWorldXZToBuildingLocal,
} from './snap'
export {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
findLevelBelowId,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
type LevelElevation,
} from './storey'
export {
buildPortComponents,
type SystemSummary,
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'bun:test'
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height'
function createFixture(): Record<AnyNodeId, AnyNode> {
const nodes: AnyNode[] = [
LevelNode.parse({ id: 'level_empty', children: [] }),
LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }),
WallNode.parse({
id: 'wall_no_slab',
parentId: 'level_no_slab',
start: [10, 0],
end: [12, 0],
}),
LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }),
SlabNode.parse({
id: 'slab_standard',
parentId: 'level_standard_slab',
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
elevation: 0.05,
}),
WallNode.parse({
id: 'wall_standard',
parentId: 'level_standard_slab',
start: [1, 2],
end: [3, 2],
}),
LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }),
SlabNode.parse({
id: 'slab_raised',
parentId: 'level_tall_wall',
polygon: [
[20, 0],
[24, 0],
[24, 4],
[20, 4],
],
elevation: 0.35,
}),
WallNode.parse({
id: 'wall_tall',
parentId: 'level_tall_wall',
start: [21, 2],
end: [23, 2],
height: 3.2,
}),
LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }),
WallNode.parse({
id: 'wall_below_ceiling',
parentId: 'level_ceiling',
start: [40, 0],
end: [42, 0],
}),
CeilingNode.parse({
id: 'ceiling_tall',
parentId: 'level_ceiling',
polygon: [
[40, 0],
[42, 0],
[42, 2],
[40, 2],
],
height: 3.4,
}),
LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }),
SlabNode.parse({
id: 'slab_negative',
parentId: 'level_negative_slab',
polygon: [
[30, 0],
[34, 0],
[34, 4],
[30, 4],
],
elevation: -0.4,
}),
WallNode.parse({
id: 'wall_negative',
parentId: 'level_negative_slab',
start: [31, 2],
end: [33, 2],
height: 2.8,
}),
]
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('deriveLegacyLevelHeight', () => {
const nodes = createFixture()
const cases = [
['level_no_slab', 2.5],
['level_standard_slab', 2.5],
['level_tall_wall', 3.55],
['level_ceiling', 3.4],
['level_negative_slab', 2.8],
['level_empty', 2.5],
] as const
for (const [levelId, expected] of cases) {
it(`derives ${expected} for ${levelId}`, () => {
expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected)
})
}
})
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
// Post-migration stack: two stored-height levels; the upper level carries a
// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower
// level's ceiling clamp bound is 2.5 0.3 0.01 = 2.19 under the deck.
function createResolverFixture(options: { deck?: boolean } = {}): Record<AnyNodeId, AnyNode> {
const list: AnyNode[] = [
BuildingNode.parse({
id: 'building_a',
children: ['level_low', 'level_high'],
}),
LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_high',
level: 1,
height: 2.5,
parentId: 'building_a',
children: options.deck ? ['slab_deck'] : [],
}),
]
if (options.deck) {
list.push(
SlabNode.parse({
id: 'slab_deck',
parentId: 'level_high',
polygon: SQUARE,
elevation: 0,
thickness: 0.3,
}),
)
}
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('resolveCeilingHeight', () => {
it('returns the explicit height verbatim when stored', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 })
expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0)
})
it('resolves an absent height to the level-top clamp bound', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
})
it('tracks a level height change without any ceiling write', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number }
const raised = {
...nodes,
level_low: { ...level, height: 3.2 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19)
})
it('resolves under a covering deck from the level above', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19)
})
it('falls back to the default plane when the level is unresolvable', () => {
const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, {} as Record<AnyNodeId, AnyNode>)).toBeCloseTo(2.49)
})
})
describe('getCeilingAt lowest-wins with mixed follows/explicit', () => {
it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => {
const base = createResolverFixture()
const follows = CeilingNode.parse({
id: 'ceiling_follows',
parentId: 'level_low',
polygon: SQUARE,
})
const explicitLow = CeilingNode.parse({
id: 'ceiling_low',
parentId: 'level_low',
polygon: SQUARE,
height: 2.0,
})
const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] }
const nodes = {
...base,
level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode,
ceiling_follows: follows,
ceiling_low: explicitLow,
} as Record<AnyNodeId, AnyNode>
// Explicit 2.0 undercuts the 2.49 follows bound.
expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id)
// Raise the explicit one above the bound comparison: 2.6 stored — the
// follows ceiling (2.49) is now the lowest surface over the point.
const nodesHighExplicit = {
...nodes,
ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id)
})
})
+55 -23
View File
@@ -1,40 +1,68 @@
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
import { resolveWallTop } from '../systems/wall/wall-top'
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
// both sides only reference the other inside function bodies.
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'
export const DEFAULT_LEVEL_HEIGHT = 2.5
/**
* Optional resolver for a wall's rendered base Y (mesh elevation).
*
* `packages/core` is pure domain logic and must not read viewer/Three.js
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
* registry access (viewer systems, node tools) may pass a resolver so the
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
* omit it and get a deterministic result from serialized node data alone.
* Effective ceiling height in level-local meters. An explicit stored
* `height` wins; absent height means the ceiling follows the level top —
* the same bound its write-clamp uses: min(storey plane, lowest
* covering-slab underside over its polygon) CEILING_CLAMP_MARGIN (see
* {@link getCeilingClampBound}). Falls back to the default plane minus
* the same margin when the owning level is unresolvable.
*/
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
export function resolveCeilingHeight(
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
nodes: Record<AnyNodeId, AnyNode>,
): number {
if (ceiling.height != null) return ceiling.height
const bound =
typeof ceiling.parentId === 'string'
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
: Number.POSITIVE_INFINITY
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
}
export function getLevelHeight(
export function deriveLegacyLevelHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
resolveWallBaseY?: WallBaseYResolver,
): number {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT
const levelChildren = level.children
.map((childId) => nodes[childId as keyof typeof nodes])
.filter((child): child is AnyNode => child !== undefined)
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (!child) continue
for (const child of levelChildren) {
if (child.type === 'ceiling') {
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (ch > maxTop) maxTop = ch
// Absence here is the PRE-migration legacy schema default (2.5), not
// follows-mode — this derivation runs before the level has a height
// for a follows-mode bound to track.
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
if (baseY < 0) baseY = 0
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
const wall = child as WallNode
const electedElevation = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation)
if (top > maxTop) maxTop = top
}
}
@@ -58,14 +86,18 @@ export function getCeilingAt(
if (!level) return null
let best: CeilingNode | null = null
let bestHeight = Number.POSITIVE_INFINITY
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling
const h = resolveCeilingHeight(ceiling, nodes)
if (best === null || h < bestHeight) {
best = ceiling
bestHeight = h
}
}
return best
}
@@ -82,5 +114,5 @@ export function getCeilingHeightAt(
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
}
+527
View File
@@ -0,0 +1,527 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
import {
CEILING_CLAMP_MARGIN,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
} from './storey'
const buildNodes = (list: AnyNode[]): Record<AnyNodeId, AnyNode> =>
Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
const level = (
id: string,
ordinal: number,
opts: { height?: number; parentId?: string | null; children?: string[] } = {},
): LevelNode =>
LevelNode.parse({
id,
level: ordinal,
parentId: opts.parentId ?? null,
children: opts.children ?? [],
...(opts.height === undefined ? {} : { height: opts.height }),
})
const building = (id: string, children: string[]): BuildingNode =>
BuildingNode.parse({ id, children })
const slabNode = (
id: string,
opts: {
polygon?: Array<[number, number]>
holes?: Array<Array<[number, number]>>
elevation?: number
thickness?: number
recessed?: boolean
},
): SlabNode =>
SlabNode.parse({
id,
polygon:
opts.polygon ??
([
[0, 0],
[4, 0],
[4, 4],
[0, 4],
] as Array<[number, number]>),
holes: opts.holes ?? [],
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
...(opts.thickness === undefined ? {} : { thickness: opts.thickness }),
...(opts.recessed === undefined ? {} : { recessed: opts.recessed }),
})
describe('getStoredLevelHeight', () => {
test('returns the stored height when present', () => {
expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25)
})
test('falls back to the default for unmigrated legacy levels', () => {
expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT)
expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5)
})
})
describe('getLevelElevations', () => {
test('single building matches a hand-computed prefix sum', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']),
level('level_0', 0, { height: 3, parentId: 'building_a' }),
level('level_1', 1, { height: 2.5, parentId: 'building_a' }),
level('level_2', 2, { height: 2.75, parentId: 'building_a' }),
level('level_3', 3, { height: 4, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')).toEqual({
baseY: 0,
height: 3,
buildingId: 'building_a',
ordinal: 0,
})
expect(elevations.get('level_1')?.baseY).toBe(3)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_3')?.baseY).toBe(8.25)
})
test('stacks two buildings independently with interleaved, unsorted ordinals', () => {
const nodes = buildNodes([
level('level_b1', 1, { height: 2.5, parentId: 'building_b' }),
level('level_a2', 2, { height: 3, parentId: 'building_a' }),
building('building_a', ['level_a0', 'level_a1', 'level_a2']),
level('level_a0', 0, { height: 3.5, parentId: 'building_a' }),
building('building_b', ['level_b0', 'level_b1']),
level('level_b0', 0, { height: 4, parentId: 'building_b' }),
level('level_a1', 1, { height: 3.25, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_a0')?.baseY).toBe(0)
expect(elevations.get('level_a1')?.baseY).toBe(3.5)
expect(elevations.get('level_a2')?.baseY).toBe(6.75)
expect(elevations.get('level_b0')?.baseY).toBe(0)
expect(elevations.get('level_b1')?.baseY).toBe(4)
expect(elevations.get('level_a2')?.buildingId).toBe('building_a')
expect(elevations.get('level_b1')?.buildingId).toBe('building_b')
})
test('negative ordinals stack from the lowest level up', () => {
const nodes = buildNodes([
building('building_a', ['level_basement', 'level_ground', 'level_upper']),
level('level_upper', 1, { height: 3, parentId: 'building_a' }),
level('level_basement', -1, { height: 2.25, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_basement')?.baseY).toBe(0)
expect(elevations.get('level_ground')?.baseY).toBe(2.25)
expect(elevations.get('level_upper')?.baseY).toBe(4.75)
})
test('duplicate and fractional ordinals stack stably without NaN', () => {
const nodes = buildNodes([
building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']),
level('level_dup_b', 1, { height: 3, parentId: 'building_a' }),
level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }),
level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_ground')?.baseY).toBe(0)
expect(elevations.get('level_mezz')?.baseY).toBe(2.5)
// Stable sort: equal ordinals keep nodes-record insertion order.
expect(elevations.get('level_dup_b')?.baseY).toBe(4)
expect(elevations.get('level_dup_a')?.baseY).toBe(7)
for (const elevation of elevations.values()) {
expect(Number.isFinite(elevation.baseY)).toBe(true)
expect(Number.isFinite(elevation.height)).toBe(true)
}
})
test('levels missing height fall back to 2.5 for both height and stacking', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_1', 1, { height: 3, parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')?.height).toBe(2.5)
expect(elevations.get('level_1')?.baseY).toBe(2.5)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_2')?.height).toBe(2.5)
})
test('resolves buildings via parentId, legacy children membership, and non-building parents', () => {
const nodes = buildNodes([
// level_direct is not in children; level_site has a non-building parentId.
building('building_x', ['level_legacy', 'level_site']),
level('level_direct', 0, { height: 3, parentId: 'building_x' }),
level('level_legacy', 1, { height: 2.5, parentId: null }),
level('level_site', 2, { height: 2.75, parentId: 'site_main' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_direct')?.buildingId).toBe('building_x')
expect(elevations.get('level_legacy')?.buildingId).toBe('building_x')
expect(elevations.get('level_site')?.buildingId).toBe('building_x')
expect(elevations.get('level_direct')?.baseY).toBe(0)
expect(elevations.get('level_legacy')?.baseY).toBe(3)
expect(elevations.get('level_site')?.baseY).toBe(5.5)
})
test('levels with no resolvable building share one legacy stack from 0', () => {
const nodes = buildNodes([
level('level_orphan_1', 1, { height: 3 }),
level('level_orphan_0', 0, { height: 2.75 }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_orphan_0')).toEqual({
baseY: 0,
height: 2.75,
buildingId: null,
ordinal: 0,
})
expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75)
})
})
describe('getLevelAbove', () => {
test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2')
expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5')
expect(getLevelAbove('level_5', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelAbove('level_a0', nodes)).toBeNull()
expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1')
})
test('orphan levels resolve within the shared legacy stack', () => {
const nodes = buildNodes([
level('level_orphan_0', 0, { height: 2.75 }),
level('level_orphan_1', 1, { height: 3 }),
])
expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1')
expect(getLevelAbove('level_orphan_1', nodes)).toBeNull()
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelAbove('level_missing', nodes)).toBeNull()
})
})
describe('getLevelBelow', () => {
test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2')
expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0')
expect(getLevelBelow('level_0', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelBelow('level_a0', nodes)).toBeNull()
expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0')
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelBelow('level_missing', nodes)).toBeNull()
})
})
// Two stacked levels in one building; `slabs` become children of the level
// above the queried one.
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
buildNodes([
building('building_a', ['level_0', 'level_1']),
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
level('level_1', 1, {
height: 2.5,
parentId: 'building_a',
children: slabs.map((node) => node.id),
}),
...slabs,
])
describe('getCoveringSlabUndersideAt', () => {
test('expresses a flush deck underside in the queried level local Y', () => {
// Flush deck occupying [-0.3, 0] above the plane: underside sits at
// storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
test('returns null outside the slab polygon', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull()
})
test('a hole in the slab vetoes coverage', () => {
const nodes = stackedNodes([
slabNode('slab_deck', {
elevation: 0,
thickness: 0.3,
holes: [
[
[1, 1],
[3, 1],
[3, 3],
[1, 3],
],
],
}),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2)
})
test('recessed pools never cover', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
})
test('the lowest underside wins among overlapping covering slabs', () => {
const nodes = stackedNodes([
// Default floor slab occupying [0, 0.05]: underside at the plane (2.5).
slabNode('slab_floor', {}),
slabNode('slab_deck', { elevation: 0, thickness: 0.3 }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
test('returns null when there is no level above', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull()
})
})
describe('getWallPlaneTop', () => {
const wallAt = (
start: [number, number],
end: [number, number],
): { start: [number, number]; end: [number, number] } => ({ start, end })
test('no covering slab → the stored level height', () => {
const nodes = stackedNodes([], 3)
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3)
})
test('a flush thick deck above clamps the plane to its underside', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a slab covering only part of the span clamps via the min of the samples', () => {
// Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it,
// only the end sample (4,2) lands inside — the min still clamps.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[3.5, 0],
[6, 0],
[6, 4],
[3.5, 4],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a recessed slab above is ignored', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5)
})
test('falls back to the default height when the level does not resolve', () => {
const nodes = stackedNodes([])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe(
DEFAULT_LEVEL_HEIGHT,
)
})
test('repro project: both boundary walls clamp to the covering slab underside', () => {
// Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's
// polygon derives from the level-0 wall CENTERLINES, so every perimeter
// wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge)
// clamped while Wall 1 (max-z edge) ran full height — ray-cast
// pointInPolygon includes min-side boundaries and excludes max-side ones.
const nodes = reproFixture as unknown as Record<AnyNodeId, AnyNode>
const levelId = 'level_pomuk0sbwec15mf3'
const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode
const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode
// storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5)
const underside = 2.7 + (0.19757210573188194 - 0.5)
expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside)
expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside)
})
test('all four rectangle walls under a same-footprint covering slab clamp', () => {
// The repro shape distilled: wall centerlines lie exactly on the covering
// slab's polygon edges. Every orientation must clamp identically.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const walls: Array<[[number, number], [number, number]]> = [
[
[0, 0],
[4, 0],
],
[
[4, 0],
[4, 4],
],
[
[4, 4],
[0, 4],
],
[
[0, 4],
[0, 0],
],
]
for (const [start, end] of walls) {
expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2)
}
})
test('a diagonal wall under the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a wall fully outside the covering slab keeps the storey height', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5)
})
test('a wall partially overlapping the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
})
describe('getCeilingClampBound', () => {
const ceilingPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
test('with no covering slab the bound is the storey plane minus the margin', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.5 - CEILING_CLAMP_MARGIN,
)
})
test('a covering deck lowers the bound to its underside minus the margin', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
test('a slab covering only the interior is caught by the centroid sample', () => {
// Deck hovers over the middle of the ceiling — every vertex sample
// misses, only the centroid (2, 2) lands inside it.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[1.5, 1.5],
[2.5, 1.5],
[2.5, 2.5],
[1.5, 2.5],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
test('returns Infinity for an unresolvable level', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe(
Number.POSITIVE_INFINITY,
)
})
test('vertices on the covering slab boundary clamp identically on every side', () => {
// Two mirrored strips share an edge with the 4x4 deck: one along its
// min-z edge, one along its max-z edge. Their interiors and centroids sit
// outside the deck, so only the shared-edge vertices can register —
// ray-cast pointInPolygon used to admit the min-side vertices and reject
// the max-side ones, giving orientation-dependent clamps.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const minSideStrip: Array<[number, number]> = [
[0, -1],
[4, -1],
[4, 0],
[0, 0],
]
const maxSideStrip: Array<[number, number]> = [
[0, 4],
[4, 4],
[4, 5],
[0, 5],
]
expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
})
+358
View File
@@ -0,0 +1,358 @@
import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
pointInPolygon,
pointOnPolygonBoundary,
wallOverlapsSlabFootprint,
} from '../systems/slab/slab-support'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
/**
* Gap kept between a ceiling's stored height and its clamp bound (storey
* plane or covering-slab underside), so the ceiling surface never
* coincides with the solid above it.
*/
export const CEILING_CLAMP_MARGIN = 0.01
/**
* Stored storey height in meters (floor-to-floor). Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height`
* field is absent.
*/
export function getStoredLevelHeight(level: Pick<LevelNode, 'height'>): number {
return level.height ?? DEFAULT_LEVEL_HEIGHT
}
export type LevelElevation = {
/** World Y of the level's floor: prefix sum of the storey heights below it. */
baseY: number
/** Stored storey height of this level (fallback applied). */
height: number
buildingId: string | null
ordinal: number
}
/**
* Resolves the owning building: explicit `parentId` pointing at a building
* wins; legacy levels that only appear in a building's `children` array
* resolve through that membership.
*/
function resolveLevelBuildingId(
levelId: LevelNode['id'],
parentId: string | null,
buildings: readonly BuildingNode[],
): string | null {
const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined
if (directParent) return directParent.id
return buildings.find((building) => building.children.includes(levelId))?.id ?? null
}
/**
* Per-building stacked elevations from stored storey heights: levels are
* sorted by ordinal ascending within each building, the lowest level's floor
* sits at 0, and each next floor sits on top of the previous storey height.
* Levels with no resolvable building share one legacy stack from 0.
*
* Pure — operates on the serialized nodes record only.
*/
export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
const buildings = Object.values(nodes).filter(
(node): node is BuildingNode => node?.type === 'building',
)
const entries: Array<{ levelId: string } & LevelElevation> = []
for (const node of Object.values(nodes)) {
if (node?.type !== 'level') continue
const level = node as LevelNode
entries.push({
levelId: level.id,
baseY: 0,
height: getStoredLevelHeight(level),
buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings),
ordinal: level.level,
})
}
const elevations = new Map<string, LevelElevation>()
const cumulativeYByBuilding = new Map<string | null, number>()
for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) {
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
elevations.set(entry.levelId, {
baseY,
height: entry.height,
buildingId: entry.buildingId,
ordinal: entry.ordinal,
})
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
}
return elevations
}
/**
* The id of the level directly above `levelId` in its own stack (same
* resolved building, or the shared legacy stack for building-less levels):
* the level with the lowest ordinal strictly greater than the queried
* level's. `null` when the level is topmost or unresolvable.
*/
export function findLevelAboveId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let aboveId: string | null = null
let aboveOrdinal = Number.POSITIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) {
aboveOrdinal = candidate.ordinal
aboveId = candidateId
}
}
return aboveId
}
/**
* The level directly above `levelId` — see {@link findLevelAboveId}.
* `null` when topmost or unresolvable. Pure.
*/
export function getLevelAbove(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes))
if (!aboveId) return null
const above = nodes[aboveId as LevelNode['id']]
return above?.type === 'level' ? (above as LevelNode) : null
}
/**
* The id of the level directly below `levelId` in its own stack — mirror of
* {@link findLevelAboveId}: the level with the highest ordinal strictly less
* than the queried level's. `null` when the level is lowest or unresolvable.
*/
export function findLevelBelowId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let belowId: string | null = null
let belowOrdinal = Number.NEGATIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) {
belowOrdinal = candidate.ordinal
belowId = candidateId
}
}
return belowId
}
/**
* The level directly below `levelId` — see {@link findLevelBelowId}.
* `null` when lowest or unresolvable. Pure.
*/
export function getLevelBelow(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const belowId = findLevelBelowId(levelId, getLevelElevations(nodes))
if (!belowId) return null
const below = nodes[belowId as LevelNode['id']]
return below?.type === 'level' ? (below as LevelNode) : null
}
type CoveringSlabContext = {
/** Stored storey height of the QUERIED level. */
storeyHeight: number
/** Non-recessed slab children of the level above. */
slabs: SlabNode[]
}
/**
* Storey height of the queried level plus the level-above's covering
* (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level.
* A missing level above yields an empty slab list, not `null` — the
* storey height is still meaningful for the clamp bound.
*/
function resolveCoveringSlabContext(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): CoveringSlabContext | null {
const level = nodes[levelId as LevelNode['id']]
if (level?.type !== 'level') return null
const above = getLevelAbove(levelId, nodes)
const slabs: SlabNode[] = []
for (const childId of above?.children ?? []) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'slab') continue
const slab = child as SlabNode
// Recessed slabs (pools) are open shells, not covering solids.
if (slab.recessed === true) continue
if (slab.polygon.length < 3) continue
slabs.push(slab)
}
return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs }
}
/**
* Underside of `slab`'s solid in the QUERIED level's local Y. The solid
* occupies `[elevation - thickness, elevation]` in ITS level's local Y,
* which sits `storeyHeight` above the queried level's floor.
*/
function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
}
/**
* Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`.
* Ray-cast pointInPolygon flips arbitrarily for points exactly ON the
* boundary (min-side edges read inside, max-side edges outside), so
* boundary contact counts as covered explicitly — the same convention as
* the slab-support interval classification. A point on a hole's rim keeps
* coverage (mirrors the support election's hole handling).
*
* Raw stored polygon + holes on purpose (mirrors getCeilingAt): the
* clamp bound doesn't need the rendered footprint's junction trims,
* and staying off the render path keeps this query cheap and pure.
*/
function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean {
if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) {
return false
}
for (const hole of slab.holes ?? []) {
if (hole.length < 3) continue
if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false
}
return true
}
/**
* Lowest underside among `slabs` covering `[x, z]`, in the queried
* level's local Y, or `null` when none covers the point.
*/
function lowestCoveringUndersideAt(
context: CoveringSlabContext,
x: number,
z: number,
): number | null {
let lowest: number | null = null
for (const slab of context.slabs) {
if (!slabCoversPoint(slab, x, z)) continue
const underside = coveringUndersideY(context.storeyHeight, slab)
if (lowest === null || underside < lowest) lowest = underside
}
return lowest
}
/**
* Underside of the LOWEST slab from the level above that covers
* level-local point `[x, z]`, expressed in the queried level's local Y:
* `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs
* (pools) never cover. `null` when no covering slab (or no level above).
*
* Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ
* transform and the viewer's LevelSystem writes only `position.y`), so a
* level-local `[x, z]` is valid in every level of the stack unchanged.
*/
export function getCoveringSlabUndersideAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return null
return lowestCoveringUndersideAt(context, x, z)
}
/**
* Top plane for a plane-bound wall on `levelId`, in level-local Y:
* `min(stored storey height, lowest covering-slab underside over the wall's
* span)` — a thick or flush slab on the level above SHORTENS the walls below
* instead of colliding with them (Revit-style automatic attach).
*
* Coverage: the wall's thickness band (centerline + face lines, arc-aware)
* is clipped against each covering slab's stored polygon minus holes via
* {@link wallOverlapsSlabFootprint} — the same overlap machinery as the
* support election. Point sampling is deliberately avoided: auto-slab
* polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON
* the polygon boundary, where ray-cast point-in-polygon flips with the
* edge's orientation (one wall clamped, its neighbor didn't). Boundary
* contact counts as covered on every side of the slab.
*
* This is THE plane for a plane-bound wall (`height` absent). Explicit-height
* walls ignore the value (`resolveWallTop` returns their stored height), so
* passing it wherever a raw storey height feeds `resolveWallTop` /
* `resolveWallEffectiveHeight` is always safe. Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level.
*/
export function getWallPlaneTop(
wall: Pick<WallNode, 'start' | 'end'> & Partial<Pick<WallNode, 'thickness' | 'curveOffset'>>,
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return DEFAULT_LEVEL_HEIGHT
let plane = context.storeyHeight
for (const slab of context.slabs) {
const underside = coveringUndersideY(context.storeyHeight, slab)
if (underside >= plane) continue
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
plane = underside
}
return plane
}
/**
* Upper bound for a ceiling's stored height over `polygon` on `levelId`:
* `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`.
* The covering underside is sampled at every polygon vertex plus the
* centroid — cheap, and a slab overlapping a convex-ish ceiling almost
* always covers one of those points; exact polygon-vs-polygon overlap is
* not worth its cost for a clamp bound. Ceiling outlines share footprint
* edges with the slabs above them the same way walls do, so vertices
* sitting exactly on a slab's boundary count as covered on every side
* (see `slabCoversPoint`) instead of flipping with the edge orientation.
*
* Returns `Infinity` when `levelId` doesn't resolve, so callers clamp
* against nothing rather than a garbage plane.
*/
export function getCeilingClampBound(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
polygon: ReadonlyArray<[number, number]>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return Number.POSITIVE_INFINITY
let bound = context.storeyHeight
if (polygon.length > 0) {
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
const samples: Array<[number, number]> = [
...polygon,
[cx / polygon.length, cz / polygon.length],
]
for (const [x, z] of samples) {
const underside = lowestCoveringUndersideAt(context, x, z)
if (underside !== null && underside < bound) bound = underside
}
}
return bound - CEILING_CLAMP_MARGIN
}
@@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode {
return sanitized.value as AnyNode
}
// An explicit `key: undefined` in update data REMOVES the key: optional
// fields like wall.height encode a mode by their absence (absent =
// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so
// a plain spread would leave a lingering own key that breaks `'height' in
// node` checks.
function mergeNodeUpdate(currentNode: AnyNode, patch: Partial<AnyNode>): AnyNode {
const merged: Record<string, unknown> = { ...currentNode, ...patch }
for (const key of Object.keys(patch)) {
if ((patch as Record<string, unknown>)[key] === undefined) delete merged[key]
}
return merged as AnyNode
}
function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode {
const candidate = { ...currentNode, ...data }
const candidate = mergeNodeUpdate(currentNode, data)
const parsed = AnyNodeSchema.safeParse(candidate)
if (parsed.success) return parsed.data
@@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
if (sanitized.issues.length === 0) {
return candidate as AnyNode
return candidate
}
warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues)
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
return mergeNodeUpdate(currentNode, sanitized.value as Partial<AnyNode>)
}
function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) {
@@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) {
(a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
// Absent height means plane-bound (follows the storey), which must never
// merge with an explicit height — even one that currently matches the plane.
(a.height == null) === (b.height == null) &&
Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 &&
aInterior === bInterior &&
aExterior === bExterior &&
a.frontSide === b.frontSide &&
@@ -1129,6 +1145,31 @@ export const deleteNodesAction = (
}
}
// Deleting a slab strips `supportSlabId` / `deckSlabId` references from
// surviving nodes in the same undo commit (mirrors the collectionIds
// cleanup below), so those nodes re-elect their support / re-derive
// their rise. Deletion is the ONLY writer — a host merely reshaped away
// keeps the field and the read path falls back, letting hosting resume
// if the slab returns.
const deletedSlabIds = new Set<string>()
for (const id of allIds) {
if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id)
}
if (deletedSlabIds.size > 0) {
for (const [nodeId, node] of Object.entries(nextNodes)) {
if (allIds.has(nodeId as AnyNodeId)) continue
const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {}
const hostId = (node as { supportSlabId?: string }).supportSlabId
if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined
const deckId = (node as { deckSlabId?: string }).deckSlabId
if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined
if (Object.keys(patch).length > 0) {
nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode
nodesToMarkDirty.add(nodeId as AnyNodeId)
}
}
}
for (const id of allIds) {
const node = nextNodes[id]
if (!node) continue
@@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number
const SHELF_ID = 'shelf_sanitize' as AnyNodeId
const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId
const WALL_ID = 'wall_keyremoval' as AnyNodeId
function makeWall(): AnyNode {
return {
id: WALL_ID,
type: 'wall',
parentId: null,
object: 'node',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [4, 0],
height: 2.5,
} as unknown as AnyNode
}
function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode {
return {
@@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => {
expect(Number.isFinite(created.thickness)).toBe(true)
})
})
describe('node update explicit-undefined key removal', () => {
beforeEach(() => {
useScene.setState({
nodes: { [WALL_ID]: makeWall() },
rootNodeIds: [WALL_ID],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('an undefined value in update data removes the key from the stored node', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
})
test('undo restores a key removed via an undefined update value', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
expect('height' in (useScene.getState().nodes[WALL_ID] as Record<string, unknown>)).toBe(false)
useScene.temporal.getState().undo()
const wall = useScene.getState().nodes[WALL_ID] as { height?: number }
expect('height' in wall).toBe(true)
expect(wall.height).toBe(2.5)
})
test('other keys in the same patch still apply when one is removed', () => {
useScene.getState().updateNode(WALL_ID, {
height: undefined,
name: 'Plane-bound wall',
} as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
expect(wall.name).toBe('Plane-bound wall')
})
})
@@ -7,6 +7,14 @@ import { create } from 'zustand'
export type LiveTransform = {
position: [number, number, number]
rotation: number // Y-axis rotation (plan-view rotation)
/**
* Pointer-decided support cap (level-local Y) published by 3D drags:
* the elevation of the surface the cursor ray actually points at. The
* floor-elevation system passes it to the slab-support election so a
* deck above the aimed-at floor never lifts the dragged node. Absent
* for 2D floorplan drags (no camera ray) — election stays uncapped.
*/
supportElevationCap?: number
}
type LiveTransformState = {
@@ -342,7 +342,9 @@ describe('scene commit boundary', () => {
const snapshot = currentSnapshot()
snapshot.nodes = {
...snapshot.nodes,
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode,
// Marker must survive the load migration: level ordinals renumber on
// load, so the stored storey height marks the applied snapshot instead.
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode,
}
snapshot.installedPlugins = ['pascal:trees']
const commits: SceneCommit[] = []
@@ -350,7 +352,7 @@ describe('scene commit boundary', () => {
useScene.getState().dirtyNodes.clear()
expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true)
expect(levelNumber()).toBe(8)
expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8)
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
type RawNode = Record<string, unknown>
function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode {
return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra }
}
function site(children: string[]): RawNode {
return baseNode('site_test', 'site', null, { children })
}
function building(id: string, children: string[]): RawNode {
return baseNode(id, 'building', 'site_test', { children })
}
function level(
id: string,
buildingId: string,
ordinal: number,
children: string[],
extra: RawNode = {},
): RawNode {
return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra })
}
function wall(
id: string,
levelId: string,
start: [number, number],
end: [number, number],
height?: number,
): RawNode {
return baseNode(id, 'wall', levelId, {
start,
end,
children: [],
...(height !== undefined ? { height } : {}),
})
}
function slab(
id: string,
levelId: string,
polygon: Array<[number, number]>,
elevation = 0.05,
): RawNode {
return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation })
}
function ceiling(
id: string,
levelId: string,
polygon: Array<[number, number]>,
height: number,
extra: RawNode = {},
): RawNode {
return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra })
}
function stair(id: string, levelId: string, extra: RawNode = {}): RawNode {
return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra })
}
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function loadScene(nodes: Record<string, RawNode>): Record<string, AnyNode> {
useScene.getState().setScene(nodes as unknown as Record<string, AnyNode>, ['site_test'] as never)
return useScene.getState().nodes as Record<string, AnyNode>
}
type LevelResult = Extract<AnyNode, { type: 'level' }>
type WallResult = Extract<AnyNode, { type: 'wall' }>
type StairResult = Extract<AnyNode, { type: 'stair' }>
type SlabResult = Extract<AnyNode, { type: 'slab' }>
type CeilingResult = Extract<AnyNode, { type: 'ceiling' }>
describe('scene vertical model migration', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('hole pattern: walls within 0.20 of the plane become plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]),
})
// Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short.
expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65)
expect('height' in (nodes.wall_tall as WallResult)).toBe(false)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('intentional short walls at or beyond 0.20 keep their explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect((nodes.wall_a as WallResult).height).toBe(2.3)
expect((nodes.wall_b as WallResult).height).toBe(2.1)
})
test('absent-height wall well short of the plane materializes the 2.5 default', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.wall_a as WallResult).height).toBe(2.5)
})
test('ordinal renumber compacts per building, anchored at zero', () => {
const nodes = loadScene({
site_test: site(['building_a', 'building_b']),
building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']),
building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']),
// Duplicate fractional ordinals (MCP wrote elevation params here).
level_a1: level('level_a1', 'building_a', 2.5, []),
level_a2: level('level_a2', 'building_a', 2.5, []),
level_a3: level('level_a3', 'building_a', 5, []),
// Basements compact upward toward -1, non-negatives down to 0.
level_b1: level('level_b1', 'building_b', -3, []),
level_b2: level('level_b2', 'building_b', -1, []),
level_b3: level('level_b3', 'building_b', 0, []),
level_b4: level('level_b4', 'building_b', 4, []),
})
expect((nodes.level_a1 as LevelResult).level).toBe(0)
expect((nodes.level_a2 as LevelResult).level).toBe(1)
expect((nodes.level_a3 as LevelResult).level).toBe(2)
expect((nodes.level_b1 as LevelResult).level).toBe(-2)
expect((nodes.level_b2 as LevelResult).level).toBe(-1)
expect((nodes.level_b3 as LevelResult).level).toBe(0)
expect((nodes.level_b4 as LevelResult).level).toBe(1)
})
test('near-bound ceiling heights become follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
// Legacy default: ceiling 2.5 drives the derived level height 2.5,
// so the clamp bound is 2.49 and |2.5 2.49| < 0.20 → follows.
level_a: level('level_a', 'building_a', 0, ['ceiling_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
// Already write-clamped default: 2.49 under a derived 2.49 level
// (bound 2.48) → follows too.
level_b: level('level_b', 'building_a', 1, ['ceiling_b']),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49),
})
expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false)
expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false)
})
test('an intentional low ceiling keeps its explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under
// the 2.99 bound — a deliberate dropped ceiling, kept explicit.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0)
})
test('autoFromWalls ceilings always convert to follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// 2.2 is far from the 2.99 bound, but auto heights were always
// derived by the sync — never user intent — so it drops anyway.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }),
})
expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false)
})
test('migrated scene keeps a near-bound ceiling height (gate respected)', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// Post-migration scene (level carries height): a stored 2.49 IS a
// deliberately typed value and must survive reloads.
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }),
})
expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49)
expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49)
})
test('legacy scene drops totalRise 2.5 but keeps other rises', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
})
expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false)
expect((nodes.stair_b as StairResult).totalRise).toBe(3.1)
})
test('migrated scene keeps a deliberately typed totalRise 2.5', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
})
expect((nodes.stair_a as StairResult).totalRise).toBe(2.5)
})
test('already-migrated level and its walls are untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5),
})
expect((nodes.level_a as LevelResult).height).toBe(4.0)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect((nodes.wall_b as WallResult).height).toBe(2.5)
})
test('slab split writes thickness = elevation exactly for legacy solids', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']),
slab_a: slab('slab_a', 'level_a', SQUARE, 0.3),
slab_b: slab('slab_b', 'level_a', SQUARE, 0),
})
const raised = nodes.slab_a as SlabResult
expect(raised.elevation).toBe(0.3)
expect(raised.thickness).toBe(0.3)
expect(raised.recessed).not.toBe(true)
// Degenerate zero-elevation slab keeps its zero occupied interval —
// migration never clamps to MIN_SLAB_THICKNESS.
const flush = nodes.slab_b as SlabResult
expect(flush.elevation).toBe(0)
expect(flush.thickness).toBe(0)
})
test('slab split defaults an absent elevation to the effective 0.05 thickness', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }),
})
expect((nodes.slab_a as SlabResult).thickness).toBe(0.05)
})
test('legacy pool becomes recessed with its elevation unchanged', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: slab('slab_a', 'level_a', SQUARE, -0.15),
})
const pool = nodes.slab_a as SlabResult
expect(pool.elevation).toBe(-0.15)
expect(pool.recessed).toBe(true)
expect(pool.thickness).toBe(0.05)
})
test('slab with thickness already present is untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
// A below-plane SOLID (already-split scene): the gate must not
// reinterpret its negative elevation as a pool.
slab_a: baseNode('slab_a', 'slab', 'level_a', {
polygon: SQUARE,
holes: [],
elevation: -0.15,
thickness: 0.3,
}),
})
const deck = nodes.slab_a as SlabResult
expect(deck.elevation).toBe(-0.15)
expect(deck.thickness).toBe(0.3)
expect('recessed' in deck).toBe(false)
})
test('migration is idempotent', () => {
const first = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
level_a: level('level_a', 'building_a', 2.5, [
'slab_a',
'wall_tall',
'wall_a',
'stair_a',
'stair_b',
]),
level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0),
wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]),
})
const second = loadScene(structuredClone(first) as unknown as Record<string, RawNode>)
expect(second).toEqual(first)
})
})
+186 -2
View File
@@ -32,6 +32,10 @@ import {
type SceneMaterialId,
} from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { deriveLegacyLevelHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint'
import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions'
import {
@@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number
}
function normalizeStairNode(node: Record<string, unknown>) {
const hasTotalRise = 'totalRise' in node
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
@@ -101,7 +106,7 @@ function normalizeStairNode(node: Record<string, unknown>) {
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
openingOffset: getFiniteNumber(node.openingOffset, 0),
width: getFiniteNumber(node.width, 1),
totalRise: getFiniteNumber(node.totalRise, 2.5),
totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined,
stepCount: getFiniteNumber(node.stepCount, 10),
thickness: getFiniteNumber(node.thickness, 0.25),
fillToFloor: getBoolean(node.fillToFloor, true),
@@ -117,7 +122,13 @@ function normalizeStairNode(node: Record<string, unknown>) {
}
const parsed = StairNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
if (!parsed.success) return null
if (hasTotalRise) return parsed.data
// Absent `totalRise` means "rise derives from the storey height" and must
// survive the load: safeParse echoes the sanitized explicit-undefined key,
// which would flip `'totalRise' in node` checks — strip it back off.
const { totalRise: _totalRise, ...rest } = parsed.data
return rest
}
function normalizeStairSegmentNode(node: Record<string, unknown>) {
@@ -559,6 +570,15 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next
}
// Walls whose top lands within this of the storey plane become plane-bound;
// ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both.
// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
// a taller wall) must snap to the plane, while intentional 0.20-short walls
// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
// height — hence 0.20 with a strictly-less-than comparison.
const PLANE_BOUND_EPSILON = 0.2
function migrateNodes(nodes: Record<string, any>): {
nodes: Record<string, AnyNode>
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
@@ -886,6 +906,169 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
// Pass 3: vertical building model.
// A level without `height` marks a scene saved before the vertical model
// landed. Computed before this pass mutates anything: the stair-rise
// cleanup below must never run on already-migrated scenes.
const isLegacyScene = Object.values(patchedNodes).some(
(node) => node?.type === 'level' && !('height' in node),
)
// 3a. Ordinal renumber — always runs, per building (idempotent
// self-healing; MCP's create-level historically wrote its elevation PARAM
// into the ordinal, so fractional/duplicate ordinals exist in the wild).
const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building')
const levelsByBuilding = new Map<string | null, Array<{ id: string; ordinal: number }>>()
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'level') continue
// Mirrors the building resolution in services/storey.ts: an explicit
// parentId pointing at a building wins, membership in a building's
// children array is the legacy fallback, and unresolvable levels share
// one orphan bucket.
const buildingId =
buildingNodes.find((building) => building.id === node.parentId)?.id ??
buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
null
const bucket = levelsByBuilding.get(buildingId) ?? []
bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) })
levelsByBuilding.set(buildingId, bucket)
}
for (const bucket of levelsByBuilding.values()) {
// Anchored at zero on purpose: ordinals are semantic — `level < 0`
// renders "Basement N" and `level === 0` is the ground-floor default —
// so negatives compact upward toward 1 and non-negatives compact down
// to 0. A blind 0..n renumber would rename basements.
const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal)
const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length
sorted.forEach((entry, index) => {
const nextOrdinal = index - negativeCount
const current = patchedNodes[entry.id]
if (current.level !== nextOrdinal) {
patchedNodes[entry.id] = { ...current, level: nextOrdinal }
}
})
}
// 3b. Stored storey heights: materialize the legacy stacked height verbatim
// (never rounded or snapped — snapping would move existing buildings).
// All planes derive before any wall height below mutates.
const legacyLevelIds = Object.entries(patchedNodes)
.filter(([, node]) => node?.type === 'level' && !('height' in node))
.map(([id]) => id)
const derivedHeights = new Map<string, number>()
for (const levelId of legacyLevelIds) {
derivedHeights.set(
levelId,
deriveLegacyLevelHeight(levelId, patchedNodes as Record<AnyNodeId, AnyNode>),
)
}
for (const levelId of legacyLevelIds) {
const plane = derivedHeights.get(levelId)!
const level = patchedNodes[levelId]
patchedNodes[levelId] = { ...level, height: plane }
// 3c. Wall-top classification against the just-written plane, using the
// same slab-support election as deriveLegacyLevelHeight (call shape
// mirrored from services/level-height.ts). Walls whose top meets the
// plane drop their explicit height and follow the level from now on;
// walls ending short (or tall) keep an explicit height — materializing
// the 2.5 default onto absent-height walls that end short of the plane.
const children = getStringArray(level.children)
.map((childId) => patchedNodes[childId])
.filter((child) => child !== undefined)
const slabs = children.filter((child) => child.type === 'slab')
const walls = children.filter((child) => child.type === 'wall')
for (const wall of walls) {
const electedBase = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const top = Math.max(0, electedBase) + effectiveHeight
if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
if ('height' in wall) {
const { height: _height, ...planeBound } = wall
patchedNodes[wall.id] = planeBound
}
} else {
patchedNodes[wall.id] = { ...wall, height: effectiveHeight }
}
}
}
// 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
// schema default, not a user choice — drop it so the rise derives from the
// storey height. Gated on isLegacyScene because on a post-migration scene
// a stored 2.5 IS a deliberately typed value and must survive reloads.
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'stair') continue
if (node.totalRise !== 2.5) continue
const { totalRise: _totalRise, ...derivedRise } = node
patchedNodes[id] = derivedRise
}
}
// 3e. Slab placement/thickness split. `elevation` stays the walking surface;
// the new `thickness` grows downward so the solid occupies
// [elevation thickness, elevation]. Legacy solids extruded [0, elevation],
// so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
// applies to edits only, never here) keeps the occupied interval identical.
// Legacy pools (elevation < 0) become explicit `recessed` intent with
// elevation unchanged. Gated per slab on a missing `thickness` — the
// migration output is cast, so schema defaults never materialize on load.
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'slab' || 'thickness' in node) continue
const elevation = getFiniteNumber(node.elevation, 0.05)
patchedNodes[id] =
elevation < 0
? { ...node, thickness: 0.05, recessed: true }
: { ...node, thickness: elevation }
}
// 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
// after 3b/3e so the clamp bound sees stored level heights and split slab
// thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
// clamp bound (min(storey plane, covering-slab underside) margin, via
// getCeilingClampBound) is the legacy default tracking the level top, not
// a choice — drop it so the ceiling follows the level from now on.
// autoFromWalls ceilings always convert: their height was derived by the
// space-detection sync, never user intent. Gated on isLegacyScene, which
// is exact — nothing shipped between the level-height migration and this
// one — and makes the step idempotent. Known accepted edge: a
// post-migration user typing a custom height exactly equal to the bound
// keeps it (the gate prevents re-classification on later loads).
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'ceiling' || !('height' in node)) continue
const dropHeight = () => {
const { height: _height, ...follows } = node
patchedNodes[id] = follows
}
if (node.autoFromWalls === true) {
dropHeight()
continue
}
if (typeof node.parentId !== 'string') continue
const bound = getCeilingClampBound(
node.parentId,
patchedNodes as Record<AnyNodeId, AnyNode>,
Array.isArray(node.polygon) ? node.polygon : [],
)
const stored = getFiniteNumber(node.height, Number.NaN)
if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
dropHeight()
}
}
}
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
}
@@ -1163,6 +1346,7 @@ const useScene: UseSceneStore = create<SceneState>()(
const level0 = LevelNode.parse({
level: 0,
children: [],
height: 2.5,
})
const building = BuildingNode.parse({
@@ -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
}
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test'
import type { CollectionId } from '../schema/collections'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { forkSceneGraph, type SceneGraph } from './clone-scene-graph'
import {
cloneLevelSubtree,
cloneSceneGraph,
forkSceneGraph,
type SceneGraph,
} from './clone-scene-graph'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return {
@@ -71,3 +76,50 @@ describe('forkSceneGraph', () => {
expect(forked.installedPlugins).toEqual(['pascal:trees'])
})
})
describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const cloned = cloneSceneGraph({
nodes: {
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: item,
},
rootNodeIds: ['level_1' as AnyNodeId],
})
const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')!
const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')!
expect(clonedSlab.id).not.toBe('slab_1')
expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id)
})
test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const external = makeNode('item_2', 'item', {
parentId: 'level_1',
supportSlabId: 'slab_external',
})
const { clonedNodes, idMap } = cloneLevelSubtree(
{
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: hosted,
['item_2' as AnyNodeId]: external,
},
'level_1' as AnyNodeId,
)
const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))!
const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))!
expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!)
expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external')
})
})
@@ -1,3 +1,4 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
@@ -85,6 +86,24 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
) as string | undefined
}
// Remap supportSlabId (persisted slab-support hosts). The 'ground'
// sentinel is not a node id — keep it as-is.
if (
'supportSlabId' in clonedNode &&
typeof clonedNode.supportSlabId === 'string' &&
clonedNode.supportSlabId !== GROUND_SUPPORT_ID
) {
;(clonedNode as Record<string, unknown>).supportSlabId = idMap.get(
clonedNode.supportSlabId,
) as string | undefined
}
if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') {
;(clonedNode as Record<string, unknown>).deckSlabId = idMap.get(clonedNode.deckSlabId) as
| string
| undefined
}
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
@@ -240,6 +259,18 @@ export function cloneLevelSubtree(
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
}
// Remap supportSlabId when the host slab is inside the cloned subtree;
// preserve it otherwise (like wallId, the reference may point outside).
if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') {
;(cloned as Record<string, unknown>).supportSlabId =
idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId
}
if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') {
;(cloned as Record<string, unknown>).deckSlabId =
idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId
}
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}