diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index f30d91bc0..de9bc8ee1 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -3,7 +3,7 @@ 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' @@ -11,6 +11,26 @@ 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({ diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 60d15aa9b..2032c61a7 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -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'), @@ -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 @@ -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 `, ) diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 245793f62..14c4e00cb 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -111,6 +111,7 @@ export { getLevelAbove, getLevelBelow, getLevelElevations, + getLevelFloorToFloorHeight, getStoredLevelHeight, getWallPlaneTop, type LevelElevation, diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts index 2cce58eff..1ca93d9f4 100644 --- a/packages/core/src/services/storey.test.ts +++ b/packages/core/src/services/storey.test.ts @@ -20,13 +20,19 @@ const buildNodes = (list: AnyNode[]): Record => 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 }), }) @@ -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']), @@ -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), @@ -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() @@ -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. @@ -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. diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts index 4fd6563fe..bc7d5482e 100644 --- a/packages/core/src/services/storey.ts +++ b/packages/core/src/services/storey.ts @@ -24,7 +24,7 @@ export function getStoredLevelHeight(level: Pick): 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 @@ -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. */ @@ -61,12 +61,13 @@ export function getLevelElevations(nodes: Record): Map 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), @@ -77,7 +78,7 @@ export function getLevelElevations(nodes: Record): Map() const cumulativeYByBuilding = new Map() 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 elevations.set(entry.levelId, { baseY, height: entry.height, @@ -90,6 +91,26 @@ export function getLevelElevations(nodes: Record): Map, +): 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, +): 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): @@ -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[] } @@ -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] @@ -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, + } } /** * 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)) } /** @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts index a95e7839b..15d34bbfb 100644 --- a/packages/core/src/store/use-scene-vertical-migration.test.ts +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -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']), diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 60f9a2329..0883be89b 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -10,7 +10,7 @@ import type { Collection, CollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections' import { DoorNode as DoorNodeSchema } from '../schema/nodes/door' import { ElevatorNode as ElevatorNodeSchema } from '../schema/nodes/elevator' -import { LevelNode } from '../schema/nodes/level' +import { LevelNode, normalizeLevelBaseElevation } from '../schema/nodes/level' import { getPitchFromActiveRoofHeight, type RoofSegmentNode, @@ -948,6 +948,7 @@ function migrateNodes(nodes: Record): { const levelNumber = getFiniteNumber(node.level, 0) patchedNodes[id] = { ...node, + baseElevation: normalizeLevelBaseElevation(node.baseElevation), level: levelNumber, children: validChildren, } diff --git a/packages/core/src/systems/elevator/elevator-service.test.ts b/packages/core/src/systems/elevator/elevator-service.test.ts new file mode 100644 index 000000000..bb5107128 --- /dev/null +++ b/packages/core/src/systems/elevator/elevator-service.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, BuildingNode, ElevatorNode, LevelNode } from '../../schema' +import { getLevelElevations } from '../../services/storey' +import { resolveElevatorLevels } from './elevator-service' + +describe('resolveElevatorLevels', () => { + test('matches offset-aware stacked level positions', () => { + const levels = [ + LevelNode.parse({ id: 'level_0', parentId: 'building_1', level: 0, height: 2.5 }), + LevelNode.parse({ + id: 'level_1', + parentId: 'building_1', + level: 1, + baseElevation: 0.4, + height: 3, + }), + LevelNode.parse({ + id: 'level_2', + parentId: 'building_1', + level: 2, + baseElevation: -0.2, + height: 2.5, + }), + ] + const elevator = ElevatorNode.parse({ + id: 'elevator_1', + parentId: 'building_1', + fromLevelId: 'level_0', + toLevelId: 'level_2', + }) + const building = BuildingNode.parse({ + id: 'building_1', + children: [...levels.map((level) => level.id), elevator.id], + }) + const nodes = Object.fromEntries( + [building, ...levels, elevator].map((node) => [node.id, node]), + ) as Record + + const stacked = getLevelElevations(nodes) + const resolved = resolveElevatorLevels(elevator, nodes) + + expect(resolved.entries.map((entry) => entry.baseY)).toEqual( + levels.map((level) => stacked.get(level.id)?.baseY), + ) + expect(resolved.shaftBaseY).toBe(stacked.get('level_0')?.baseY) + expect(resolved.shaftTopY).toBeCloseTo(8.2) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 124746e9f..24369cfe0 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' -import { getStoredLevelHeight } from '../../services/storey' +import { getLevelElevations } from '../../services/storey' export type ElevatorLevelEntry = { id: LevelNode['id'] @@ -84,19 +84,13 @@ export function resolveElevatorLevels( totalHeight: number } { const allLevels = resolveElevatorBuildingLevels(elevator, nodes) - - const baseYByLevelId = new Map() - let cumulativeY = 0 - for (const level of allLevels) { - baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getStoredLevelHeight(level) - } + const levelElevations = getLevelElevations(nodes as Record) const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) const entries = serviceLevels.map((level) => ({ id: level.id, label: String(level.level), - baseY: baseYByLevelId.get(level.id) ?? 0, + baseY: levelElevations.get(level.id)?.baseY ?? 0, })) const defaultEntry = @@ -106,15 +100,20 @@ export function resolveElevatorLevels( null const firstServedLevel = serviceLevels[0] ?? null const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null - const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 + const shaftBaseY = firstServedLevel ? (levelElevations.get(firstServedLevel.id)?.baseY ?? 0) : 0 const lastServedIndex = lastServedLevel ? allLevels.findIndex((level) => level.id === lastServedLevel.id) : -1 const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null + const lastStackedLevel = allLevels[allLevels.length - 1] + const stackTopY = lastStackedLevel + ? (levelElevations.get(lastStackedLevel.id)?.baseY ?? 0) + + (levelElevations.get(lastStackedLevel.id)?.height ?? 0) + : 0 const shaftTopY = nextLevel - ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) + ? (levelElevations.get(nextLevel.id)?.baseY ?? stackTopY) : lastServedLevel - ? cumulativeY + ? stackTopY : elevator.cabHeight + 0.3 return { diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts index 00309cbfa..aac4fcbf7 100644 --- a/packages/core/src/systems/stair/stair-rise.test.ts +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -8,7 +8,7 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' import type { AnyNode, StairNode as StairNodeType } from '../../schema' -import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { BuildingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' import { resolveStairTotalRise, syncStairRises } from './stair-rise' // The deck branch elects the stair's floor-stack base through the node @@ -146,6 +146,33 @@ describe('resolveStairTotalRise', () => { expect(resolveStairTotalRise(stair, updated)).toBe(3.0) }) + it('includes the next level base elevation in a following stair rise', () => { + const { stair, nodes } = buildScene(2.5, undefined) + const current = nodes.level_1 + if (current.type !== 'level') throw new Error('expected level') + const building = BuildingNode.parse({ + id: 'building_1', + children: ['level_1', 'level_2'], + }) + const upper = LevelNode.parse({ + id: 'level_2', + parentId: building.id, + level: 1, + baseElevation: 0.4, + height: 2.5, + }) + const stackedNodes = { + ...nodes, + [building.id]: building, + level_1: { ...current, parentId: building.id }, + level_2: upper, + } as Record + + expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.9) + stackedNodes.level_2 = { ...upper, baseElevation: -0.4 } + expect(resolveStairTotalRise(stair, stackedNodes)).toBeCloseTo(2.1) + }) + it('prefers an explicit totalRise over the storey height', () => { const { stair, nodes } = buildScene(3.2, 2.5) expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts index 12054831c..f67a07755 100644 --- a/packages/core/src/systems/stair/stair-rise.ts +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -1,7 +1,7 @@ import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation' import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' -import { getStoredLevelHeight } from '../../services/storey' +import { getLevelFloorToFloorHeight } from '../../services/storey' export function resolveStairTotalRise(stair: StairNode, nodes: Record): number { if (stair.totalRise !== undefined) return stair.totalRise @@ -33,7 +33,9 @@ export function resolveStairTotalRise(stair: StairNode, nodes: Record) + : DEFAULT_LEVEL_HEIGHT } const RISE_SYNC_EPSILON = 1e-4 diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index d5517896a..869247914 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -21,9 +21,8 @@ import { openElevatorDoor, pointInPolygon2D, requestElevatorLevel, - resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, - resolveElevatorServiceLevels, + resolveElevatorLevels, sceneRegistry, useInteractive, useScene, @@ -430,45 +429,6 @@ function isInsideElevatorCab( ) } -function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) { - const allLevels = resolveElevatorBuildingLevels(elevator, nodes) - const levelElevations = getLevelElevations(nodes as Record) - - const baseYByLevelId = new Map() - let cumulativeY = 0 - for (const level of allLevels) { - const elevation = levelElevations.get(level.id) - const baseY = elevation?.baseY ?? 0 - baseYByLevelId.set(level.id, baseY) - cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0)) - } - - const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) - const entries = serviceLevels.map((level) => ({ - baseY: baseYByLevelId.get(level.id) ?? 0, - id: level.id as AnyNodeId, - })) - const firstServedLevel = serviceLevels[0] ?? null - const lastServedLevel = serviceLevels[serviceLevels.length - 1] ?? null - const shaftBaseY = firstServedLevel ? (baseYByLevelId.get(firstServedLevel.id) ?? 0) : 0 - const lastServedIndex = lastServedLevel - ? allLevels.findIndex((level) => level.id === lastServedLevel.id) - : -1 - const nextLevel = lastServedIndex >= 0 ? allLevels[lastServedIndex + 1] : null - const shaftTopY = nextLevel - ? (baseYByLevelId.get(nextLevel.id) ?? cumulativeY) - : lastServedLevel - ? cumulativeY - : elevator.cabHeight + 0.3 - - return { - entries, - shaftBaseY, - shaftTopY, - totalHeight: Math.max(shaftTopY - shaftBaseY, elevator.cabHeight + 0.3), - } -} - function createElevatorColliderMesh( elevatorId: AnyNodeId, kind: ElevatorColliderKind, @@ -519,10 +479,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] { const node = nodes[typedElevatorId] if (node?.type !== 'elevator' || node.visible === false) continue - const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorColliderLevels( - node, - nodes, - ) + const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes) const cabWidth = getElevatorCabWidth(node) const cabDepth = getElevatorCabDepth(node) const shaftWidth = getElevatorShaftWidth(node, cabWidth) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 87a6352b4..e397930aa 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -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' @@ -873,6 +874,17 @@ const LevelItem = memo(function LevelItem({ initial={{ height: 0, opacity: 0 }} transition={{ type: 'spring', bounce: 0, duration: 0.3 }} > +
+
+ updateNode(level.id, { baseElevation: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((level.baseElevation ?? 0) * 100) / 100} + /> +
() +const registryNodes = new Map() +const sceneRegistry = { + byType: { level: levelIds }, + nodes: registryNodes, +} +let nodes: Record = {} +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() + const cumulativeYByBuilding = new Map() + 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]) + }) +}) diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md index 80c588215..e34f2d92a 100644 --- a/wiki/architecture/vertical-model.md +++ b/wiki/architecture/vertical-model.md @@ -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. | @@ -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 |