From 92a550e25aa9f4bca0f24a7aa1df3930f9f9f6ca Mon Sep 17 00:00:00 2001
From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Sun, 19 Jul 2026 23:56:16 -0700
Subject: [PATCH 1/5] feat(viewer): per-level base elevation parameter
baseElevation is an offset: it shifts the level and every level above
it within the same building (cumulative). Integrated into the level
stacking computation so stacked, exploded, solo, floorplan, and
snap-to-true-positions all respect it. Includes focused tests.
Closes #209
---
packages/core/src/schema/nodes/level.test.ts | 14 ++
packages/core/src/schema/nodes/level.ts | 5 +
.../ui/sidebar/panels/site-panel/index.tsx | 12 ++
.../src/systems/level/level-stacking.test.ts | 72 ++++++++
.../src/systems/level/level-stacking.ts | 8 +-
.../src/systems/level/level-system.test.ts | 165 ++++++++++++++++++
.../viewer/src/systems/level/level-system.tsx | 2 +
.../viewer/src/systems/level/level-utils.ts | 2 +
8 files changed, 278 insertions(+), 2 deletions(-)
create mode 100644 packages/viewer/src/systems/level/level-system.test.ts
diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts
index 839be780d..780d3e3ee 100644
--- a/packages/core/src/schema/nodes/level.test.ts
+++ b/packages/core/src/schema/nodes/level.test.ts
@@ -11,6 +11,20 @@ import { PipeSegmentNode } from './pipe-segment'
import { PipeTrapNode } from './pipe-trap'
describe('LevelNode', () => {
+ test('defaults baseElevation to 0', () => {
+ expect(LevelNode.parse({ level: 0, name: 'Ground' }).baseElevation).toBe(0)
+ })
+
+ test('accepts a custom baseElevation', () => {
+ expect(
+ LevelNode.parse({
+ baseElevation: 1.25,
+ level: 1,
+ name: 'Split level',
+ }).baseElevation,
+ ).toBe(1.25)
+ })
+
test('accepts every level-hosted MEP node ID', () => {
const nodes = [
DuctSegmentNode.parse({
diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts
index ef4de43ae..4fb6f86f9 100644
--- a/packages/core/src/schema/nodes/level.ts
+++ b/packages/core/src/schema/nodes/level.ts
@@ -59,11 +59,16 @@ export const LevelNode = BaseNode.extend({
.default([]),
// Specific props
level: z.number().default(0),
+ baseElevation: z
+ .number()
+ .default(0)
+ .describe("Additive Y offset in meters applied above this level's computed stack position."),
}).describe(
dedent`
Level node - used to represent a level in the building
- children: array of architectural, equipment, and MEP distribution nodes
- level: level number
+ - baseElevation: additive Y offset in meters above the computed stack position
`,
)
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
index af7fe82de..07c4adf3e 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
@@ -50,6 +50,7 @@ import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
+import { MetricControl } from '../../../controls/metric-control'
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode } from './tree-node'
@@ -872,6 +873,17 @@ const LevelItem = memo(function LevelItem({
initial={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
+
+
+
updateNode(level.id, { baseElevation: value })}
+ precision={2}
+ step={0.05}
+ unit="m"
+ value={Math.round(level.baseElevation * 100) / 100}
+ />
+
{
level_1: 2.7,
})
})
+
+ test('applies an offset to its level and every higher level in the same building', () => {
+ const entries: LevelStackEntry[] = [
+ {
+ levelId: 'level_a0',
+ buildingId: 'building_a',
+ index: 0,
+ height: 2.5,
+ baseElevation: 0,
+ },
+ {
+ levelId: 'level_b0',
+ buildingId: 'building_b',
+ index: 0,
+ height: 3,
+ baseElevation: 0,
+ },
+ {
+ levelId: 'level_a1',
+ buildingId: 'building_a',
+ index: 1,
+ height: 3,
+ baseElevation: 1.25,
+ },
+ {
+ levelId: 'level_b1',
+ buildingId: 'building_b',
+ index: 1,
+ height: 3,
+ baseElevation: 0,
+ },
+ {
+ levelId: 'level_a2',
+ buildingId: 'building_a',
+ index: 2,
+ height: 2.8,
+ baseElevation: 0,
+ },
+ ]
+
+ expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({
+ level_a0: 0,
+ level_b0: 0,
+ level_a1: 3.75,
+ level_b1: 3,
+ level_a2: 6.75,
+ })
+ })
+
+ test('allows negative offsets', () => {
+ const entries: LevelStackEntry[] = [
+ {
+ levelId: 'level_ground',
+ buildingId: 'building_a',
+ index: 0,
+ height: 2.5,
+ baseElevation: -0.75,
+ },
+ {
+ levelId: 'level_first',
+ buildingId: 'building_a',
+ index: 1,
+ height: 3,
+ baseElevation: 0,
+ },
+ ]
+
+ expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({
+ level_ground: -0.75,
+ level_first: 1.75,
+ })
+ })
})
diff --git a/packages/viewer/src/systems/level/level-stacking.ts b/packages/viewer/src/systems/level/level-stacking.ts
index b966e3835..af2e577ce 100644
--- a/packages/viewer/src/systems/level/level-stacking.ts
+++ b/packages/viewer/src/systems/level/level-stacking.ts
@@ -3,6 +3,7 @@ export type LevelStackEntry = {
buildingId: string | null
index: number
height: number
+ baseElevation?: number
}
type BuildingOwnership = { id: string; children: readonly string[] }
@@ -24,8 +25,11 @@ export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map
for (const entry of [...entries].sort((a, b) => a.index - b.index)) {
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
- positions.set(entry.levelId, baseY)
- cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
+ // baseElevation is an offset, not an absolute Y: it shifts this level and,
+ // cumulatively, every level above it in the same building. Negative offsets are valid.
+ const levelY = baseY + (entry.baseElevation ?? 0)
+ positions.set(entry.levelId, levelY)
+ cumulativeYByBuilding.set(entry.buildingId, levelY + entry.height)
}
return positions
diff --git a/packages/viewer/src/systems/level/level-system.test.ts b/packages/viewer/src/systems/level/level-system.test.ts
new file mode 100644
index 000000000..f745fdf2c
--- /dev/null
+++ b/packages/viewer/src/systems/level/level-system.test.ts
@@ -0,0 +1,165 @@
+// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
+// include Bun ambient types in its production declaration build.
+import { afterEach, describe, expect, mock, test } from 'bun:test'
+
+type FakeLevelObject = {
+ position: { y: number }
+ visible: boolean
+}
+
+type FakeLevelNode = {
+ id: string
+ type: 'level'
+ parentId: string
+ level: number
+ baseElevation: number
+ children: []
+}
+
+type FakeBuildingNode = {
+ id: string
+ type: 'building'
+ children: string[]
+}
+
+const levelIds = new Set()
+const registryNodes = new Map()
+const sceneRegistry = {
+ byType: { level: levelIds },
+ nodes: registryNodes,
+}
+let nodes: Record = {}
+let viewerState = {
+ levelMode: 'stacked' as 'stacked' | 'exploded' | 'solo',
+ selection: { levelId: null as string | null },
+}
+let frameCallback: ((state: unknown, delta: number) => void) | null = null
+
+mock.module('@pascal-app/core', () => ({
+ getLevelHeight: (levelId: string) => (nodes[levelId]?.type === 'level' ? 2.5 : 0),
+ sceneRegistry,
+ useScene: {
+ getState: () => ({ nodes }),
+ },
+}))
+
+mock.module('@react-three/fiber', () => ({
+ useFrame: (callback: (state: unknown, delta: number) => void) => {
+ frameCallback = callback
+ },
+}))
+
+mock.module('three/src/math/MathUtils.js', () => ({
+ lerp: (start: number, end: number, alpha: number) => start + (end - start) * alpha,
+}))
+
+mock.module('../../store/use-viewer', () => ({
+ default: {
+ getState: () => viewerState,
+ },
+}))
+
+const [{ LevelSystem }, { snapLevelsToTruePositions }] = await Promise.all([
+ import('./level-system'),
+ import('./level-utils'),
+])
+
+function setupLevels(baseElevations: number[]) {
+ const buildingId = 'building_base-elevation-system-test'
+ const levels: FakeLevelNode[] = baseElevations.map((baseElevation, level) => ({
+ id: `level_base-elevation-system-${level}`,
+ type: 'level',
+ parentId: buildingId,
+ level,
+ baseElevation,
+ children: [],
+ }))
+ const building: FakeBuildingNode = {
+ id: buildingId,
+ type: 'building',
+ children: levels.map((level) => level.id),
+ }
+ nodes = Object.fromEntries([building, ...levels].map((node) => [node.id, node]))
+
+ const objects = levels.map((level) => {
+ const object: FakeLevelObject = {
+ position: { y: -100 },
+ visible: true,
+ }
+ sceneRegistry.nodes.set(level.id, object)
+ sceneRegistry.byType.level.add(level.id)
+ return object
+ })
+
+ return { building, levels, objects }
+}
+
+function setLevelMode(
+ mode: 'stacked' | 'exploded' | 'solo',
+ selectedLevelId: string | null = null,
+) {
+ viewerState = {
+ levelMode: mode,
+ selection: { levelId: selectedLevelId },
+ }
+}
+
+function updateLevelPresentation(delta: number) {
+ frameCallback = null
+ LevelSystem()
+ expect(frameCallback).not.toBeNull()
+ frameCallback?.({}, delta)
+}
+
+afterEach(() => {
+ sceneRegistry.nodes.clear()
+ sceneRegistry.byType.level.clear()
+ nodes = {}
+})
+
+describe('updateLevelPresentation', () => {
+ test('writes offset positions to the registry transform used by floorplan and selection', () => {
+ const { objects } = setupLevels([0, 1.25, 0])
+ setLevelMode('stacked')
+
+ updateLevelPresentation(1 / 12)
+
+ expect(objects.map((object) => object.position.y)).toEqual([0, 3.75, 6.25])
+ })
+
+ test('keeps offset-aware positions in exploded and solo modes', () => {
+ const { levels, objects } = setupLevels([1, 0.5])
+
+ setLevelMode('exploded')
+ updateLevelPresentation(1 / 12)
+ expect(objects.map((object) => object.position.y)).toEqual([1, 9])
+
+ objects.forEach((object) => {
+ object.position.y = -100
+ })
+ setLevelMode('solo', levels[1]!.id)
+ updateLevelPresentation(1 / 12)
+ expect(objects.map((object) => object.position.y)).toEqual([1, 4])
+ expect(objects[0]!.visible).toBe(false)
+ expect(objects[1]!.visible).toBe(true)
+ })
+})
+
+describe('snapLevelsToTruePositions', () => {
+ test('bakes offset-aware stacked positions and restores the prior presentation', () => {
+ const { objects } = setupLevels([0.5, 1.25])
+ objects[0]!.position.y = 10
+ objects[0]!.visible = false
+ objects[1]!.position.y = 20
+
+ const restore = snapLevelsToTruePositions()
+
+ expect(objects.map((object) => object.position.y)).toEqual([0.5, 4.25])
+ expect(objects.map((object) => object.visible)).toEqual([true, true])
+
+ restore()
+
+ expect(objects.map((object) => object.position.y)).toEqual([10, 20])
+ expect(objects.map((object) => object.visible)).toEqual([false, true])
+ })
+})
diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx
index 80af5af36..18fd19ad0 100644
--- a/packages/viewer/src/systems/level/level-system.tsx
+++ b/packages/viewer/src/systems/level/level-system.tsx
@@ -33,6 +33,7 @@ export const LevelSystem = () => {
buildingId: string | null
index: number
height: number
+ baseElevation: number
obj: NonNullable>
}
const entries: LevelEntry[] = []
@@ -52,6 +53,7 @@ export const LevelSystem = () => {
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
),
+ baseElevation: level.baseElevation,
obj,
})
}
diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts
index 25c03ba94..475b2c601 100644
--- a/packages/viewer/src/systems/level/level-utils.ts
+++ b/packages/viewer/src/systems/level/level-utils.ts
@@ -28,6 +28,7 @@ export function snapLevelsToTruePositions(): () => void {
buildingId: string | null
index: number
height: number
+ baseElevation: number
}
const entries: LevelEntry[] = []
@@ -47,6 +48,7 @@ export function snapLevelsToTruePositions(): () => void {
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
),
+ baseElevation: level.baseElevation,
obj,
})
}
From ac82cf890e935920bb455ae4388a27c93cef379d Mon Sep 17 00:00:00 2001
From: Matt Van Horn
Date: Sun, 2 Aug 2026 14:11:42 -0700
Subject: [PATCH 2/5] fix: thread baseElevation through slab clamps, elevator
stacking and migration
Three Bugbot findings, all the same shape: baseElevation was applied in
one path and ignored in another.
- Covering-slab math assumed the floor above sat exactly one stored storey
height away, so wall and ceiling clamps ignored the offset. A positive
offset over-shortened walls under thick slabs and a negative one let them
penetrate the slab above. Floor-to-floor distance now comes from the
stacked elevations (above.baseY - current.baseY) via one helper, so the
clamp math and getLevelElevations cannot drift apart.
- Stair rise used the stored storey height for the same reason; it now uses
the same helper.
- Elevator level tables and the first-person elevator colliders built
cumulative Y from storey heights, so cab stops desynced from the visible
floors. Both now read baseY from getLevelElevations. first-person-controls
had its own near-copy of that logic, which is deleted in favour of the
shared resolveElevatorLevels.
- Levels loaded from older project JSON could omit baseElevation, which made
the editor control render NaN. Migration now normalizes it to 0 alongside
level and children, with a defensive fallback at the control.
---
packages/core/src/schema/nodes/level.test.ts | 8 ++-
packages/core/src/schema/nodes/level.ts | 8 ++-
packages/core/src/services/index.ts | 1 +
packages/core/src/services/storey.test.ts | 33 +++++++++++-
packages/core/src/services/storey.ts | 52 ++++++++++++++-----
.../use-scene-vertical-migration.test.ts | 12 +++++
packages/core/src/store/use-scene.ts | 3 +-
.../systems/elevator/elevator-service.test.ts | 48 +++++++++++++++++
.../src/systems/elevator/elevator-service.ts | 23 ++++----
.../core/src/systems/stair/stair-rise.test.ts | 29 ++++++++++-
packages/core/src/systems/stair/stair-rise.ts | 6 ++-
.../editor/first-person-controls.tsx | 47 +----------------
.../ui/sidebar/panels/site-panel/index.tsx | 2 +-
13 files changed, 195 insertions(+), 77 deletions(-)
create mode 100644 packages/core/src/systems/elevator/elevator-service.test.ts
diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts
index 98d602abc..de9bc8ee1 100644
--- a/packages/core/src/schema/nodes/level.test.ts
+++ b/packages/core/src/schema/nodes/level.test.ts
@@ -3,7 +3,7 @@ import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
import { HvacEquipmentNode } from './hvac-equipment'
-import { LevelNode } from './level'
+import { LevelNode, normalizeLevelBaseElevation } from './level'
import { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line'
import { PipeFittingNode } from './pipe-fitting'
@@ -25,6 +25,12 @@ describe('LevelNode', () => {
).toBe(1.25)
})
+ test('normalizes legacy missing and invalid baseElevation values to a finite zero', () => {
+ expect(normalizeLevelBaseElevation(undefined)).toBe(0)
+ expect(normalizeLevelBaseElevation(Number.NaN)).toBe(0)
+ expect(Number.isNaN(normalizeLevelBaseElevation(undefined))).toBe(false)
+ })
+
test('accepts every level-hosted MEP node ID', () => {
const nodes = [
DuctSegmentNode.parse({
diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts
index 276af1244..2032c61a7 100644
--- a/packages/core/src/schema/nodes/level.ts
+++ b/packages/core/src/schema/nodes/level.ts
@@ -56,6 +56,12 @@ type CoreLevelChildId =
const LevelChildId = z.string().transform((id) => id as CoreLevelChildId)
+export const DEFAULT_LEVEL_BASE_ELEVATION = 0
+
+export function normalizeLevelBaseElevation(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : DEFAULT_LEVEL_BASE_ELEVATION
+}
+
export const LevelNode = BaseNode.extend({
id: objectId('level'),
type: nodeType('level'),
@@ -66,7 +72,7 @@ export const LevelNode = BaseNode.extend({
level: z.number().default(0),
baseElevation: z
.number()
- .default(0)
+ .default(DEFAULT_LEVEL_BASE_ELEVATION)
.describe("Additive Y offset in meters applied above this level's computed stack position."),
/**
* Stored storey height in meters (floor-to-floor). No zod default on
diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts
index 245793f62..14c4e00cb 100644
--- a/packages/core/src/services/index.ts
+++ b/packages/core/src/services/index.ts
@@ -111,6 +111,7 @@ export {
getLevelAbove,
getLevelBelow,
getLevelElevations,
+ getLevelFloorToFloorHeight,
getStoredLevelHeight,
getWallPlaneTop,
type LevelElevation,
diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts
index 6b95883de..1ca93d9f4 100644
--- a/packages/core/src/services/storey.test.ts
+++ b/packages/core/src/services/storey.test.ts
@@ -321,11 +321,12 @@ describe('getLevelBelow', () => {
// Two stacked levels in one building; `slabs` become children of the level
// above the queried one.
-const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
+const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5, aboveBaseElevation = 0) =>
buildNodes([
building('building_a', ['level_0', 'level_1']),
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
level('level_1', 1, {
+ baseElevation: aboveBaseElevation,
height: 2.5,
parentId: 'building_a',
children: slabs.map((node) => node.id),
@@ -341,6 +342,17 @@ describe('getCoveringSlabUndersideAt', () => {
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
+ test('includes positive and negative offsets in the covering plane', () => {
+ const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })
+
+ expect(getCoveringSlabUndersideAt('level_0', stackedNodes([slab], 2.5, 0.4), 2, 2)).toBeCloseTo(
+ 2.6,
+ )
+ expect(
+ getCoveringSlabUndersideAt('level_0', stackedNodes([slab], 2.5, -0.4), 2, 2),
+ ).toBeCloseTo(1.8)
+ })
+
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()
@@ -403,6 +415,14 @@ describe('getWallPlaneTop', () => {
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
+ test('uses offset-aware floor spacing for positive and negative wall clamps', () => {
+ const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })
+ const wall = wallAt([0.5, 2], [3.5, 2])
+
+ expect(getWallPlaneTop(wall, 'level_0', stackedNodes([slab], 2.5, 0.4))).toBeCloseTo(2.6)
+ expect(getWallPlaneTop(wall, 'level_0', stackedNodes([slab], 2.5, -0.4))).toBeCloseTo(1.8)
+ })
+
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.
@@ -516,6 +536,17 @@ describe('getCeilingClampBound', () => {
)
})
+ test('uses offset-aware floor spacing for positive and negative ceiling clamps', () => {
+ const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })
+
+ expect(
+ getCeilingClampBound('level_0', stackedNodes([slab], 2.5, 0.4), ceilingPolygon),
+ ).toBeCloseTo(2.6 - CEILING_CLAMP_MARGIN)
+ expect(
+ getCeilingClampBound('level_0', stackedNodes([slab], 2.5, -0.4), ceilingPolygon),
+ ).toBeCloseTo(1.8 - 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.
diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts
index 6e2b6b21a..bc7d5482e 100644
--- a/packages/core/src/services/storey.ts
+++ b/packages/core/src/services/storey.ts
@@ -91,6 +91,26 @@ export function getLevelElevations(nodes: Record): Map,
+): number | null {
+ const current = elevations.get(levelId)
+ if (!current) return null
+
+ const aboveId = findLevelAboveId(levelId, elevations)
+ if (!aboveId) return current.height
+ const above = elevations.get(aboveId)
+ return above ? above.baseY - current.baseY : current.height
+}
+
+export function getLevelFloorToFloorHeight(
+ levelId: string,
+ nodes: Record,
+): number {
+ return resolveLevelFloorToFloorHeight(levelId, getLevelElevations(nodes)) ?? DEFAULT_LEVEL_HEIGHT
+}
+
/**
* The id of the level directly above `levelId` in its own stack (same
* resolved building, or the shared legacy stack for building-less levels):
@@ -171,8 +191,8 @@ export function getLevelBelow(
}
type CoveringSlabContext = {
- /** Stored storey height of the QUERIED level. */
- storeyHeight: number
+ /** Offset-aware distance from the queried floor to the floor above. */
+ floorToFloorHeight: number
/** Non-recessed slab children of the level above. */
slabs: SlabNode[]
}
@@ -190,7 +210,10 @@ function resolveCoveringSlabContext(
const level = nodes[levelId as LevelNode['id']]
if (level?.type !== 'level') return null
- const above = getLevelAbove(levelId, nodes)
+ const elevations = getLevelElevations(nodes)
+ const aboveId = findLevelAboveId(levelId, elevations)
+ const aboveNode = aboveId ? nodes[aboveId as LevelNode['id']] : null
+ const above = aboveNode?.type === 'level' ? (aboveNode as LevelNode) : null
const slabs: SlabNode[] = []
for (const childId of above?.children ?? []) {
const child = nodes[childId as keyof typeof nodes]
@@ -202,16 +225,21 @@ function resolveCoveringSlabContext(
slabs.push(slab)
}
- return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs }
+ return {
+ floorToFloorHeight:
+ resolveLevelFloorToFloorHeight(levelId, elevations) ??
+ 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.
+ * which sits `floorToFloorHeight` above the queried level's floor.
*/
-function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
- return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
+function coveringUndersideY(floorToFloorHeight: number, slab: SlabNode): number {
+ return floorToFloorHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
}
/**
@@ -249,7 +277,7 @@ function lowestCoveringUndersideAt(
let lowest: number | null = null
for (const slab of context.slabs) {
if (!slabCoversPoint(slab, x, z)) continue
- const underside = coveringUndersideY(context.storeyHeight, slab)
+ const underside = coveringUndersideY(context.floorToFloorHeight, slab)
if (lowest === null || underside < lowest) lowest = underside
}
return lowest
@@ -258,7 +286,7 @@ function lowestCoveringUndersideAt(
/**
* 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
+ * `floorToFloorHeight + (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
@@ -305,9 +333,9 @@ export function getWallPlaneTop(
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return DEFAULT_LEVEL_HEIGHT
- let plane = context.storeyHeight
+ let plane = context.floorToFloorHeight
for (const slab of context.slabs) {
- const underside = coveringUndersideY(context.storeyHeight, slab)
+ const underside = coveringUndersideY(context.floorToFloorHeight, slab)
if (underside >= plane) continue
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
plane = underside
@@ -337,7 +365,7 @@ export function getCeilingClampBound(
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return Number.POSITIVE_INFINITY
- let bound = context.storeyHeight
+ let bound = context.floorToFloorHeight
if (polygon.length > 0) {
let cx = 0
let cz = 0
diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts
index a95e7839b..15d34bbfb 100644
--- a/packages/core/src/store/use-scene-vertical-migration.test.ts
+++ b/packages/core/src/store/use-scene-vertical-migration.test.ts
@@ -108,6 +108,18 @@ describe('scene vertical model migration', () => {
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
+ test('materializes a finite zero base elevation for legacy levels', () => {
+ const nodes = loadScene({
+ site_test: site(['building_a']),
+ building_a: building('building_a', ['level_a']),
+ level_a: level('level_a', 'building_a', 0, []),
+ })
+
+ const baseElevation = (nodes.level_a as LevelResult).baseElevation
+ expect(baseElevation).toBe(0)
+ expect(Number.isNaN(baseElevation)).toBe(false)
+ })
+
test('hole pattern: walls within 0.20 of the plane become plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index 60f9a2329..0883be89b 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -10,7 +10,7 @@ import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections'
import { DoorNode as DoorNodeSchema } from '../schema/nodes/door'
import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator'
-import { LevelNode } from '../schema/nodes/level'
+import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level'
import {
getPitchFromActiveRoofHeight,
type RoofSegmentNode,
@@ -948,6 +948,7 @@ function migrateNodes(nodes: Record): {
const levelNumber = getFiniteNumber(node.level, 0)
patchedNodes[id] = {
...node,
+ baseElevation: normalizeLevelBaseElevation(node.baseElevation),
level: levelNumber,
children: validChildren,
}
diff --git a/packages/core/src/systems/elevator/elevator-service.test.ts b/packages/core/src/systems/elevator/elevator-service.test.ts
new file mode 100644
index 000000000..bb5107128
--- /dev/null
+++ b/packages/core/src/systems/elevator/elevator-service.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, test } from 'bun:test'
+import { type AnyNode, BuildingNode, ElevatorNode, LevelNode } from '../../schema'
+import { getLevelElevations } from '../../services/storey'
+import { resolveElevatorLevels } from './elevator-service'
+
+describe('resolveElevatorLevels', () => {
+ test('matches offset-aware stacked level positions', () => {
+ const levels = [
+ LevelNode.parse({ id: 'level_0', parentId: 'building_1', level: 0, height: 2.5 }),
+ LevelNode.parse({
+ id: 'level_1',
+ parentId: 'building_1',
+ level: 1,
+ baseElevation: 0.4,
+ height: 3,
+ }),
+ LevelNode.parse({
+ id: 'level_2',
+ parentId: 'building_1',
+ level: 2,
+ baseElevation: -0.2,
+ height: 2.5,
+ }),
+ ]
+ const elevator = ElevatorNode.parse({
+ id: 'elevator_1',
+ parentId: 'building_1',
+ fromLevelId: 'level_0',
+ toLevelId: 'level_2',
+ })
+ const building = BuildingNode.parse({
+ id: 'building_1',
+ children: [...levels.map((level) => level.id), elevator.id],
+ })
+ const nodes = Object.fromEntries(
+ [building, ...levels, elevator].map((node) => [node.id, node]),
+ ) as Record
+
+ const stacked = getLevelElevations(nodes)
+ const resolved = resolveElevatorLevels(elevator, nodes)
+
+ expect(resolved.entries.map((entry) => entry.baseY)).toEqual(
+ levels.map((level) => stacked.get(level.id)?.baseY),
+ )
+ expect(resolved.shaftBaseY).toBe(stacked.get('level_0')?.baseY)
+ expect(resolved.shaftTopY).toBeCloseTo(8.2)
+ })
+})
diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts
index 124746e9f..24369cfe0 100644
--- a/packages/core/src/systems/elevator/elevator-service.ts
+++ b/packages/core/src/systems/elevator/elevator-service.ts
@@ -1,5 +1,5 @@
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
-import { getStoredLevelHeight } from '../../services/storey'
+import { getLevelElevations } from '../../services/storey'
export type ElevatorLevelEntry = {
id: LevelNode['id']
@@ -84,19 +84,13 @@ export function resolveElevatorLevels(
totalHeight: number
} {
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
-
- const baseYByLevelId = new Map()
- let cumulativeY = 0
- for (const level of allLevels) {
- baseYByLevelId.set(level.id, cumulativeY)
- cumulativeY += getStoredLevelHeight(level)
- }
+ const levelElevations = getLevelElevations(nodes as Record)
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
const entries = serviceLevels.map((level) => ({
id: level.id,
label: String(level.level),
- baseY: baseYByLevelId.get(level.id) ?? 0,
+ baseY: levelElevations.get(level.id)?.baseY ?? 0,
}))
const defaultEntry =
@@ -106,15 +100,20 @@ export function resolveElevatorLevels(
null
const firstServedLevel = serviceLevels[0] ?? null
const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
- const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
+ const shaftBaseY = firstServedLevel ? (levelElevations.get(firstServedLevel.id)?.baseY ?? 0) : 0
const lastServedIndex = lastServedLevel
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
: -1
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
+ const lastStackedLevel = allLevels[allLevels.length - 1]
+ const stackTopY = lastStackedLevel
+ ? (levelElevations.get(lastStackedLevel.id)?.baseY ?? 0) +
+ (levelElevations.get(lastStackedLevel.id)?.height ?? 0)
+ : 0
const shaftTopY = nextLevel
- ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
+ ? (levelElevations.get(nextLevel.id)?.baseY ?? stackTopY)
: lastServedLevel
- ? cumulativeY
+ ? stackTopY
: elevator.cabHeight + 0.3
return {
diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts
index 00309cbfa..aac4fcbf7 100644
--- a/packages/core/src/systems/stair/stair-rise.test.ts
+++ b/packages/core/src/systems/stair/stair-rise.test.ts
@@ -8,7 +8,7 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
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 { BuildingNode, 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
@@ -146,6 +146,33 @@ describe('resolveStairTotalRise', () => {
expect(resolveStairTotalRise(stair, updated)).toBe(3.0)
})
+ it('includes the next level base elevation in a following stair rise', () => {
+ const { stair, nodes } = buildScene(2.5, undefined)
+ const current = nodes.level_1
+ if (current.type !== 'level') throw new Error('expected level')
+ const building = BuildingNode.parse({
+ id: 'building_1',
+ children: ['level_1', 'level_2'],
+ })
+ const upper = LevelNode.parse({
+ id: 'level_2',
+ parentId: building.id,
+ level: 1,
+ baseElevation: 0.4,
+ height: 2.5,
+ })
+ const stackedNodes = {
+ ...nodes,
+ [building.id]: building,
+ level_1: { ...current, parentId: building.id },
+ level_2: upper,
+ } as Record
+
+ expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.9)
+ stackedNodes.level_2 = { ...upper, baseElevation: -0.4 }
+ expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.1)
+ })
+
it('prefers an explicit totalRise over the storey height', () => {
const { stair, nodes } = buildScene(3.2, 2.5)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts
index 12054831c..f67a07755 100644
--- a/packages/core/src/systems/stair/stair-rise.ts
+++ b/packages/core/src/systems/stair/stair-rise.ts
@@ -1,7 +1,7 @@
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'
+import { getLevelFloorToFloorHeight } from '../../services/storey'
export function resolveStairTotalRise(stair: StairNode, nodes: Record): number {
if (stair.totalRise !== undefined) return stair.totalRise
@@ -33,7 +33,9 @@ export function resolveStairTotalRise(stair: StairNode, nodes: Record)
+ : DEFAULT_LEVEL_HEIGHT
}
const RISE_SYNC_EPSILON = 1e-4
diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx
index d5517896a..869247914 100644
--- a/packages/editor/src/components/editor/first-person-controls.tsx
+++ b/packages/editor/src/components/editor/first-person-controls.tsx
@@ -21,9 +21,8 @@ import {
openElevatorDoor,
pointInPolygon2D,
requestElevatorLevel,
- resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
- resolveElevatorServiceLevels,
+ resolveElevatorLevels,
sceneRegistry,
useInteractive,
useScene,
@@ -430,45 +429,6 @@ function isInsideElevatorCab(
)
}
-function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) {
- const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
- const levelElevations = getLevelElevations(nodes as Record)
-
- const baseYByLevelId = new Map()
- let cumulativeY = 0
- for (const level of allLevels) {
- const elevation = levelElevations.get(level.id)
- const baseY = elevation?.baseY ?? 0
- baseYByLevelId.set(level.id, baseY)
- cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0))
- }
-
- const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
- const entries = serviceLevels.map((level) => ({
- baseY: baseYByLevelId.get(level.id) ?? 0,
- id: level.id as AnyNodeId,
- }))
- const firstServedLevel = serviceLevels[0] ?? null
- const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null
- const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0
- const lastServedIndex = lastServedLevel
- ? allLevels.findIndex((level) => level.id === lastServedLevel.id)
- : -1
- const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
- const shaftTopY = nextLevel
- ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY)
- : lastServedLevel
- ? cumulativeY
- : elevator.cabHeight + 0.3
-
- return {
- entries,
- shaftBaseY,
- shaftTopY,
- totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3),
- }
-}
-
function createElevatorColliderMesh(
elevatorId: AnyNodeId,
kind: ElevatorColliderKind,
@@ -519,10 +479,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
const node = nodes[typedElevatorId]
if (node?.type !== 'elevator' || node.visible === false) continue
- const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels(
- node,
- nodes,
- )
+ const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
const cabWidth = getElevatorCabWidth(node)
const cabDepth = getElevatorCabDepth(node)
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
index 58fd4968f..e397930aa 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx
@@ -882,7 +882,7 @@ const LevelItem = memo(function LevelItem({
precision={2}
step={0.05}
unit="m"
- value={Math.round(level.baseElevation * 100) / 100}
+ value={Math.round((level.baseElevation ?? 0) * 100) / 100}
/>
Date: Tue, 4 Aug 2026 17:21:36 -0400
Subject: [PATCH 3/5] viewer: stop the level-system test mocking core, and fix
the type gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The quality gate was red for two reasons.
1. `frameCallback?.({}, delta)` typed `never`. The useFrame mock assigns
that binding while LevelSystem() runs, which TypeScript cannot see, so
after the `frameCallback = null` reset it narrowed the binding to
`null`. Reading it through a function keeps the declared type.
2. `mock.module('@pascal-app/core', ...)` replaced core's entire export
surface. mock.module is process-wide and Bun does not restore it, so
every viewer suite that ran after this file got the fake core: running
the systems directory went from 47 pass / 0 fail to 29 / 2, and a
whole-repo run lost 32 tests, including
wall-support-extension's covering-slab case. The failure looked like a
baseElevation regression and was not one.
Core does not need mocking here — `sceneRegistry` is a real in-memory
store with a `clear()`, and `useScene` is a zustand store with
`setState`. The test now drives both directly and only mocks
`@react-three/fiber` and `use-viewer`, the two modules that genuinely
need a renderer or a React context. Dropping the `lerp` mock too: the
real one is already pure.
Also adds the now-required `baseElevation` to the level fixture in
wall-drafting.test.ts — the schema default makes it required on
LevelNode's output type, so the existing `as AnyNode` cast no longer
held.
Gates on the merge ref (main merged in): check clean, check-types 9/9,
test 12/12 tasks with 0 fail, build 7/7.
Co-Authored-By: Claude Opus 5
---
.../tools/wall/wall-drafting.test.ts | 1 +
.../src/systems/level/level-system.test.ts | 125 ++++++++----------
2 files changed, 55 insertions(+), 71 deletions(-)
diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts
index f2e91c1a9..2d1f3168e 100644
--- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts
+++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts
@@ -168,6 +168,7 @@ describe('createWallOnCurrentLevel', () => {
metadata: {},
children: [],
level: 0,
+ baseElevation: 0,
height: 3,
} as AnyNode
const nodes = Object.fromEntries([site, building, level].map((node) => [node.id, node]))
diff --git a/packages/viewer/src/systems/level/level-system.test.ts b/packages/viewer/src/systems/level/level-system.test.ts
index d9962c4cb..ded9f6c4a 100644
--- a/packages/viewer/src/systems/level/level-system.test.ts
+++ b/packages/viewer/src/systems/level/level-system.test.ts
@@ -1,70 +1,35 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// include Bun ambient types in its production declaration build.
import { afterEach, describe, expect, mock, test } from 'bun:test'
-
-type FakeLevelObject = {
- position: { y: number }
- visible: boolean
-}
-
-type FakeLevelNode = {
- id: string
- type: 'level'
- parentId: string
- level: number
- baseElevation: number
- children: []
-}
-
-type FakeBuildingNode = {
- id: string
- type: 'building'
- children: string[]
-}
-
-const levelIds = new Set()
-const registryNodes = new Map()
-const sceneRegistry = {
- byType: { level: levelIds },
- nodes: registryNodes,
-}
-let nodes: Record = {}
-let viewerState = {
- levelMode: 'stacked' as 'stacked' | 'exploded' | 'solo',
- selection: { levelId: null as string | null },
+import type { AnyNode, AnyNodeId } from '@pascal-app/core'
+import { sceneRegistry, useScene } from '@pascal-app/core'
+import type { Object3D } from 'three'
+
+// Only the two modules that need a renderer or a React context are mocked.
+// `@pascal-app/core` is deliberately NOT mocked: mock.module replaces a module
+// for the whole test process and Bun never restores it, so faking core here
+// breaks the other viewer suites that run after this file.
+type FrameCallback = (state: unknown, delta: number) => void
+let frameCallback: FrameCallback | null = null
+
+// Read through a function so the value is not control-flow narrowed. The
+// useFrame mock assigns frameCallback while LevelSystem() runs; TypeScript
+// cannot see through that indirection, so reading the binding directly after
+// `frameCallback = null` narrows it to `null` and types the call `never`.
+function takeFrameCallback(): FrameCallback | null {
+ return frameCallback
}
-let frameCallback: ((state: unknown, delta: number) => void) | null = null
-
-mock.module('@pascal-app/core', () => ({
- getLevelElevations: () => {
- const elevations = new Map()
- const cumulativeYByBuilding = new Map()
- const levels = Object.values(nodes)
- .filter((node): node is FakeLevelNode => node.type === 'level')
- .sort((a, b) => a.level - b.level)
-
- for (const level of levels) {
- const baseY = (cumulativeYByBuilding.get(level.parentId) ?? 0) + level.baseElevation
- elevations.set(level.id, { baseY })
- cumulativeYByBuilding.set(level.parentId, baseY + 2.5)
- }
- return elevations
- },
- sceneRegistry,
- useScene: {
- getState: () => ({ nodes }),
- },
-}))
mock.module('@react-three/fiber', () => ({
- useFrame: (callback: (state: unknown, delta: number) => void) => {
+ useFrame: (callback: FrameCallback) => {
frameCallback = callback
},
}))
-mock.module('three/src/math/MathUtils.js', () => ({
- lerp: (start: number, end: number, alpha: number) => start + (end - start) * alpha,
-}))
+let viewerState = {
+ levelMode: 'stacked' as 'stacked' | 'exploded' | 'solo',
+ selection: { levelId: null as string | null },
+}
mock.module('../../store/use-viewer', () => ({
default: {
@@ -77,30 +42,48 @@ const [{ LevelSystem }, { snapLevelsToTruePositions }] = await Promise.all([
import('./level-utils'),
])
+/** Stand-in for a level's Object3D — LevelSystem only touches these fields. */
+function fakeLevelObject(): Object3D {
+ return {
+ position: { y: -100 },
+ visible: true,
+ layers: { mask: 0 },
+ } as unknown as Object3D
+}
+
function setupLevels(baseElevations: number[]) {
const buildingId = 'building_base-elevation-system-test'
- const levels: FakeLevelNode[] = baseElevations.map((baseElevation, level) => ({
+ const levels = baseElevations.map((baseElevation, level) => ({
+ object: 'node',
id: `level_base-elevation-system-${level}`,
type: 'level',
parentId: buildingId,
+ visible: true,
+ metadata: {},
+ children: [],
level,
baseElevation,
- children: [],
+ height: 2.5,
}))
- const building: FakeBuildingNode = {
+ const building = {
+ object: 'node',
id: buildingId,
type: 'building',
+ parentId: null,
+ visible: true,
+ metadata: {},
children: levels.map((level) => level.id),
}
- nodes = Object.fromEntries([building, ...levels].map((node) => [node.id, node]))
+
+ const nodes = Object.fromEntries(
+ [building, ...levels].map((node) => [node.id, node]),
+ ) as unknown as Record
+ useScene.setState({ nodes })
const objects = levels.map((level) => {
- const object: FakeLevelObject = {
- position: { y: -100 },
- visible: true,
- }
+ const object = fakeLevelObject()
sceneRegistry.nodes.set(level.id, object)
- sceneRegistry.byType.level.add(level.id)
+ sceneRegistry.byType.level!.add(level.id)
return object
})
@@ -120,14 +103,14 @@ function setLevelMode(
function updateLevelPresentation(delta: number) {
frameCallback = null
LevelSystem()
- expect(frameCallback).not.toBeNull()
- frameCallback?.({}, delta)
+ const callback = takeFrameCallback()
+ expect(callback).not.toBeNull()
+ callback?.({}, delta)
}
afterEach(() => {
- sceneRegistry.nodes.clear()
- sceneRegistry.byType.level.clear()
- nodes = {}
+ sceneRegistry.clear()
+ useScene.setState({ nodes: {} as Record })
})
describe('updateLevelPresentation', () => {
From 0f479236eda86a84bfe7f277d8bc006d1ccaa2b9 Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Tue, 4 Aug 2026 17:30:08 -0400
Subject: [PATCH 4/5] test(core): build a fresh nodes record when re-asserting
the lowered base elevation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#556 landed an identity-keyed WeakMap memo on getLevelElevations after this
branch last ran green. The new base-elevation case mutated `stackedNodes`
in place, so the second resolveStairTotalRise call handed the memo the same
object and got the cached 2.9 back instead of 2.1.
The memo's contract holds in production — every store write publishes a new
record (updateNodesAction spreads into `nextNodes`) — so the fix belongs in
the test. The sibling storey-height case in this same file already builds a
fresh record; match it.
Co-Authored-By: Claude Opus 5
---
packages/core/src/systems/stair/stair-rise.test.ts | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts
index aac4fcbf7..8a2e78e7a 100644
--- a/packages/core/src/systems/stair/stair-rise.test.ts
+++ b/packages/core/src/systems/stair/stair-rise.test.ts
@@ -169,8 +169,15 @@ describe('resolveStairTotalRise', () => {
} as Record
expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.9)
- stackedNodes.level_2 = { ...upper, baseElevation: -0.4 }
- expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.1)
+ // A fresh record, not a mutation of `stackedNodes`: getLevelElevations
+ // memoises on the identity of the nodes object, which holds because the
+ // store always publishes a new record. Mutating in place would read the
+ // cached elevations and silently assert nothing.
+ const loweredNodes = {
+ ...stackedNodes,
+ level_2: { ...upper, baseElevation: -0.4 },
+ } as Record
+ expect(resolveStairTotalRise(stair, loweredNodes)).toBeCloseTo(2.1)
})
it('prefers an explicit totalRise over the storey height', () => {
From b96c3b9721e33febc498d6bad885f5145e346475 Mon Sep 17 00:00:00 2001
From: Aymeric Rabot
Date: Tue, 4 Aug 2026 17:37:12 -0400
Subject: [PATCH 5/5] fix(core): take the highest ceiling in the stack as the
elevator shaft top
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The editor-side helper this PR consolidates into resolveElevatorLevels
guarded the stack top with Math.max over every level; the core version
reads only the topmost level. With the new baseElevation that is no longer
the same thing — a negative offset can sink the top level's ceiling below
the level beneath it, and the shaft then tops out under a served level and
clips the cab (3 m -> 2.5 m for a -2.5 m offset on a 2 m top storey).
Co-Authored-By: Claude Opus 5
---
.../systems/elevator/elevator-service.test.ts | 32 +++++++++++++++++++
.../src/systems/elevator/elevator-service.ts | 14 +++++---
2 files changed, 41 insertions(+), 5 deletions(-)
diff --git a/packages/core/src/systems/elevator/elevator-service.test.ts b/packages/core/src/systems/elevator/elevator-service.test.ts
index bb5107128..baf250c93 100644
--- a/packages/core/src/systems/elevator/elevator-service.test.ts
+++ b/packages/core/src/systems/elevator/elevator-service.test.ts
@@ -45,4 +45,36 @@ describe('resolveElevatorLevels', () => {
expect(resolved.shaftBaseY).toBe(stacked.get('level_0')?.baseY)
expect(resolved.shaftTopY).toBeCloseTo(8.2)
})
+
+ // A negative offset large enough to sink the top level's ceiling below the
+ // one beneath it must not drag the shaft top down with it — the cab travels
+ // to the highest served ceiling, so a lower shaft top would clip it.
+ test('a sunken top level does not pull the shaft top below the level beneath it', () => {
+ const levels = [
+ LevelNode.parse({ id: 'level_0', parentId: 'building_1', level: 0, height: 3 }),
+ LevelNode.parse({
+ id: 'level_1',
+ parentId: 'building_1',
+ level: 1,
+ baseElevation: -2.5,
+ height: 2,
+ }),
+ ]
+ const elevator = ElevatorNode.parse({
+ id: 'elevator_1',
+ parentId: 'building_1',
+ fromLevelId: 'level_0',
+ toLevelId: 'level_1',
+ })
+ const building = BuildingNode.parse({
+ id: 'building_1',
+ children: [...levels.map((level) => level.id), elevator.id],
+ })
+ const nodes = Object.fromEntries(
+ [building, ...levels, elevator].map((node) => [node.id, node]),
+ ) as Record
+
+ // level_0 ceiling is 3; level_1 sits at 3 - 2.5 = 0.5 and tops out at 2.5.
+ expect(resolveElevatorLevels(elevator, nodes).shaftTopY).toBeCloseTo(3)
+ })
})
diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts
index 24369cfe0..1c4390d2a 100644
--- a/packages/core/src/systems/elevator/elevator-service.ts
+++ b/packages/core/src/systems/elevator/elevator-service.ts
@@ -105,11 +105,15 @@ export function resolveElevatorLevels(
? allLevels.findIndex((level) => level.id === lastServedLevel.id)
: -1
const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null
- const lastStackedLevel = allLevels[allLevels.length - 1]
- const stackTopY = lastStackedLevel
- ? (levelElevations.get(lastStackedLevel.id)?.baseY ?? 0) +
- (levelElevations.get(lastStackedLevel.id)?.height ?? 0)
- : 0
+ // Highest ceiling in the stack, not the topmost level's: a negative
+ // baseElevation on the top level can put its ceiling below the level
+ // beneath it, and a shaft top under a served level would clip the cab.
+ let stackTopY = 0
+ for (const level of allLevels) {
+ const elevation = levelElevations.get(level.id)
+ if (!elevation) continue
+ stackTopY = Math.max(stackTopY, elevation.baseY + elevation.height)
+ }
const shaftTopY = nextLevel
? (levelElevations.get(nextLevel.id)?.baseY ?? stackTopY)
: lastServedLevel