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 @@ -64,6 +64,10 @@ export const LevelNode = BaseNode.extend({
children: z.array(LevelChildId).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."),
/**
* Stored storey height in meters (floor-to-floor). No zod default on
* purpose: absence marks unmigrated legacy data and gates the load-time
Expand All @@ -75,6 +79,7 @@ export const LevelNode = BaseNode.extend({
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
- height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data
`,
)
Expand Down
47 changes: 46 additions & 1 deletion packages/core/src/services/storey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ const buildNodes = (list: AnyNode[]): Record<AnyNodeId, AnyNode> =>
const level = (
id: string,
ordinal: number,
opts: { height?: number; parentId?: string | null; children?: string[] } = {},
opts: {
baseElevation?: number
height?: number
parentId?: string | null
children?: string[]
} = {},
): LevelNode =>
LevelNode.parse({
id,
level: ordinal,
parentId: opts.parentId ?? null,
children: opts.children ?? [],
...(opts.baseElevation === undefined ? {} : { baseElevation: opts.baseElevation }),
...(opts.height === undefined ? {} : { height: opts.height }),
})

Expand Down Expand Up @@ -113,6 +119,45 @@ describe('getLevelElevations', () => {
expect(elevations.get('level_b1')?.buildingId).toBe('building_b')
})

test('applies an offset to its level and every higher level in the same building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0', 'level_a1', 'level_a2']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { height: 2.5, parentId: 'building_a' }),
level('level_b0', 0, { height: 3, parentId: 'building_b' }),
level('level_a1', 1, {
baseElevation: 1.25,
height: 3,
parentId: 'building_a',
}),
level('level_b1', 1, { height: 3, parentId: 'building_b' }),
level('level_a2', 2, { height: 2.8, parentId: 'building_a' }),
])

const elevations = getLevelElevations(nodes)
expect(elevations.get('level_a0')?.baseY).toBe(0)
expect(elevations.get('level_b0')?.baseY).toBe(0)
expect(elevations.get('level_a1')?.baseY).toBe(3.75)
expect(elevations.get('level_b1')?.baseY).toBe(3)
expect(elevations.get('level_a2')?.baseY).toBe(6.75)
})

test('allows negative offsets', () => {
const nodes = buildNodes([
building('building_a', ['level_ground', 'level_first']),
level('level_ground', 0, {
baseElevation: -0.75,
height: 2.5,
parentId: 'building_a',
}),
level('level_first', 1, { height: 3, parentId: 'building_a' }),
])

const elevations = getLevelElevations(nodes)
expect(elevations.get('level_ground')?.baseY).toBe(-0.75)
expect(elevations.get('level_first')?.baseY).toBe(1.75)
})

test('negative ordinals stack from the lowest level up', () => {
const nodes = buildNodes([
building('building_a', ['level_basement', 'level_ground', 'level_upper']),
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/services/storey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function getStoredLevelHeight(level: Pick<LevelNode, 'height'>): number {
}

export type LevelElevation = {
/** World Y of the level's floor: prefix sum of the storey heights below it. */
/** World Y of the level's floor: cumulative heights and level offsets through this level. */
baseY: number
/** Stored storey height of this level (fallback applied). */
height: number
Expand All @@ -49,10 +49,10 @@ function resolveLevelBuildingId(
}

/**
* Per-building stacked elevations from stored storey heights: levels are
* sorted by ordinal ascending within each building, the lowest level's floor
* sits at 0, and each next floor sits on top of the previous storey height.
* Levels with no resolvable building share one legacy stack from 0.
* Per-building stacked elevations from stored storey heights and additive
* base-elevation offsets: levels are sorted by ordinal ascending within each
* building, and each offset shifts its level plus every higher level in the
* same stack. Levels with no resolvable building share one legacy stack.
*
* Pure — operates on the serialized nodes record only.
*/
Expand All @@ -61,12 +61,13 @@ export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<strin
(node): node is BuildingNode => node?.type === 'building',
)

const entries: Array<{ levelId: string } & LevelElevation> = []
const entries: Array<{ baseElevation: number; levelId: string } & LevelElevation> = []
for (const node of Object.values(nodes)) {
if (node?.type !== 'level') continue
const level = node as LevelNode
entries.push({
levelId: level.id,
baseElevation: level.baseElevation ?? 0,
baseY: 0,
height: getStoredLevelHeight(level),
buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings),
Expand All @@ -77,7 +78,7 @@ export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<strin
const elevations = new Map<string, LevelElevation>()
const cumulativeYByBuilding = new Map<string | null, number>()
for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) {
const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0
const baseY = (cumulativeYByBuilding.get(entry.buildingId) ?? 0) + entry.baseElevation
Comment thread
cursor[bot] marked this conversation as resolved.
elevations.set(entry.levelId, {
baseY,
height: entry.height,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 @@ -873,6 +874,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
178 changes: 178 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,178 @@
// @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', () => ({
getLevelElevations: () => {
const elevations = new Map<string, { baseY: number }>()
const cumulativeYByBuilding = new Map<string, number>()
const levels = Object.values(nodes)
.filter((node): node is FakeLevelNode => node.type === 'level')
.sort((a, b) => a.level - b.level)

for (const level of levels) {
const baseY = (cumulativeYByBuilding.get(level.parentId) ?? 0) + level.baseElevation
elevations.set(level.id, { baseY })
cumulativeYByBuilding.set(level.parentId, baseY + 2.5)
}
return elevations
},
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])
})
})
5 changes: 3 additions & 2 deletions wiki/architecture/vertical-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ The invariant, in one sentence:

| Field | Meaning | Absent means |
|---|---|---|
| `level.height` | Storey height in meters, floor-to-floor. Level world Y = per-building prefix sum of stored heights, ordered by the `level` ordinal (`getLevelElevations`). | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). |
| `level.height` | Storey height in meters, floor-to-floor. Level world Y is resolved by `getLevelElevations`, ordered by the `level` ordinal. | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). |
| `level.baseElevation` | Additive offset from the computed stack position. It shifts this level and cumulatively shifts every higher level in the same building; negative offsets are valid. | Zero (the schema default). |
| `wall.height` | Explicit body height (half wall, parapet, or a raised-support draft whose ghost height must remain invariant). Ground-hosted walls always resolve top = elected base + height, including below datum; other legacy sunken supports retain their absolute-top constraint. | **Plane-bound** (the default for ordinary datum placement): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. |
| `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. |
| `slab.elevation` | The walking surface (top), level-local. | Default 0.05. |
Expand All @@ -41,7 +42,7 @@ Two schema rules protect these semantics:

| Helper | Home | Resolves |
|---|---|---|
| `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, per-building stacking, neighbors |
| `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, offset-aware per-building stacking, neighbors |
| `getWallPlaneTop` | `services/storey.ts` | A plane-bound wall's top: level height clamped to covering-slab undersides, span-sampled with boundary-inclusive band overlap |
| `resolveWallTop`, `resolveWallEffectiveHeight`, `MIN_WALL_HEIGHT` | `systems/wall/wall-top.ts` | A wall's top / effective height given plane + elected base |
| `getWallBaseElevationForNodes`, `getWallEffectiveHeightForNodes` | spatial-grid manager | The elected base and body height with terrain/support offsets, for UI overlays |
Expand Down
Loading