Skip to content
14 changes: 14 additions & 0 deletions packages/core/src/schema/nodes/level.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
`,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -872,6 +873,17 @@ const LevelItem = memo(function LevelItem({
initial={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
<div className="relative border-border/50 border-b py-2 pr-3 pl-[60px]">
<div className="pointer-events-none absolute top-0 bottom-0 left-[45px] z-10 w-px bg-border/50" />
<MetricControl
label="Base elevation"
onChange={(value) => updateNode(level.id, { baseElevation: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round(level.baseElevation * 100) / 100}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
/>
</div>
<LevelReferences
isLastLevel={isLast}
levelId={level.id}
Expand Down
72 changes: 72 additions & 0 deletions packages/viewer/src/systems/level/level-stacking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,76 @@ describe('getLevelStackPositions', () => {
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,
})
})
})
8 changes: 6 additions & 2 deletions packages/viewer/src/systems/level/level-stacking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type LevelStackEntry = {
buildingId: string | null
index: number
height: number
baseElevation?: number
}

type BuildingOwnership = { id: string; children: readonly string[] }
Expand All @@ -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)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}

return positions
Expand Down
165 changes: 165 additions & 0 deletions packages/viewer/src/systems/level/level-system.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
const registryNodes = new Map<string, FakeLevelObject>()
const sceneRegistry = {
byType: { level: levelIds },
nodes: registryNodes,
}
let nodes: Record<string, FakeLevelNode | FakeBuildingNode> = {}
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])
})
})
2 changes: 2 additions & 0 deletions packages/viewer/src/systems/level/level-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const LevelSystem = () => {
buildingId: string | null
index: number
height: number
baseElevation: number
obj: NonNullable<ReturnType<typeof sceneRegistry.nodes.get>>
}
const entries: LevelEntry[] = []
Expand All @@ -52,6 +53,7 @@ export const LevelSystem = () => {
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
),
baseElevation: level.baseElevation,
obj,
})
}
Expand Down
2 changes: 2 additions & 0 deletions packages/viewer/src/systems/level/level-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function snapLevelsToTruePositions(): () => void {
buildingId: string | null
index: number
height: number
baseElevation: number
}

const entries: LevelEntry[] = []
Expand All @@ -47,6 +48,7 @@ export function snapLevelsToTruePositions(): () => void {
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
),
baseElevation: level.baseElevation,
obj,
})
}
Expand Down
Loading