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