diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts
index 11523fbe0..f7e1f971e 100644
--- a/packages/core/src/schema/nodes/level.ts
+++ b/packages/core/src/schema/nodes/level.ts
@@ -27,11 +27,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 floor, wall, ceiling, roof, item nodes
- level: level number
+ - baseElevation: additive Y offset in meters above the computed stack position
`,
)
diff --git a/packages/core/test/level.test.ts b/packages/core/test/level.test.ts
new file mode 100644
index 000000000..2be5b682a
--- /dev/null
+++ b/packages/core/test/level.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from 'bun:test'
+import { LevelNode } from '../src/schema/nodes/level'
+
+describe('LevelNode', () => {
+ test('defaults baseElevation to 0', () => {
+ const level = LevelNode.parse({
+ level: 0,
+ name: 'Ground',
+ })
+
+ expect(level.baseElevation).toBe(0)
+ })
+
+ test('accepts a custom baseElevation', () => {
+ const level = LevelNode.parse({
+ baseElevation: 1.25,
+ level: 1,
+ name: 'Split level',
+ })
+
+ expect(level.baseElevation).toBe(1.25)
+ })
+})
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 a48c036d7..6e7ab87a6 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
@@ -34,6 +34,7 @@ import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-sel
import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
+import { MetricControl } from '../../../controls/metric-control'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode } from './tree-node'
import { TreeNodeDragProvider } from './tree-node-drag'
@@ -766,6 +767,17 @@ 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 ?? 0) * 100) / 100}
+ />
+
{
// Walk sorted levels, accumulating base Y offsets
let cumulativeY = 0
for (const { levelId, index, obj } of entries) {
- const level = nodes[levelId as LevelNode['id']]
- const baseY = cumulativeY
+ const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
- const targetY = baseY + explodedExtra
+ const targetY = getLevelTargetY(cumulativeY, level, explodedExtra)
obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel
- cumulativeY += getLevelHeight(levelId, nodes)
+ cumulativeY = getNextLevelCumulativeY(cumulativeY, getLevelHeight(levelId, nodes), level)
}
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
return null
diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts
index 06bb66219..539bbfbec 100644
--- a/packages/viewer/src/systems/level/level-utils.ts
+++ b/packages/viewer/src/systems/level/level-utils.ts
@@ -8,6 +8,28 @@ import {
export const DEFAULT_LEVEL_HEIGHT = 2.5
+type LevelWithBaseElevation = Pick
+
+export function getLevelBaseElevation(level: LevelWithBaseElevation | null | undefined): number {
+ return level?.baseElevation ?? 0
+}
+
+export function getLevelTargetY(
+ cumulativeY: number,
+ level: LevelWithBaseElevation | null | undefined,
+ explodedExtra = 0,
+): number {
+ return cumulativeY + getLevelBaseElevation(level) + explodedExtra
+}
+
+export function getNextLevelCumulativeY(
+ cumulativeY: number,
+ levelHeight: number,
+ level: LevelWithBaseElevation | null | undefined,
+): number {
+ return cumulativeY + levelHeight + getLevelBaseElevation(level)
+}
+
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
// Zustand produces a new `nodes` object on every mutation, so reference equality
// is a zero-cost way to detect stale data without any subscription overhead.
@@ -88,9 +110,10 @@ export function snapLevelsToTruePositions(): () => void {
// Snap to true stacked positions and make all levels visible
let cumulativeY = 0
for (const { levelId, obj } of entries) {
- obj.position.y = cumulativeY
+ const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
+ obj.position.y = getLevelTargetY(cumulativeY, level)
obj.visible = true
- cumulativeY += getLevelHeight(levelId, nodes)
+ cumulativeY = getNextLevelCumulativeY(cumulativeY, getLevelHeight(levelId, nodes), level)
}
return () => {
diff --git a/packages/viewer/test/level-utils.test.ts b/packages/viewer/test/level-utils.test.ts
new file mode 100644
index 000000000..7369f5a34
--- /dev/null
+++ b/packages/viewer/test/level-utils.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, mock, test } from 'bun:test'
+
+mock.module('@pascal-app/core', () => ({
+ sceneRegistry: {
+ byType: { level: new Set() },
+ nodes: new Map(),
+ },
+ useScene: {
+ getState: () => ({ nodes: {} }),
+ },
+}))
+
+const { getLevelTargetY, getNextLevelCumulativeY } = await import(
+ '../src/systems/level/level-utils'
+)
+
+describe('level vertical offsets', () => {
+ test('adds baseElevation to target Y and cumulative stack height', () => {
+ const lowerLevel = { baseElevation: 0 }
+ const upperLevel = { baseElevation: 1.25 }
+ let cumulativeY = 0
+
+ expect(getLevelTargetY(cumulativeY, lowerLevel)).toBe(0)
+ cumulativeY = getNextLevelCumulativeY(cumulativeY, 2.5, lowerLevel)
+
+ expect(getLevelTargetY(cumulativeY, upperLevel)).toBe(3.75)
+ cumulativeY = getNextLevelCumulativeY(cumulativeY, 2.5, upperLevel)
+
+ expect(cumulativeY).toBe(6.25)
+ })
+})