Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
`,
)

Expand Down
23 changes: 23 additions & 0 deletions packages/core/test/level.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -766,6 +767,17 @@ 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 ?? 0) * 100) / 100}
/>
</div>
<LevelReferences
isLastLevel={isLast}
levelId={level.id}
Expand Down
9 changes: 4 additions & 5 deletions packages/viewer/src/systems/level/level-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { lerp } from 'three/src/math/MathUtils.js'
import useViewer from '../../store/use-viewer'
import { getLevelHeight } from './level-utils'
import { getLevelHeight, getLevelTargetY, getNextLevelCumulativeY } from './level-utils'

const EXPLODED_GAP = 5

Expand Down Expand Up @@ -32,15 +32,14 @@ export const LevelSystem = () => {
// 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
Expand Down
27 changes: 25 additions & 2 deletions packages/viewer/src/systems/level/level-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import {

export const DEFAULT_LEVEL_HEIGHT = 2.5

type LevelWithBaseElevation = Pick<LevelNode, 'baseElevation'>

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.
Expand Down Expand Up @@ -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 () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/viewer/test/level-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, mock, test } from 'bun:test'

mock.module('@pascal-app/core', () => ({
sceneRegistry: {
byType: { level: new Set<string>() },
nodes: new Map<string, { position: { y: number }; visible: boolean }>(),
},
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)
})
})