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
22 changes: 21 additions & 1 deletion packages/core/src/schema/nodes/level.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,34 @@ import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
import { HvacEquipmentNode } from './hvac-equipment'
import { LevelNode } from './level'
import { LevelNode, normalizeLevelBaseElevation } from './level'
import { LinesetNode } from './lineset'
import { LiquidLineNode } from './liquid-line'
import { PipeFittingNode } from './pipe-fitting'
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('normalizes legacy missing and invalid baseElevation values to a finite zero', () => {
expect(normalizeLevelBaseElevation(undefined)).toBe(0)
expect(normalizeLevelBaseElevation(Number.NaN)).toBe(0)
expect(Number.isNaN(normalizeLevelBaseElevation(undefined))).toBe(false)
})

test('accepts every level-hosted MEP node ID', () => {
const nodes = [
DuctSegmentNode.parse({
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ type CoreLevelChildId =

const LevelChildId = z.string().transform((id) => id as CoreLevelChildId)

export const DEFAULT_LEVEL_BASE_ELEVATION = 0

export function normalizeLevelBaseElevation(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : DEFAULT_LEVEL_BASE_ELEVATION
}

export const LevelNode = BaseNode.extend({
id: objectId('level'),
type: nodeType('level'),
Expand All @@ -64,6 +70,10 @@ export const LevelNode = BaseNode.extend({
children: z.array(LevelChildId).default([]),
// Specific props
level: z.number().default(0),
baseElevation: z
.number()
.default(DEFAULT_LEVEL_BASE_ELEVATION)
.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 +85,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
1 change: 1 addition & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export {
getLevelAbove,
getLevelBelow,
getLevelElevations,
getLevelFloorToFloorHeight,
getStoredLevelHeight,
getWallPlaneTop,
type LevelElevation,
Expand Down
80 changes: 78 additions & 2 deletions 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 Expand Up @@ -276,11 +321,12 @@ describe('getLevelBelow', () => {

// Two stacked levels in one building; `slabs` become children of the level
// above the queried one.
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5, aboveBaseElevation = 0) =>
buildNodes([
building('building_a', ['level_0', 'level_1']),
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
level('level_1', 1, {
baseElevation: aboveBaseElevation,
height: 2.5,
parentId: 'building_a',
children: slabs.map((node) => node.id),
Expand All @@ -296,6 +342,17 @@ describe('getCoveringSlabUndersideAt', () => {
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})

test('includes positive and negative offsets in the covering plane', () => {
const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })

expect(getCoveringSlabUndersideAt('level_0', stackedNodes([slab], 2.5, 0.4), 2, 2)).toBeCloseTo(
2.6,
)
expect(
getCoveringSlabUndersideAt('level_0', stackedNodes([slab], 2.5, -0.4), 2, 2),
).toBeCloseTo(1.8)
})

test('returns null outside the slab polygon', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull()
Expand Down Expand Up @@ -358,6 +415,14 @@ describe('getWallPlaneTop', () => {
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})

test('uses offset-aware floor spacing for positive and negative wall clamps', () => {
const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })
const wall = wallAt([0.5, 2], [3.5, 2])

expect(getWallPlaneTop(wall, 'level_0', stackedNodes([slab], 2.5, 0.4))).toBeCloseTo(2.6)
expect(getWallPlaneTop(wall, 'level_0', stackedNodes([slab], 2.5, -0.4))).toBeCloseTo(1.8)
})

test('a slab covering only part of the span clamps via the min of the samples', () => {
// Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it,
// only the end sample (4,2) lands inside — the min still clamps.
Expand Down Expand Up @@ -471,6 +536,17 @@ describe('getCeilingClampBound', () => {
)
})

test('uses offset-aware floor spacing for positive and negative ceiling clamps', () => {
const slab = slabNode('slab_deck', { elevation: 0, thickness: 0.3 })

expect(
getCeilingClampBound('level_0', stackedNodes([slab], 2.5, 0.4), ceilingPolygon),
).toBeCloseTo(2.6 - CEILING_CLAMP_MARGIN)
expect(
getCeilingClampBound('level_0', stackedNodes([slab], 2.5, -0.4), ceilingPolygon),
).toBeCloseTo(1.8 - CEILING_CLAMP_MARGIN)
})

test('a slab covering only the interior is caught by the centroid sample', () => {
// Deck hovers over the middle of the ceiling — every vertex sample
// misses, only the centroid (2, 2) lands inside it.
Expand Down
67 changes: 48 additions & 19 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 All @@ -90,6 +91,26 @@ export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<strin
return elevations
}

function resolveLevelFloorToFloorHeight(
levelId: string,
elevations: Map<string, LevelElevation>,
): number | null {
const current = elevations.get(levelId)
if (!current) return null

const aboveId = findLevelAboveId(levelId, elevations)
if (!aboveId) return current.height
const above = elevations.get(aboveId)
return above ? above.baseY - current.baseY : current.height
}

export function getLevelFloorToFloorHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
return resolveLevelFloorToFloorHeight(levelId, getLevelElevations(nodes)) ?? DEFAULT_LEVEL_HEIGHT
}

/**
* The id of the level directly above `levelId` in its own stack (same
* resolved building, or the shared legacy stack for building-less levels):
Expand Down Expand Up @@ -170,8 +191,8 @@ export function getLevelBelow(
}

type CoveringSlabContext = {
/** Stored storey height of the QUERIED level. */
storeyHeight: number
/** Offset-aware distance from the queried floor to the floor above. */
floorToFloorHeight: number
/** Non-recessed slab children of the level above. */
slabs: SlabNode[]
}
Expand All @@ -189,7 +210,10 @@ function resolveCoveringSlabContext(
const level = nodes[levelId as LevelNode['id']]
if (level?.type !== 'level') return null

const above = getLevelAbove(levelId, nodes)
const elevations = getLevelElevations(nodes)
const aboveId = findLevelAboveId(levelId, elevations)
const aboveNode = aboveId ? nodes[aboveId as LevelNode['id']] : null
const above = aboveNode?.type === 'level' ? (aboveNode as LevelNode) : null
const slabs: SlabNode[] = []
for (const childId of above?.children ?? []) {
const child = nodes[childId as keyof typeof nodes]
Expand All @@ -201,16 +225,21 @@ function resolveCoveringSlabContext(
slabs.push(slab)
}

return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs }
return {
floorToFloorHeight:
resolveLevelFloorToFloorHeight(levelId, elevations) ??
getStoredLevelHeight(level as LevelNode),
slabs,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Base elevation skips geometry rebuild

High Severity

Walls and ceilings now derive their plane from offset-aware floor-to-floor spacing via getLevelFloorToFloorHeight, so a level’s baseElevation changes the storey below. spatial-grid-sync still only dirties dependents when height changes, not baseElevation, so editing Base elevation moves the stack in 3D while walls, ceilings, and fences on the level below keep stale geometry until something else rebuilds them. Stairs happen to update because StairOpeningSystem already reacts to any level node change.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ac82cf8. Configure here.

}
}

/**
* Underside of `slab`'s solid in the QUERIED level's local Y. The solid
* occupies `[elevation - thickness, elevation]` in ITS level's local Y,
* which sits `storeyHeight` above the queried level's floor.
* which sits `floorToFloorHeight` above the queried level's floor.
*/
function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
function coveringUndersideY(floorToFloorHeight: number, slab: SlabNode): number {
return floorToFloorHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
}

/**
Expand Down Expand Up @@ -248,7 +277,7 @@ function lowestCoveringUndersideAt(
let lowest: number | null = null
for (const slab of context.slabs) {
if (!slabCoversPoint(slab, x, z)) continue
const underside = coveringUndersideY(context.storeyHeight, slab)
const underside = coveringUndersideY(context.floorToFloorHeight, slab)
if (lowest === null || underside < lowest) lowest = underside
}
return lowest
Expand All @@ -257,7 +286,7 @@ function lowestCoveringUndersideAt(
/**
* Underside of the LOWEST slab from the level above that covers
* level-local point `[x, z]`, expressed in the queried level's local Y:
* `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs
* `floorToFloorHeight + (slab.elevation - slab.thickness)`. `recessed` slabs
* (pools) never cover. `null` when no covering slab (or no level above).
*
* Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ
Expand Down Expand Up @@ -304,9 +333,9 @@ export function getWallPlaneTop(
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return DEFAULT_LEVEL_HEIGHT

let plane = context.storeyHeight
let plane = context.floorToFloorHeight
for (const slab of context.slabs) {
const underside = coveringUndersideY(context.storeyHeight, slab)
const underside = coveringUndersideY(context.floorToFloorHeight, slab)
if (underside >= plane) continue
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
plane = underside
Expand Down Expand Up @@ -336,7 +365,7 @@ export function getCeilingClampBound(
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return Number.POSITIVE_INFINITY

let bound = context.storeyHeight
let bound = context.floorToFloorHeight
if (polygon.length > 0) {
let cx = 0
let cz = 0
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/store/use-scene-vertical-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ describe('scene vertical model migration', () => {
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})

test('materializes a finite zero base elevation for legacy levels', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, []),
})

const baseElevation = (nodes.level_a as LevelResult).baseElevation
expect(baseElevation).toBe(0)
expect(Number.isNaN(baseElevation)).toBe(false)
})

test('hole pattern: walls within 0.20 of the plane become plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
Expand Down
Loading
Loading