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