fix(viewer): stack levels per building (#519)
* fix(viewer): stack levels per building * fix(viewer): recover legacy level ownership
This commit is contained in:
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, number> {
|
||||||
|
const positions = new Map<string, number>()
|
||||||
|
const cumulativeYByBuilding = new Map<string | null, number>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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 { useFrame } from '@react-three/fiber'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
import { lerp } from 'three/src/math/MathUtils.js'
|
import { lerp } from 'three/src/math/MathUtils.js'
|
||||||
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only'
|
import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only'
|
||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
|
import { getLevelBuildingId, getLevelStackPositions } from './level-stacking'
|
||||||
|
|
||||||
const EXPLODED_GAP = 5
|
const EXPLODED_GAP = 5
|
||||||
|
|
||||||
@@ -19,31 +26,44 @@ export const LevelSystem = () => {
|
|||||||
const levelMode = useViewer.getState().levelMode
|
const levelMode = useViewer.getState().levelMode
|
||||||
const selectedLevel = useViewer.getState().selection.levelId
|
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.
|
// Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc.
|
||||||
type LevelEntry = {
|
type LevelEntry = {
|
||||||
levelId: string
|
levelId: string
|
||||||
|
buildingId: string | null
|
||||||
index: number
|
index: number
|
||||||
|
height: number
|
||||||
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
||||||
}
|
}
|
||||||
const entries: LevelEntry[] = []
|
const entries: LevelEntry[] = []
|
||||||
|
const buildings = Object.values(nodes).filter(
|
||||||
|
(node): node is BuildingNode => node?.type === 'building',
|
||||||
|
)
|
||||||
sceneRegistry.byType.level!.forEach((levelId) => {
|
sceneRegistry.byType.level!.forEach((levelId) => {
|
||||||
const obj = sceneRegistry.nodes.get(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) {
|
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
|
const selectedIndex = selectedLevel
|
||||||
? entries.find((e) => e.levelId === selectedLevel)?.index
|
? entries.find((e) => e.levelId === selectedLevel)?.index
|
||||||
: undefined
|
: undefined
|
||||||
let cumulativeY = 0
|
|
||||||
for (const { levelId, index, obj } of entries) {
|
for (const { levelId, index, obj } of entries) {
|
||||||
const level = nodes[levelId as LevelNode['id']]
|
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||||
const baseY = cumulativeY
|
const baseY = stackPositions.get(levelId) ?? 0
|
||||||
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
|
const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0
|
||||||
const targetY = baseY + explodedExtra
|
const targetY = baseY + explodedExtra
|
||||||
|
|
||||||
@@ -65,12 +85,6 @@ export const LevelSystem = () => {
|
|||||||
}
|
}
|
||||||
obj.visible = !hidden
|
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
|
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -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
|
* Instantly snaps all level Objects3D to their true stacked Y positions
|
||||||
@@ -18,18 +25,33 @@ export function snapLevelsToTruePositions(): () => void {
|
|||||||
type LevelEntry = {
|
type LevelEntry = {
|
||||||
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
|
||||||
levelId: string
|
levelId: string
|
||||||
|
buildingId: string | null
|
||||||
index: number
|
index: number
|
||||||
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries: LevelEntry[] = []
|
const entries: LevelEntry[] = []
|
||||||
|
const buildings = Object.values(nodes).filter(
|
||||||
|
(node): node is BuildingNode => node?.type === 'building',
|
||||||
|
)
|
||||||
sceneRegistry.byType.level!.forEach((levelId) => {
|
sceneRegistry.byType.level!.forEach((levelId) => {
|
||||||
const obj = sceneRegistry.nodes.get(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) {
|
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
|
// Snapshot current Y and visibility so we can restore them after the render
|
||||||
const snapshot = new Map(
|
const snapshot = new Map(
|
||||||
@@ -37,15 +59,9 @@ export function snapLevelsToTruePositions(): () => void {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Snap to true stacked positions and make all levels visible
|
// Snap to true stacked positions and make all levels visible
|
||||||
let cumulativeY = 0
|
|
||||||
for (const { levelId, obj } of entries) {
|
for (const { levelId, obj } of entries) {
|
||||||
obj.position.y = cumulativeY
|
obj.position.y = stackPositions.get(levelId) ?? 0
|
||||||
obj.visible = true
|
obj.visible = true
|
||||||
cumulativeY += getLevelHeight(
|
|
||||||
levelId,
|
|
||||||
nodes,
|
|
||||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user