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