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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy levels show NaN elevation

Medium Severity

The new Base elevation control uses Math.round(level.baseElevation * 100) / 100 without a fallback. Levels loaded from older project JSON can omit baseElevation because scene migration only normalizes level and children, not this field, so the control receives NaN even though stacking treats missing values as zero.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92a550e. Configure here.

/>
</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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elevator stack ignores base elevation

Medium Severity

Per-level baseElevation now shifts stacked level meshes via getLevelStackPositions, but elevator level tables and first-person elevator colliders still build cumulative Y from story heights only. Non-zero offsets leave cab stops and shaft geometry out of sync with the floors users see in the editor.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92a550e. Configure here.

}

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