diff --git a/packages/viewer/src/systems/level/level-stacking.test.ts b/packages/viewer/src/systems/level/level-stacking.test.ts new file mode 100644 index 00000000..c71d7668 --- /dev/null +++ b/packages/viewer/src/systems/level/level-stacking.test.ts @@ -0,0 +1,67 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// include Bun ambient types in its production declaration build. +import { describe, expect, test } from 'bun:test' +import { getLevelBuildingId, getLevelStackPositions, type LevelStackEntry } from './level-stacking' + +describe('getLevelBuildingId', () => { + const buildings = [ + { id: 'building_a', children: ['level_a0'] }, + { id: 'building_b', children: ['level_b0'] }, + ] + + test('uses an explicit building parent', () => { + expect(getLevelBuildingId('level_a0', 'building_a', buildings)).toBe('building_a') + }) + + test('falls back to building children for legacy levels without a parentId', () => { + expect(getLevelBuildingId('level_b0', null, buildings)).toBe('building_b') + }) + + test('ignores a non-building parent before checking building children', () => { + expect(getLevelBuildingId('level_a0', 'site_main', buildings)).toBe('building_a') + }) +}) + +describe('getLevelStackPositions', () => { + test('stacks levels within one building by level index', () => { + const entries: LevelStackEntry[] = [ + { levelId: 'level_second', buildingId: 'building_a', index: 2, height: 3.4 }, + { levelId: 'level_ground', buildingId: 'building_a', index: 0, height: 2.5 }, + { levelId: 'level_first', buildingId: 'building_a', index: 1, height: 3.1 }, + ] + + expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ + level_ground: 0, + level_first: 2.5, + level_second: 5.6, + }) + }) + + test('starts each building on its own ground plane', () => { + const entries: LevelStackEntry[] = [ + { levelId: 'level_a0', buildingId: 'building_a', index: 0, height: 2.5 }, + { levelId: 'level_b0', buildingId: 'building_b', index: 0, height: 3 }, + { levelId: 'level_a1', buildingId: 'building_a', index: 1, height: 2.8 }, + { levelId: 'level_b1', buildingId: 'building_b', index: 1, height: 3.2 }, + ] + + expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ + level_a0: 0, + level_b0: 0, + level_a1: 2.5, + level_b1: 3, + }) + }) + + test('keeps orphan levels in one legacy stack', () => { + const entries: LevelStackEntry[] = [ + { levelId: 'level_0', buildingId: null, index: 0, height: 2.7 }, + { levelId: 'level_1', buildingId: null, index: 1, height: 3 }, + ] + + expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ + level_0: 0, + level_1: 2.7, + }) + }) +}) diff --git a/packages/viewer/src/systems/level/level-stacking.ts b/packages/viewer/src/systems/level/level-stacking.ts new file mode 100644 index 00000000..b966e383 --- /dev/null +++ b/packages/viewer/src/systems/level/level-stacking.ts @@ -0,0 +1,32 @@ +export type LevelStackEntry = { + levelId: string + buildingId: string | null + index: number + height: number +} + +type BuildingOwnership = { id: string; children: readonly string[] } + +export function getLevelBuildingId( + levelId: string, + parentId: string | null, + buildings: readonly BuildingOwnership[], +): string | null { + const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined + if (directParent) return directParent.id + + return buildings.find((building) => building.children.includes(levelId))?.id ?? null +} + +export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map { + const positions = new Map() + const cumulativeYByBuilding = new 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) + } + + return positions +} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index b32b976c..80af5af3 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,9 +1,16 @@ -import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + type BuildingNode, + getLevelHeight, + type LevelNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' +import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' const EXPLODED_GAP = 5 @@ -19,31 +26,44 @@ export const LevelSystem = () => { const levelMode = useViewer.getState().levelMode const selectedLevel = useViewer.getState().selection.levelId - // Collect and sort levels by floor index so we can compute cumulative offsets. + // Collect level heights so each building can compute its own cumulative offsets. // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc. type LevelEntry = { levelId: string + buildingId: string | null index: number + height: number obj: NonNullable> } const entries: LevelEntry[] = [] + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node?.type === 'building', + ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) - const level = nodes[levelId as LevelNode['id']] + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { - entries.push({ levelId, index: (level as any).level ?? 0, obj }) + entries.push({ + levelId, + buildingId: getLevelBuildingId(levelId, level.parentId, buildings), + index: level.level, + height: getLevelHeight( + levelId, + nodes, + (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, + ), + obj, + }) } }) - entries.sort((a, b) => a.index - b.index) + const stackPositions = getLevelStackPositions(entries) - // Walk sorted levels, accumulating base Y offsets const selectedIndex = selectedLevel ? entries.find((e) => e.levelId === selectedLevel)?.index : undefined - 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 baseY = stackPositions.get(levelId) ?? 0 const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 const targetY = baseY + explodedExtra @@ -65,12 +85,6 @@ export const LevelSystem = () => { } obj.visible = !hidden } - - cumulativeY += getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ) } }, 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 bf595c6c..25c03ba9 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,4 +1,11 @@ -import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +import { + type BuildingNode, + getLevelHeight, + type LevelNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' /** * Instantly snaps all level Objects3D to their true stacked Y positions @@ -18,18 +25,33 @@ export function snapLevelsToTruePositions(): () => void { type LevelEntry = { obj: NonNullable> levelId: string + buildingId: string | null index: number + height: number } const entries: LevelEntry[] = [] + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node?.type === 'building', + ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) - const level = nodes[levelId as LevelNode['id']] + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { - entries.push({ levelId, index: (level as any).level ?? 0, obj }) + entries.push({ + levelId, + buildingId: getLevelBuildingId(levelId, level.parentId, buildings), + index: level.level, + height: getLevelHeight( + levelId, + nodes, + (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, + ), + obj, + }) } }) - entries.sort((a, b) => a.index - b.index) + const stackPositions = getLevelStackPositions(entries) // Snapshot current Y and visibility so we can restore them after the render const snapshot = new Map( @@ -37,15 +59,9 @@ 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 + obj.position.y = stackPositions.get(levelId) ?? 0 obj.visible = true - cumulativeY += getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ) } return () => {