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,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
}