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