Merge remote-tracking branch 'origin/main' into feat/realtime-collaboration-sfx

# Conflicts:
#	packages/core/src/schema/nodes/level.test.ts
#	packages/core/src/schema/nodes/level.ts
#	packages/core/src/store/use-scene.ts
This commit is contained in:
Aymeric Rabot
2026-07-23 17:43:41 +02:00
367 changed files with 37993 additions and 3768 deletions
+9
View File
@@ -9,10 +9,12 @@ import type {
CeilingNode,
ChimneyNode,
ColumnNode,
ConstructionDimensionNode,
CupolaNode,
DoorNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctFittingNode,
DuctSegmentNode,
DuctTerminalNode,
@@ -42,6 +44,7 @@ import type {
SpawnNode,
StairNode,
StairSegmentNode,
StructuralGridNode,
TurbineVentNode,
WallNode,
WindowNode,
@@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent<SlabNode>
export type SpawnEvent = NodeEvent<SpawnNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type ColumnEvent = NodeEvent<ColumnNode>
export type ConstructionDimensionEvent = NodeEvent<ConstructionDimensionNode>
export type RoofEvent = NodeEvent<RoofNode>
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
export type StairEvent = NodeEvent<StairNode>
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type StructuralGridEvent = NodeEvent<StructuralGridNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
export type ElevatorEvent = NodeEvent<ElevatorNode>
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
export type SkylightEvent = NodeEvent<SkylightNode>
export type DormerEvent = NodeEvent<DormerNode>
export type DownspoutEvent = NodeEvent<DownspoutNode>
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
@@ -295,10 +301,12 @@ type EditorEvents = GridEvents &
NodeEvents<'spawn', SpawnEvent> &
NodeEvents<'ceiling', CeilingEvent> &
NodeEvents<'column', ColumnEvent> &
NodeEvents<'construction-dimension', ConstructionDimensionEvent> &
NodeEvents<'roof', RoofEvent> &
NodeEvents<'roof-segment', RoofSegmentEvent> &
NodeEvents<'stair', StairEvent> &
NodeEvents<'stair-segment', StairSegmentEvent> &
NodeEvents<'structural-grid', StructuralGridEvent> &
NodeEvents<'window', WindowEvent> &
NodeEvents<'door', DoorEvent> &
NodeEvents<'scan', ScanEvent> &
@@ -314,6 +322,7 @@ type EditorEvents = GridEvents &
NodeEvents<'skylight', SkylightEvent> &
NodeEvents<'dormer', DormerEvent> &
NodeEvents<'downspout', DownspoutEvent> &
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
NodeEvents<'duct-segment', DuctSegmentEvent> &
NodeEvents<'duct-fitting', DuctFittingEvent> &
NodeEvents<'duct-terminal', DuctTerminalEvent> &
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */
const DECK_POLYGON: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
/** Ground floor slab under (and far beyond) the deck. */
const GROUND_POLYGON: Array<[number, number]> = [
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
/** Straight fence fully over the deck footprint. */
function fenceOnDeck(overrides: Partial<FenceSupportInput> = {}): FenceSupportInput {
return {
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.08,
parentId: LEVEL_ID,
...overrides,
}
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
function sceneWith(...slabs: SlabNode[]): Record<string, AnyNode> {
const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[]))
useScene.setState({ nodes })
for (const slab of slabs) addSlab(slab)
return nodes
}
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('resolveFenceSupportSlabPatch', () => {
test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('the pointer cap decides between stacked surfaces', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
// Aiming at the floor under the deck elects (and persists) the floor.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }),
).toEqual({ supportSlabId: 'slab_ground' })
// Aiming at the deck top keeps the deck.
expect(
resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }),
).toEqual({ supportSlabId: 'slab_deck' })
})
test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => {
// Unambiguous single candidate — but fences resolve an absent host to
// the level floor, so an elevated winner must be written or the fence
// renders buried under the deck.
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: 'slab_deck',
})
})
test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => {
const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
})
test('capped at bare ground under a deck-only overlap resolves to the floor default', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({
supportSlabId: undefined,
})
})
test('no slabs / off-slab fence persists nothing', () => {
const nodes = sceneWith()
expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({
supportSlabId: undefined,
})
const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(
resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck),
).toEqual({ supportSlabId: undefined })
})
test('a spline fence elects through its path band segments', () => {
const nodes = sceneWith(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION),
makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION),
)
const spline = fenceOnDeck({
start: [0.5, 0.5],
end: [3.5, 2.5],
path: [
[0.5, 0.5],
[2, 1.5],
[3.5, 2.5],
],
})
expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a fence not parented to a level persists nothing', () => {
const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({
supportSlabId: undefined,
})
})
})
@@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab
holes: [],
holeMetadata: [],
elevation,
thickness: Math.max(elevation, 0),
recessed: elevation < 0,
autoFromWalls: false,
} as SlabNode
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
@@ -8,12 +8,29 @@ import type {
import type { AnyNode, AnyNodeId } from '../../schema'
import { spatialGridManager } from './spatial-grid-manager'
/**
* Sentinel `supportSlabId` meaning "hosted by the level base (ground)".
* Persisted when a pointer-capped commit elects the ground while one or
* more slabs (e.g. an elevated deck) still overlap the footprint above the
* cap — without it, the uncapped per-frame election would lift the
* committed node back onto the deck.
*/
export const GROUND_SUPPORT_ID = 'ground'
export type FloorPlacedElevationArgs = {
node: AnyNode
nodes: Record<string, AnyNode>
position: [number, number, number]
rotation?: unknown
levelId?: string | null
/**
* Pointer-decided support cap (level-local Y): only slabs whose walking
* surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON`
* may be elected, and the persisted `supportSlabId` is bypassed — during
* a drag the pointer, not the stored host, decides the target surface.
* Omit (or pass null) for the uncapped committed-read behavior.
*/
maxElevation?: number | null
}
function finiteSlabElevation(elevation: number): number {
@@ -50,6 +67,7 @@ export function getFloorPlacedElevation({
position,
rotation,
levelId,
maxElevation,
}: FloorPlacedElevationArgs): number {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced) return 0
@@ -66,8 +84,31 @@ export function getFloorPlacedElevation({
const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId
if (!resolvedLevelId) return 0
let maxElevation = Number.NEGATIVE_INFINITY
for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) {
const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })
// A persisted support host pins the elevation while it still exists and
// overlaps a footprint — deterministic across stacked slabs. A stale
// host (deleted or reshaped away) silently falls through to the
// election below; this per-frame read path never writes the field.
// Skipped entirely under a pointer cap: the cursor, not the stored
// host, decides the target surface during a drag.
const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId
if (maxElevation == null && supportSlabId) {
if (supportSlabId === GROUND_SUPPORT_ID) return 0
for (const footprint of footprints) {
const hosted = spatialGridManager.getHostSlabElevationForFootprint(
resolvedLevelId,
supportSlabId,
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
)
if (hosted !== null) return finiteSlabElevation(hosted)
}
}
let elected = Number.NEGATIVE_INFINITY
for (const footprint of footprints) {
const footprintPosition = footprint.position ?? position
const elevation = finiteSlabElevation(
spatialGridManager.getSlabElevationForItem(
@@ -75,14 +116,15 @@ export function getFloorPlacedElevation({
footprintPosition,
footprint.dimensions,
footprint.rotation,
maxElevation,
),
)
if (elevation > maxElevation) {
maxElevation = elevation
if (elevation > elected) {
elected = elevation
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
return elected === Number.NEGATIVE_INFINITY ? 0 : elected
}
export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] {
@@ -0,0 +1,487 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { resolveSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
/** Deck footprint in plan: x/z ∈ [-1, 1]. */
const DECK_POLYGON: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */
const GROUND_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function addDeckAndFloor() {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
describe('pointer-capped slab support election', () => {
test('hit at the floor under the deck elects the floor, not the deck above', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
FLOOR_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('hit on the deck top still elects the deck', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('no cap keeps the historical max election', () => {
addDeckAndFloor()
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
})
test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => {
// Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not.
addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0.1, slabId: 'slab_within' })
spatialGridManager.clear()
addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05),
).toEqual({ elevation: 0, slabId: null })
})
})
describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => {
test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => {
addDeckAndFloor()
// Camera in front of the deck (negative z), high up. Aiming at the
// floor point (0, FLOOR, 0) — a point that lies UNDER the deck in
// plan — crosses the deck's elevation plane before reaching the deck
// polygon, so only the floor is hit.
const origin: [number, number, number] = [0, 5, -10]
const toFloorUnderDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
0 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual(
{ elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] },
)
// Aiming at the deck's top surface: the deck plane crossing lands
// inside the deck polygon and is nearer along the ray than the floor.
const toDeckTop: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.5 - origin[2],
]
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({
elevation: DECK_ELEVATION,
slabId: 'slab_deck',
point: [0, 0.5],
})
})
test('a ray through a deck hole falls through to the surface below', () => {
addSlab(
makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, {
holes: [
[
[-0.5, -0.5],
[0.5, -0.5],
[0.5, 0.5],
[-0.5, 0.5],
],
],
}),
)
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
// Straight down through the hole center.
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 0],
})
})
test('no slab crossing resolves the level base (with the base-plane point)', () => {
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({
elevation: 0,
slabId: null,
point: [3, 3],
})
})
test('a ray that cannot reach any surface has no point', () => {
addDeckAndFloor()
expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({
elevation: 0,
slabId: null,
point: null,
})
})
})
describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => {
// Manual repro this pins down: deck slab stacked above a floor slab,
// move an item over the deck near its far edge with an angled camera.
// The grid event plane rides at the ghost's LAST surface height, so the
// same screen ray produces hit points whose XZ differ by metres
// depending on which storey the plane rode at. The cap (ray → pointed
// surface) is plane-height independent, but electing at the RAW hit XZ
// is not: the floor-height hit is perspective-skewed past the deck, its
// footprint misses the deck polygon, and the capped election falls to
// the floor — dropping the ghost, which drops the plane, which keeps
// the hit skewed (a second self-consistent state). Transitions between
// the two states are the hop. Electing at the ray-derived `point`
// leaves a single fixed point per pointer ray.
const origin: [number, number, number] = [0, 5, -10]
/** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */
const aimAtDeck: [number, number, number] = [
0 - origin[0],
DECK_ELEVATION - origin[1],
0.8 - origin[2],
]
test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => {
addDeckAndFloor()
// The two grid hits the SAME screen ray produces — one per event-plane
// height (plane riding at the deck vs at the floor slab).
const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1]
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [
origin[0] + aimAtDeck[0] * t,
origin[1] + aimAtDeck[1] * t,
origin[2] + aimAtDeck[2] * t,
])
for (const hit of planeHits) {
const direction: [number, number, number] = [
hit[0] - origin[0],
hit[1] - origin[1],
hit[2] - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction)
expect(pointed.slabId).toBe('slab_deck')
expect(pointed.elevation).toBe(DECK_ELEVATION)
expect(pointed.point?.[0]).toBeCloseTo(0, 10)
expect(pointed.point?.[1]).toBeCloseTo(0.8, 10)
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[pointed.point![0], 0, pointed.point![1]],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' })
}
})
test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => {
addDeckAndFloor()
const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1]
const floorPlaneHit: [number, number, number] = [
origin[0] + aimAtDeck[0] * tFloor,
0,
origin[2] + aimAtDeck[2] * tFloor,
]
// The skew carries the hit metres past the deck's far edge (z = 1)…
expect(floorPlaneHit[2]).toBeGreaterThan(2)
// …so the same pointer ray, elected at the raw hit XZ, picks the
// FLOOR while the cap says the pointer is on the deck.
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
floorPlaneHit,
[1, 1, 1],
[0, 0, 0],
DECK_ELEVATION,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
test('pointer past the deck edge: pointed point lands on the floor and elects it', () => {
addDeckAndFloor()
// Aimed at a floor point far enough out that the deck-plane crossing
// falls outside the deck polygon (the floor there is actually visible).
const aimPastDeck: [number, number, number] = [
0 - origin[0],
FLOOR_ELEVATION - origin[1],
4 - origin[2],
]
const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck)
expect(pointed).toEqual({
elevation: FLOOR_ELEVATION,
slabId: 'slab_floor',
point: [0, 4],
})
expect(
spatialGridManager.getSlabSupportForItem(
LEVEL_ID,
[0, 0, 4],
[1, 1, 1],
[0, 0, 0],
pointed.elevation,
),
).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' })
})
})
describe('getFloorPlacedElevation under a pointer cap', () => {
test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: DECK_ELEVATION,
}),
).toBeCloseTo(DECK_ELEVATION)
// Uncapped read keeps the historical max election.
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(DECK_ELEVATION)
})
test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
maxElevation: FLOOR_ELEVATION,
}),
).toBeCloseTo(FLOOR_ELEVATION)
})
test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBe(0)
})
})
describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => {
test('a commit under the deck persists the elected lower slab', () => {
registerFloorPlacedItem()
addDeckAndFloor()
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_floor',
})
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
// Uncapped commits keep the historical rule (max winner on ambiguity).
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('a commit on bare ground under the deck persists the ground sentinel', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({
supportSlabId: GROUND_SUPPORT_ID,
})
// Aiming at the deck top with only the deck overlapping stays
// unambiguous — no host persisted, same as the uncapped rule.
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
test('a single floor slab under the cap stays unpersisted (unambiguous)', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION))
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node)
expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: undefined,
})
})
})
@@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
import { getWallPlaneTop } from '../../services/storey'
import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import {
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallSlabSupport,
} from '../../systems/slab/slab-support'
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid'
export {
computeWallSlabElevation,
computeWallSlabSupport,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
type WallOverlapInput,
type WallSlabSupport,
type WallSlabSupportSegment,
wallOverlapsPolygon,
} from '../../systems/slab/slab-support'
// ============================================================================
// GEOMETRY HELPERS
// ============================================================================
/**
* Point-in-polygon test using ray casting algorithm.
*/
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0],
zi = polygon[i]![1]
const xj = polygon[j]![0],
zj = polygon[j]![1]
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
/**
* Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation.
*/
@@ -295,512 +294,29 @@ export function itemOverlapsPolygon(
return false
}
function pointSegmentDistance(
px: number,
pz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number {
const dx = bx - ax
const dz = bz - az
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
}
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
// boundary: the answer flips depending on which side of the polygon the edge
// is on. Interval classification below therefore treats "within this distance
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
// edge (the common case — auto-slab polygons derive from wall centerlines)
// classify identically on every side of the slab.
const ON_BOUNDARY_EPSILON = 1e-4
function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean {
const n = polygon.length
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i]!
const [bx, bz] = polygon[(i + 1) % n]!
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
}
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
for (let i = 0; i < n; i++) {
const [px, pz] = polygon[i]!
const [qx, qz] = polygon[(i + 1) % n]!
const ex = qx - px
const ez = qz - pz
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
const t = ((px - ax) * ez - (pz - az) * ex) / denom
const s = ((px - ax) * dz - (pz - az) * dx) / denom
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
}
ts.sort((a, b) => a - b)
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
if (t1 - t0 < 1e-9) continue
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
function polylineInsideLength(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
): number {
return intervalsLength(polylineInsideIntervals(points, polygon))
}
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
thickness?: number
}
// Minimum length of wall that must lie on/inside a slab polygon before the
// wall counts as overlapping it. Point contact (a perpendicular wall butting
// into a room's edge) clips to ~zero length and never reaches this, so such
// walls don't follow the slab's elevation.
const WALL_SLAB_MIN_OVERLAP = 0.05
/**
* Centerline of the wall plus its two face lines (centerline offset by
* ±halfThickness). The face lines catch walls whose centerline sits on or
* just outside the slab boundary but whose body reaches onto the slab —
* e.g. slab polygons drawn to the room's interior faces.
*/
function wallTestPolylines(
start: [number, number],
end: [number, number],
curveOffset: number,
halfThickness: number,
): Array<Array<{ x: number; y: number }>> {
const wallLike = { start, end, curveOffset }
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
const count = 16
const center: Array<{ x: number; y: number }> = []
const left: Array<{ x: number; y: number }> = []
const right: Array<{ x: number; y: number }> = []
for (let i = 0; i <= count; i++) {
const frame = getWallCurveFrameAt(wallLike, i / count)
center.push(frame.point)
left.push({
x: frame.point.x + frame.normal.x * halfThickness,
y: frame.point.y + frame.normal.y * halfThickness,
})
right.push({
x: frame.point.x - frame.normal.x * halfThickness,
y: frame.point.y - frame.normal.y * halfThickness,
})
}
return halfThickness > 0 ? [center, left, right] : [center]
}
const center = [
{ x: start[0], y: start[1] },
{ x: end[0], y: end[1] },
]
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const len = Math.hypot(dx, dz)
if (len < 1e-10 || halfThickness <= 0) return [center]
const nx = (-dz / len) * halfThickness
const nz = (dx / len) * halfThickness
return [
center,
[
{ x: start[0] + nx, y: start[1] + nz },
{ x: end[0] + nx, y: end[1] + nz },
],
[
{ x: start[0] - nx, y: start[1] - nz },
{ x: end[0] - nx, y: end[1] - nz },
],
]
}
/**
* Test whether a wall overlaps a slab polygon along a segment of its length.
*
* The wall's centerline and both face lines are clipped against the polygon;
* the wall overlaps when the longest clipped inside-or-on-boundary length
* exceeds a threshold (5cm, halved for very short walls). Because interval
* midpoints classify "on the boundary" as inside explicitly (never by
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
* identically on every side of the slab.
*
* A wall that only touches the polygon at a point — a perpendicular wall
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
* length and does NOT overlap.
*/
export function wallOverlapsPolygon(
startOrWall: [number, number] | WallOverlapInput,
endOrPolygon: [number, number] | Array<[number, number]>,
polygonArg?: Array<[number, number]>,
): boolean {
// Two call shapes:
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
let start: [number, number]
let end: [number, number]
let polygon: Array<[number, number]>
let curveOffset = 0
let thickness = DEFAULT_WALL_THICKNESS
if (Array.isArray(startOrWall)) {
start = startOrWall as [number, number]
end = endOrPolygon as [number, number]
polygon = polygonArg as Array<[number, number]>
} else {
start = startOrWall.start
end = startOrWall.end
curveOffset = startOrWall.curveOffset ?? 0
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
polygon = endOrPolygon as Array<[number, number]>
}
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
for (const line of polylines) {
overlap = Math.max(overlap, polylineInsideLength(line, polygon))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
/** One slab overlapping a queried footprint, as seen by support election. */
export type SlabSupportCandidate = {
slabId: string
elevation: number
}
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] }
const groups: ElevationGroup[] = []
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, perPolyline: polylines.map(() => []) }
groups.push(group)
}
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of evaluatedGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, baseElevation, baseSegments }
export type ItemSlabSupport = {
elevation: number
/** The winning slab, or null when no slab overlaps the footprint. */
slabId: string | null
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
export type PointedSupportSurface = ItemSlabSupport & {
/**
* Level-local XZ where the ray meets the pointed surface's plane, or
* null when the ray never reaches it (grazing / aimed above the base).
* This is the plan point the pointer actually indicates: unlike a grid
* event-plane hit — whose XZ shifts with whatever height the event
* plane currently rides at — it depends only on the ray and the
* aimed-at surface, so election/preview at this point cannot flip when
* the event plane changes storey.
*/
point: [number, number] | null
}
export class SpatialGridManager {
@@ -842,7 +358,24 @@ export class SpatialGridManager {
private getWallHeight(wallId: string): number {
const wall = this.walls.get(wallId)
return wall?.height ?? 2.5 // Default wall height
if (!wall) return 0
if (wall.height != null) return wall.height
const nodes = useScene.getState().nodes
const levelId = resolveNodeLevelId(wall, nodes)
const support = this.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(
wall,
getWallPlaneTop(wall, levelId, nodes),
support.elevation,
)
}
private getCeilingGrid(ceilingId: string): SpatialGrid {
@@ -859,15 +392,74 @@ export class SpatialGridManager {
return this.slabsByLevel.get(levelId)!
}
/**
* Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item
* support queries run per frame and the projection scans the level's
* walls + sibling slabs, so the result is cached per slab id and
* dropped for the whole level whenever a slab or wall on that level
* flows through the manager's create/update/delete handlers.
*/
private readonly renderedSlabPolygons = new Map<string, Array<[number, number]>>()
private invalidateRenderedSlabPolygons(levelId: string) {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return
for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId)
}
private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> {
const cached = this.renderedSlabPolygons.get(slab.id)
if (cached) return cached
const siblingSlabs: SlabNode[] = []
for (const other of this.getSlabMap(levelId).values()) {
if (other.id !== slab.id) siblingSlabs.push(other)
}
const polygon = getRenderableSlabPolygon(slab, {
walls: this.getLevelWallNodes(levelId),
siblingSlabs,
})
this.renderedSlabPolygons.set(slab.id, polygon)
return polygon
}
/**
* Support test shared by election, candidate listing, and persisted-host
* validation: the footprint overlaps the slab's RENDERED polygon (what
* users see — matching the wall election in `computeWallSlabSupport`),
* with the center-point hole veto kept against the stored holes (holes
* are data, never render-offset).
*/
private slabSupportsFootprint(
levelId: string,
slab: SlabNode,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): boolean {
if (slab.polygon.length < 3) return false
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false
const [cx, , cz] = position
for (const hole of slab.holes || []) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false
}
return true
}
// Called when nodes change
handleNodeCreated(node: AnyNode, levelId: string) {
if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') {
const wall = node as WallNode
this.walls.set(wall.id, wall)
// Rendered slab polygons adopt wall bands — a new wall can extend them.
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -920,11 +512,13 @@ export class SpatialGridManager {
handleNodeUpdated(node: AnyNode, levelId: string) {
if (node.type === 'slab') {
this.getSlabMap(levelId).set(node.id, node as SlabNode)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'ceiling') {
this.ceilings.set(node.id, node as CeilingNode)
} else if (node.type === 'wall') {
const wall = node as WallNode
this.walls.set(wall.id, wall)
this.invalidateRenderedSlabPolygons(levelId)
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') {
@@ -982,12 +576,16 @@ export class SpatialGridManager {
handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) {
if (nodeType === 'slab') {
// Invalidate before removal so the deleted slab's own cache entry
// (still keyed in the level map here) is dropped with its siblings'.
this.invalidateRenderedSlabPolygons(levelId)
this.getSlabMap(levelId).delete(nodeId)
} else if (nodeType === 'ceiling') {
this.ceilings.delete(nodeId)
this.ceilingGrids.delete(nodeId)
} else if (nodeType === 'wall') {
this.walls.delete(nodeId)
this.invalidateRenderedSlabPolygons(levelId)
// Remove all items attached to this wall from the spatial grid
const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId)
return removedItemIds // Caller can use this to delete the items from scene
@@ -1201,45 +799,162 @@ export class SpatialGridManager {
/**
* Get the slab elevation for an item using its full footprint (bounding box).
* Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes).
* Returns the highest overlapping slab elevation, or 0 if none.
* Thin wrapper over {@link getSlabSupportForItem} for callers (and tests)
* that only need the number.
*/
getSlabElevationForItem(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
maxElevation?: number | null,
): number {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return 0
return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation)
.elevation
}
let maxElevation = Number.NEGATIVE_INFINITY
/**
* Elect the supporting slab for a footprint: the highest-elevation slab
* whose RENDERED polygon the footprint overlaps (center-point hole veto
* applies). Returns `{ elevation: 0, slabId: null }` when nothing
* overlaps.
*
* `maxElevation` is the pointer-decided cap: when set, only slabs whose
* walking surface sits at or below `maxElevation +
* SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface
* the cursor ray actually hit never captures the election.
*/
getSlabSupportForItem(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
maxElevation?: number | null,
): ItemSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return { elevation: 0, slabId: null }
let winningElevation = Number.NEGATIVE_INFINITY
let winnerId: string | null = null
for (const slab of slabMap.values()) {
if (
slab.polygon.length >= 3 &&
itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)
) {
// Check if item is entirely within a hole (if so, ignore this slab)
// We consider it entirely in a hole if the item center is in the hole
const elevation = slab.elevation ?? 0.05
if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
if (elevation > winningElevation) {
winningElevation = elevation
winnerId = slab.id
}
}
return winnerId === null
? { elevation: 0, slabId: null }
: { elevation: winningElevation, slabId: winnerId }
}
/**
* The walking surface the pointer actually points at: the nearest slab
* plane the ray crosses INSIDE that slab's rendered polygon (hole veto
* applies), or the level base (`elevation: 0, slabId: null`) when it
* crosses none. Ray origin/direction are level-local. Deliberately a
* point test, not a footprint test — it answers "which surface is under
* the cursor", which then caps the footprint election so a deck hanging
* above the aimed-at floor never lifts the placement. `point` is the
* ray's crossing of that surface's plane — the stable plan point
* callers should elect/preview at (see {@link PointedSupportSurface}).
*/
getPointedSupportSurface(
levelId: string,
rayOrigin: [number, number, number],
rayDirection: [number, number, number],
): PointedSupportSurface {
const slabMap = this.slabsByLevel.get(levelId)
const [ox, oy, oz] = rayOrigin
const [dx, dy, dz] = rayDirection
if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null }
let best: { t: number; elevation: number; slabId: string } | null = null
if (slabMap) {
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
const elevation = slab.elevation ?? 0.05
const t = (elevation - oy) / dy
if (t <= 0) continue
if (best && t >= best.t) continue
const x = ox + dx * t
const z = oz + dz * t
const rendered = this.getRenderedSlabPolygon(levelId, slab)
if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue
let inHole = false
const [cx, , cz] = position
const holes = slab.holes || []
for (const hole of holes) {
if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) {
for (const hole of slab.holes || []) {
if (hole.length >= 3 && pointInPolygon(x, z, hole)) {
inHole = true
break
}
}
if (!inHole) {
const elevation = slab.elevation ?? 0.05
if (elevation > maxElevation) {
maxElevation = elevation
}
}
if (inHole) continue
best = { t, elevation, slabId: slab.id }
}
}
return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation
if (best) {
return {
elevation: best.elevation,
slabId: best.slabId,
point: [ox + dx * best.t, oz + dz * best.t],
}
}
const tBase = -oy / dy
return {
elevation: 0,
slabId: null,
point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null,
}
}
/**
* All slabs supporting a footprint, one entry per overlapping slab
* (highest elevation first; slab id breaks ties deterministically).
* Commit-side ambiguity check: persist a `supportSlabId` only when the
* candidates carry ≥ 2 distinct elevations.
*/
getSupportCandidatesForFootprint(
levelId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): SlabSupportCandidate[] {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) return []
const candidates: SlabSupportCandidate[] = []
for (const slab of slabMap.values()) {
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue
candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 })
}
candidates.sort(
(a, b) =>
b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0),
)
return candidates
}
/**
* Elevation of a persisted support host for a footprint, or null when
* the slab no longer exists on the level or no longer overlaps the
* footprint (same overlap test as election). Deliberately read-only: a
* host reshaped away is NOT cleared — callers fall back to election and
* the stale reference resumes hosting if the slab's polygon returns.
* Slab deletion is the only writer (`deleteNodesAction` strips it).
*/
getHostSlabElevationForFootprint(
levelId: string,
slabId: string,
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): number | null {
const slab = this.slabsByLevel.get(levelId)?.get(slabId)
if (!slab) return null
if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null
return slab.elevation ?? 0.05
}
/**
@@ -1255,8 +970,10 @@ export class SpatialGridManager {
end: [number, number],
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
): number {
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation
return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId)
.elevation
}
getSlabSupportForWall(
@@ -1265,11 +982,14 @@ export class SpatialGridManager {
end: [number, number],
curveOffset = 0,
thickness = DEFAULT_WALL_THICKNESS,
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport {
const slabMap = this.slabsByLevel.get(levelId)
if (!slabMap) {
return {
elevation: 0,
electedSlabId: null,
baseElevation: 0,
baseSegments: [{ start: 0, end: 1, elevation: 0 }],
}
@@ -1279,6 +999,8 @@ export class SpatialGridManager {
{ start, end, curveOffset, thickness },
[...slabMap.values()],
this.getLevelWallNodes(levelId),
preferredSlabId,
maxElevation,
)
}
@@ -1387,6 +1109,7 @@ export class SpatialGridManager {
}
clearLevel(levelId: string) {
this.invalidateRenderedSlabPolygons(levelId)
this.floorGrids.delete(levelId)
this.wallGrids.delete(levelId)
this.slabsByLevel.delete(levelId)
@@ -1400,8 +1123,33 @@ export class SpatialGridManager {
this.ceilingGrids.clear()
this.ceilings.clear()
this.itemCeilingMap.clear()
this.renderedSlabPolygons.clear()
}
}
// Singleton instance
export const spatialGridManager = new SpatialGridManager()
/**
* Effective (extruded) height of a wall resolved from a nodes record:
* {@link resolveWallEffectiveHeight} over the covering-clamped plane top
* (`getWallPlaneTop`) and the singleton manager's slab election — so the
* value always agrees with the rendered wall. One shared resolver for the
* editor overlays (measurement label, action menu, side handles) that used
* to copy this derivation locally.
*/
export function getWallEffectiveHeightForNodes(
wall: WallNode,
nodes: Record<string, AnyNode>,
): number {
const levelId = resolveNodeLevelId(wall, nodes)
const support = spatialGridManager.getSlabSupportForWall(
levelId,
wall.start,
wall.end,
wall.curveOffset ?? 0,
wall.thickness,
wall.supportSlabId ?? null,
)
return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation)
}
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { spatialGridManager } from './spatial-grid-manager'
import {
initSpatialGridSync,
markCoveringDependentsBelow,
markLevelHeightDependents,
} from './spatial-grid-sync'
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode {
return {
id,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: ordinal,
height,
} as AnyNode
}
function makeChild(id: string, type: string, parentId: string): AnyNode {
return {
id,
type,
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
start: [0, 1],
end: [4, 1],
thickness: 0.1,
polygon: SQUARE,
holes: [],
} as unknown as AnyNode
}
function makeSlab(id: string, parentId: string, overrides: Partial<AnyNode> = {}): AnyNode {
return {
id,
type: 'slab',
object: 'node',
parentId,
visible: true,
metadata: {},
children: [],
polygon: SQUARE,
holes: [],
holeMetadata: [],
elevation: 0.05,
thickness: 0.05,
autoFromWalls: false,
...overrides,
} as AnyNode
}
function nodesFor(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
function dirtyIds(): string[] {
return [...useScene.getState().dirtyNodes].sort()
}
describe('spatial-grid sync dirty rules (vertical model)', () => {
let stopSync = () => {}
// Two orphan levels sharing the legacy stack: level_0 (below) carries a
// wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab.
const wall = makeChild('wall_a', 'wall', 'level_0')
const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0')
const stair = makeChild('stair_a', 'stair', 'level_0')
const fence = makeChild('fence_a', 'fence', 'level_0')
const zone = makeChild('zone_a', 'zone', 'level_0')
const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 })
const level0 = makeLevel('level_0', 0, 2.5, [
'wall_a',
'ceiling_a',
'stair_a',
'fence_a',
'zone_a',
])
const level1 = makeLevel('level_1', 1, 2.5, ['slab_up'])
function setScene(nodes: Record<AnyNodeId, AnyNode>) {
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes,
readOnly: false,
rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[],
} as never)
clearSceneHistory()
}
beforeEach(() => {
spatialGridManager.clear()
setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab))
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
level_0: { ...level0, height: 3 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('a slab thickness change marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('a slab recessed toggle marks the walls and ceilings of the level below', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_up: { ...upperSlab, recessed: true } as AnyNode,
} as never,
})
expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a'])
})
test('creating a slab on the level above marks the level below, deleting it too', () => {
const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 })
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_new: added,
level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record<string, AnyNode>
useScene.setState({
nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never,
})
expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true)
})
})
describe('spatial-grid sync dirty rules (deck-attached stairs)', () => {
let stopSync = () => {}
const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 })
const attachedStair = {
...makeChild('stair_deck', 'stair', 'level_0'),
deckSlabId: 'slab_deck',
} as AnyNode
const otherStair = makeChild('stair_other', 'stair', 'level_0')
const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other'])
beforeEach(() => {
spatialGridManager.clear()
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(deckLevel, deck, attachedStair, otherStair),
readOnly: false,
rootNodeIds: ['level_0'] as AnyNodeId[],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
useScene.setState({ dirtyNodes: new Set<AnyNodeId>() })
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
test('changing a deck elevation marks its attached stair dirty, not other stairs', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: { ...deck, elevation: 1.6 } as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true)
expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false)
})
test('a deck polygon-only change leaves the attached stair alone', () => {
useScene.setState({
nodes: {
...useScene.getState().nodes,
slab_deck: {
...deck,
polygon: [
[0, 0],
[5, 0],
[5, 5],
[0, 5],
],
} as AnyNode,
} as never,
})
expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false)
})
})
describe('sync dirty helpers (pure)', () => {
const collect = () => {
const marked: string[] = []
return { marked, markDirty: (id: AnyNodeId) => marked.push(id) }
}
test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => {
const level = makeLevel('level_0', 0, 2.5, [
'wall_a',
'stair_a',
'ceiling_a',
'fence_a',
'zone_a',
'missing',
])
const nodes = nodesFor(
level,
makeChild('wall_a', 'wall', 'level_0'),
makeChild('stair_a', 'stair', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('fence_a', 'fence', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markLevelHeightDependents(level as never, nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a'])
})
test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']),
makeLevel('level_1', 1, 2.5, []),
makeChild('wall_a', 'wall', 'level_0'),
makeChild('ceiling_a', 'ceiling', 'level_0'),
makeChild('zone_a', 'zone', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_1', nodes, markDirty)
expect(marked.sort()).toEqual(['ceiling_a', 'wall_a'])
})
test('markCoveringDependentsBelow is a no-op for the lowest level', () => {
const nodes = nodesFor(
makeLevel('level_0', 0, 2.5, ['wall_a']),
makeChild('wall_a', 'wall', 'level_0'),
)
const { marked, markDirty } = collect()
markCoveringDependentsBelow('level_0', nodes, markDirty)
expect(marked).toEqual([])
})
})
@@ -1,6 +1,7 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema'
import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema'
import { getLevelBelow } from '../../services/storey'
import useScene from '../../store/use-scene'
import { getFloorPlacedFootprints } from './floor-placed-elevation'
import {
@@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is added, mark overlapping items/walls dirty
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
}
}
@@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void {
// When a slab is removed, mark items/walls that were on it dirty (using current state)
if (node.type === 'slab') {
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
markCoveringDependentsBelow(levelId, state.nodes, markDirty)
}
}
}
@@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void {
}
}
} else if (node.type === 'slab' && prev.type === 'slab') {
if (
const supportChanged =
node.polygon !== prev.polygon ||
node.elevation !== prev.elevation ||
node.holes !== prev.holes
) {
if (supportChanged) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
@@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void {
markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty)
markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty)
}
if (node.elevation !== prev.elevation) {
markDeckAttachedStairs(node.id, state.nodes, markDirty)
}
// The covering bound over the level below also moves with thickness
// (underside = elevation thickness) and recessed (pools never
// cover), which same-level support ignores.
if (
supportChanged ||
node.thickness !== prev.thickness ||
node.recessed !== prev.recessed
) {
markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty)
}
} else if (node.type === 'level' && prev.type === 'level') {
if (node.height !== prev.height) {
markLevelHeightDependents(node as LevelNode, state.nodes, markDirty)
}
} else if (node.type === 'wall' && prev.type === 'wall') {
if (
node.start !== prev.start ||
node.end !== prev.end ||
node.curveOffset !== prev.curveOffset ||
node.thickness !== prev.thickness
) {
// Rendered slab polygons adopt wall bands, so a wall reshape
// must reach the manager to refresh its wall map and drop the
// level's rendered-polygon cache.
spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes))
}
}
}
})
@@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i])
}
/**
* A level's stored height moved: plane-bound walls follow the new plane,
* stair rise re-derives, and ceilings/fences re-resolve their clamp — mark
* them all so their systems rebuild. Restacking the level containers alone
* leaves their geometry stale.
*/
export function markLevelHeightDependents(
level: LevelNode,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const childId of level.children) {
const child = nodes[childId]
if (!child) continue
if (
child.type === 'wall' ||
child.type === 'stair' ||
child.type === 'ceiling' ||
child.type === 'fence'
) {
markDirty(child.id)
}
}
}
/**
* A deck slab's walking surface moved: stairs attached to it via
* `deckSlabId` derive their rise from that elevation, so their geometry
* (and rise-derived affordances) must rebuild.
*/
export function markDeckAttachedStairs(
slabId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
for (const node of Object.values(nodes)) {
if (node.type === 'stair' && node.deckSlabId === slabId) {
markDirty(node.id)
}
}
}
/**
* A slab on `slabLevelId` was created/deleted or changed shape/placement:
* the covering bound (slab underside) over the level BELOW moved, so that
* level's plane-bound walls and clamped ceilings must rebuild.
*/
export function markCoveringDependentsBelow(
slabLevelId: string,
nodes: Record<string, AnyNode>,
markDirty: (id: AnyNodeId) => void,
) {
const below = getLevelBelow(slabLevelId, nodes)
if (!below) return
for (const childId of below.children) {
const child = nodes[childId]
if (child?.type === 'wall' || child?.type === 'ceiling') {
markDirty(child.id)
}
}
}
/**
* Mark all floor items and walls that may be affected by a slab change as dirty.
*/
@@ -190,10 +284,11 @@ function markNodesOverlappingSlab(
if (slab.polygon.length < 3) return
const slabLevelId = resolveLevelId(slab, nodes)
// Walls follow the slab's RENDERED footprint (band-adopted edges reach
// the wall's outer face), so the dirty gate must test the same polygon
// `getSlabElevationForWall` will re-evaluate — a stored polygon that
// stops short of the wall body would otherwise never re-elevate it.
// Walls AND floor-placed nodes follow the slab's RENDERED footprint
// (band-adopted edges reach the wall's outer face), so the dirty gate
// must test the same polygon the support queries re-evaluate — a stored
// polygon that stops short of the wall body would otherwise never
// re-elevate nodes sitting over the adopted band.
const levelWalls: WallNode[] = []
const siblingSlabs: SlabNode[] = []
for (const node of Object.values(nodes)) {
@@ -249,7 +344,7 @@ function markNodesOverlappingSlab(
footprint.position ?? position,
footprint.dimensions,
footprint.rotation,
slab.polygon,
renderedPolygon,
0.01,
)
) {
@@ -0,0 +1,217 @@
import { nodeRegistry } from '../../registry'
import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve'
import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation'
import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager'
export type SupportSlabPatch = { supportSlabId: string | undefined }
export type SupportSlabPatchOptions = {
/**
* Pointer-decided support cap (level-local Y) — see
* `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host
* reproduces the CAPPED election: the elected lower slab wins over a
* deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the
* ground is elected while capped-out slabs still overlap the footprint.
*/
maxElevation?: number | null
}
export function resolveSupportSlabPatch(
node: AnyNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) {
return { supportSlabId: undefined }
}
const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null
const parent = parentId ? nodes[parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes })
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
let cappedOut = false
for (const footprint of footprints) {
const position = footprint.position ?? (node as { position?: unknown }).position
if (!Array.isArray(position) || position.length !== 3) continue
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position as [number, number, number],
footprint.dimensions,
footprint.rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
if (maxElevation != null && support.slabId === null && candidates.length > 0) {
cappedOut = true
}
}
if (winner !== null) {
return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined }
}
// Capped election chose the ground while overlapping slabs sit above the
// cap: persist the ground host, or the uncapped per-frame election would
// lift the committed node back onto the deck.
return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined }
}
export function resolveWallSupportSlabPatch(
wall: WallNode,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = wall.parentId ? nodes[wall.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
// Winner under the pointer cap (when given): a deck hanging above the
// aimed-at surface can't capture the elected base, so a wall drawn at the
// floor underneath it persists the floor slab the user actually targeted.
const support = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
null,
options?.maxElevation,
)
const candidateElevations = new Set<number>()
for (const node of Object.values(nodes)) {
if (node.type !== 'slab' || node.parentId !== parent.id) continue
const candidate = node as SlabNode
const preferred = spatialGridManager.getSlabSupportForWall(
parent.id,
wall.start,
wall.end,
wall.curveOffset,
wall.thickness,
candidate.id,
)
if (preferred.electedSlabId === candidate.id) {
candidateElevations.add(candidate.elevation)
}
}
return {
supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined,
}
}
/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */
export type FenceSupportInput = Pick<
FenceNode,
'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId'
>
/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */
const FENCE_CURVE_SUPPORT_SAMPLES = 16
/** Fallback fence thickness (schema default) when the node carries none. */
const DEFAULT_FENCE_THICKNESS = 0.08
/** Minimum band depth so the footprint survives the election's polygon inset. */
const MIN_FENCE_SUPPORT_BAND = 0.05
function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> {
if (fence.path && fence.path.length >= 2) {
return fence.path.map((point) => [point[0], point[1]])
}
const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 }
if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) {
const points: Array<[number, number]> = []
for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) {
const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES)
points.push([frame.point.x, frame.point.y])
}
return points
}
return [
[fence.start[0], fence.start[1]],
[fence.end[0], fence.end[1]],
]
}
/**
* Support-host patch for a fence: elect the slab the fence line stands on
* and persist it as `supportSlabId` (the fence lift resolves absent =
* level floor — see `packages/nodes/src/fence/lift.ts`).
*
* The centerline (chord, sampled arc, or spline path) is turned into thin
* band footprints and run through the same candidate machinery items use.
* `options.maxElevation` is the pointer-decided cap: aiming at the floor
* under a deck elects the floor, aiming at the deck top elects the deck.
*
* Persist rule: the items ambiguity rule (stacked candidates disagree)
* PLUS the elevated-host case — a winner sitting meaningfully above the
* level floor must be persisted even when unambiguous (a balcony deck with
* nothing underneath), or the commit loses the election entirely since
* fences run no per-frame election. A single default ground slab (its top
* within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so
* plain fences keep sitting at the level base. A capped-out election (all
* overlapping slabs above the aimed-at ground) also resolves to the floor
* via the same absent-host default. Pure; exported for tests.
*/
export function resolveFenceSupportSlabPatch(
fence: FenceSupportInput,
nodes: Record<string, AnyNode>,
options?: SupportSlabPatchOptions,
): SupportSlabPatch {
const parent = fence.parentId ? nodes[fence.parentId] : null
if (parent?.type !== 'level') return { supportSlabId: undefined }
const maxElevation = options?.maxElevation
const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND)
const points = fenceCenterlinePoints(fence)
const candidateElevations = new Set<number>()
let winner: { slabId: string; elevation: number } | null = null
for (let i = 1; i < points.length; i++) {
const [ax, az] = points[i - 1]!
const [bx, bz] = points[i]!
const length = Math.hypot(bx - ax, bz - az)
if (length < 1e-6) continue
const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2]
const dimensions: [number, number, number] = [length, 1, band]
// getItemFootprint's rotation convention: local +X maps to
// (cos yRot, sin yRot) in XZ, so the segment angle aligns the band.
const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0]
const candidates = spatialGridManager.getSupportCandidatesForFootprint(
parent.id,
position,
dimensions,
rotation,
)
for (const candidate of candidates) candidateElevations.add(candidate.elevation)
const support = spatialGridManager.getSlabSupportForItem(
parent.id,
position,
dimensions,
rotation,
maxElevation,
)
if (support.slabId && (!winner || support.elevation > winner.elevation)) {
winner = { slabId: support.slabId, elevation: support.elevation }
}
}
if (winner === null) return { supportSlabId: undefined }
const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON
return { supportSlabId: persist ? winner.slabId : undefined }
}
@@ -0,0 +1,628 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, AnyNodeId, SlabNode } from '../../schema'
import { WallNode } from '../../schema'
import useScene, { clearSceneHistory } from '../../store/use-scene'
import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top'
import { getFloorPlacedElevation } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
import { initSpatialGridSync } from './spatial-grid-sync'
import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch'
const LEVEL_ID = 'level_test'
const SQUARE: Array<[number, number]> = [
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
]
function makeDefinition(
kind: AnyNode['type'],
capabilities: AnyNodeDefinition['capabilities'] = {},
): AnyNodeDefinition {
return {
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
capabilities,
}
}
function registerFloorPlacedItem() {
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }),
},
}),
)
}
function makeLevel(children: string[] = []): AnyNode {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children,
level: 0,
} as AnyNode
}
function makeFloorNode(overrides: Partial<AnyNode> = {}): AnyNode {
return {
id: 'item_test',
type: 'item',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
asset: {
id: 'asset_test',
category: 'test',
name: 'Test',
thumbnail: '',
src: 'asset:test',
dimensions: [1, 1, 1],
source: 'library',
},
...overrides,
} as AnyNode
}
function makeSlab(
id: string,
polygon: Array<[number, number]>,
elevation: number,
overrides: Partial<SlabNode> = {},
): SlabNode {
return {
id,
type: 'slab',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
polygon,
holes: [],
holeMetadata: [],
elevation,
autoFromWalls: false,
...overrides,
} as SlabNode
}
function addSlab(slab: SlabNode) {
spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID)
}
function nodesFor(...nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
describe('persisted support hosts (items)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('no-host election over stacked slabs keeps returning the highest elevation', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode()
expect(
getFloorPlacedElevation({
node,
nodes: nodesFor(level, node),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a persisted host wins over the election, whichever slab it names', () => {
registerFloorPlacedItem()
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial<AnyNode>)
expect(
getFloorPlacedElevation({
node: hostedLow,
nodes: nodesFor(level, hostedLow),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.2)
expect(
getFloorPlacedElevation({
node: hostedHigh,
nodes: nodesFor(level, hostedHigh),
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
).toBeCloseTo(0.8)
})
test('a host reshaped away falls back without clearing the field, and resumes on return', () => {
registerFloorPlacedItem()
const host = makeSlab('slab_low', SQUARE, 0.2)
addSlab(host)
addSlab(makeSlab('slab_high', SQUARE, 0.8))
const level = makeLevel()
const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const args = {
node,
nodes: nodesFor(level, node),
position: [0, 0, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
}
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
// Reshape the host away from the item's footprint.
const movedAway: Array<[number, number]> = [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
]
spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8)
expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low')
// Reshape it back — the stale reference resumes hosting.
spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID)
expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2)
})
test('getSlabSupportForItem surfaces the winning slab id', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0.8, slabId: 'slab_high' })
expect(
spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]),
).toEqual({ elevation: 0, slabId: null })
})
test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => {
addSlab(makeSlab('slab_low', SQUARE, 0.2))
addSlab(makeSlab('slab_high', SQUARE, 0.8))
addSlab(
makeSlab(
'slab_far',
[
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
0.5,
),
)
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[0, 0, 0],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([
{ slabId: 'slab_high', elevation: 0.8 },
{ slabId: 'slab_low', elevation: 0.2 },
])
expect(
spatialGridManager.getSupportCandidatesForFootprint(
LEVEL_ID,
[20, 0, 20],
[1, 1, 1],
[0, 0, 0],
),
).toEqual([])
})
test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => {
registerFloorPlacedItem()
const low = makeSlab('slab_low', SQUARE, 0.2)
const high = makeSlab('slab_high', SQUARE, 0.8)
addSlab(low)
addSlab(high)
const level = makeLevel()
const node = makeFloorNode()
const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode)
expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' })
spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID)
expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({
supportSlabId: undefined,
})
})
test('item support follows the RENDERED slab polygon (wall band adoption)', () => {
registerFloorPlacedItem()
// Room slab drawn on the wall centerlines; the rendered polygon
// extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls).
const roomPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }),
WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }),
]
const level = makeLevel(walls.map((wall) => wall.id))
const node = makeFloorNode()
useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) })
// Grounded raised floor (thickness = elevation): band adoption only
// applies to grounded slabs — a floating deck keeps its drawn polygon.
addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 }))
// Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a
// 0.01 overlap inset) but inside the rendered band edge at x = 4.05.
const elevation = spatialGridManager.getSlabElevationForItem(
LEVEL_ID,
[4.3, 0, 1.5],
[0.6, 1, 0.6],
[0, 0, 0],
)
expect(elevation).toBeCloseTo(0.4)
// The manager sees wall changes: removing the walls drops the adopted
// band, so the same footprint stops electing the slab.
for (const wall of walls) {
spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID)
}
useScene.setState({ nodes: nodesFor(makeLevel(), node) })
expect(
spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]),
).toBe(0)
})
})
describe('persisted support hosts (walls, via the manager)', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('preferred slab pins the elected elevation; invalid preference falls back', () => {
const polygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
addSlab(makeSlab('slab_low', polygon, 0.1))
addSlab(makeSlab('slab_high', polygon, 0.6))
const start: [number, number] = [0, 1.5]
const end: [number, number] = [4, 1.5]
const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end)
expect(elected.elevation).toBeCloseTo(0.6)
expect(elected.electedSlabId).toBe('slab_high')
const preferred = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_low',
)
expect(preferred.elevation).toBeCloseTo(0.1)
expect(preferred.electedSlabId).toBe('slab_low')
const fallback = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
start,
end,
0,
0.1,
'slab_missing',
)
expect(fallback.elevation).toBeCloseTo(0.6)
expect(fallback.electedSlabId).toBe('slab_high')
})
test('resolveWallSupportSlabPatch persists the winner over two elevations', () => {
const low = makeSlab(
'slab_low',
[
[-2, -1],
[0, -1],
[0, 1],
[-2, 1],
],
0.2,
)
const high = makeSlab(
'slab_high',
[
[0, -1],
[2, -1],
[2, 1],
[0, 1],
],
0.8,
)
const wall = WallNode.parse({
id: 'wall_test',
parentId: LEVEL_ID,
start: [-2, 0],
end: [2, 0],
thickness: 0.1,
})
const level = makeLevel([low.id, high.id, wall.id])
const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(low)
addSlab(high)
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({
supportSlabId: 'slab_high',
})
})
// Elevated deck stacked over a ground floor slab — the "wall on a deck"
// fixture (both slabs cover the wall band; the deck sits above).
const DECK_ELEVATION = 0.9
const FLOOR_ELEVATION = 0.05
function makeDeckOverFloorFixture() {
const deck = makeSlab(
'slab_deck',
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
DECK_ELEVATION,
)
const ground = makeSlab(
'slab_ground',
[
[-6, -6],
[6, -6],
[6, 6],
[-6, 6],
],
FLOOR_ELEVATION,
)
const wall = WallNode.parse({
id: 'wall_on_deck',
parentId: LEVEL_ID,
start: [0.5, 1.5],
end: [3.5, 1.5],
thickness: 0.1,
})
const level = makeLevel([deck.id, ground.id, wall.id])
const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode)
useScene.setState({ nodes })
addSlab(deck)
addSlab(ground)
return { wall, nodes }
}
test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const support = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
)
expect(support.electedSlabId).toBe('slab_deck')
expect(support.elevation).toBeCloseTo(DECK_ELEVATION)
// Wall-top inversion: no stored height → the top stays at the storey
// plane, so the extruded body is the plane minus the deck base.
const storeyHeight = 2.7
expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight)
expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo(
storeyHeight - DECK_ELEVATION,
)
// Commit persists the deck deterministically (two candidate elevations).
expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' })
})
test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => {
const { wall, nodes } = makeDeckOverFloorFixture()
const capped = spatialGridManager.getSlabSupportForWall(
LEVEL_ID,
wall.start,
wall.end,
0,
wall.thickness,
null,
FLOOR_ELEVATION,
)
expect(capped.electedSlabId).toBe('slab_ground')
expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION)
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({
supportSlabId: 'slab_ground',
})
// Aiming at the deck top keeps the deck.
expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({
supportSlabId: 'slab_deck',
})
})
})
describe('deleteNodesAction strips supportSlabId references', () => {
let stopSync = () => {}
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
registerFloorPlacedItem()
const slabLow = makeSlab('slab_low', SQUARE, 0.2)
const slabHigh = makeSlab('slab_high', SQUARE, 0.8)
const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial<AnyNode>)
const level = makeLevel(['slab_low', 'slab_high', item.id])
useScene.setState({
collections: {},
dirtyNodes: new Set<AnyNodeId>(),
nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item),
readOnly: false,
rootNodeIds: [LEVEL_ID as AnyNodeId],
} as never)
clearSceneHistory()
stopSync = initSpatialGridSync()
})
afterEach(() => {
stopSync()
stopSync = () => {}
})
function itemElevation(): number {
const nodes = useScene.getState().nodes
const item = nodes['item_test' as AnyNodeId]!
return getFloorPlacedElevation({
node: item,
nodes,
position: [0, 0, 0],
rotation: [0, 0, 0],
})
}
test('deleting the host slab clears the reference and re-elects; undo restores both', () => {
expect(itemElevation()).toBeCloseTo(0.2)
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined()
expect(
(afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId,
).toBeUndefined()
expect(itemElevation()).toBeCloseTo(0.8)
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe(
'slab_low',
)
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting a non-host slab leaves the reference alone', () => {
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string })
.supportSlabId,
).toBe('slab_low')
expect(itemElevation()).toBeCloseTo(0.2)
})
test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: {
...useScene.getState().nodes,
stair_test: stair,
[LEVEL_ID]: {
...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!,
children: ['slab_low', 'slab_high', 'item_test', 'stair_test'],
} as AnyNode,
} as never,
})
clearSceneHistory()
useScene.getState().deleteNodes(['slab_low' as AnyNodeId])
const afterDelete = useScene.getState().nodes
expect(
(afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBeUndefined()
useScene.temporal.getState().undo()
const afterUndo = useScene.getState().nodes
expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined()
expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe(
'slab_low',
)
})
test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => {
const stair = {
id: 'stair_test',
type: 'stair',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
deckSlabId: 'slab_low',
} as unknown as AnyNode
useScene.setState({
nodes: { ...useScene.getState().nodes, stair_test: stair } as never,
})
useScene.getState().deleteNodes(['slab_high' as AnyNodeId])
expect(
(useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId,
).toBe('slab_low')
})
})
@@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 })
const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
const bottom = walls[0]!
expect(
@@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => {
).toBeCloseTo(0.1)
})
it('elects a floating deck for a wall standing on its drawn footprint', () => {
// Wall ON a deck: no band adoption needed — the wall body lies inside
// the deck's drawn polygon, which is exactly what a floating slab
// renders.
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
const wallOnDeck = parseWall([1, 2], [3, 2])
expect(
computeWallSlabElevation(
{ start: [1, 2], end: [3, 2], thickness: 0.1 },
[deck],
[wallOnDeck],
),
).toBeCloseTo(1.5)
})
it('a wall in the adoption band beside a floating deck does not stand on it', () => {
// Centerline 6cm below the deck's bottom edge — inside the adoption
// band (half-thickness + 0.06) but the body never reaches the drawn
// footprint. A grounded slab adopts the band and carries the wall; the
// deck keeps its drawn polygon and offers no support.
const bandWall = parseWall([0, -0.06], [4, -0.06])
const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness }
const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 })
expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0)
const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 })
expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1)
})
it('lifts a wall whose body a legacy stored polygon falls short of', () => {
// Legacy hand-adjusted slab: edges 6cm inside the wall centerlines —
// 1cm short of even the inner faces, so the STORED polygon never
@@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => {
parseWall([4, 4], [0, 4]),
parseWall([0, 4], [0, 0]),
]
// Grounded (thickness = elevation): band adoption only applies to
// grounded room floors under the vertical model.
const slab = SlabNode.parse({
polygon: [
[0.06, 0.06],
@@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => {
[0.06, 3.94],
],
elevation: 0.1,
thickness: 0.1,
})
const bottom = walls[0]!
@@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => {
computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []),
).toEqual({
elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
@@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: platform.id,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 2 / 3, elevation: 0.6 },
@@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => {
it('keeps a shared wall on the higher slab that carries the full wall band', () => {
const sharedWall = parseWall([4, 0], [4, 4])
const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 })
// Raised room floor: grounded (thickness = elevation) so the band-carry
// rule applies — a floating deck would keep its drawn polygon instead.
const high = SlabNode.parse({
polygon: [
[4, 0],
@@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => {
[4, 4],
],
elevation: 0.6,
thickness: 0.6,
})
expect(
@@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.6,
baseSegments: [{ start: 0, end: 1, elevation: 0.6 }],
})
@@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => {
parseWall([8, 1.5], [8, 4.5]),
parseWall([8, 4.5], [4, 4.5]),
]
// Grounded raised room floor (see the shared-wall test above).
const high = SlabNode.parse({
polygon: [
[0, 0],
@@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => {
[0, 3],
],
elevation: 0.6,
thickness: 0.6,
})
const low = SlabNode.parse({
polygon: [
@@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => {
),
).toEqual({
elevation: 0.6,
electedSlabId: high.id,
baseElevation: 0.05,
baseSegments: [
{ start: 0, end: 3.05 / 4.5, elevation: 0.6 },
+35 -3
View File
@@ -9,8 +9,10 @@ export type {
CeilingEvent,
ChimneyEvent,
ColumnEvent,
ConstructionDimensionEvent,
DoorEvent,
DormerEvent,
DrawingSheetEvent,
ElevatorEvent,
EventSuffix,
FenceEvent,
@@ -34,6 +36,7 @@ export type {
SpawnEvent,
StairEvent,
StairSegmentEvent,
StructuralGridEvent,
WallEvent,
WindowEvent,
ZoneEvent,
@@ -46,12 +49,16 @@ export {
} from './hooks/scene-registry/scene-registry'
export {
type FloorPlacedElevationArgs,
GROUND_SUPPORT_ID,
getFloorPlacedElevation,
getFloorPlacedFootprints,
getFloorStackedPosition,
} from './hooks/spatial-grid/floor-placed-elevation'
export {
getWallEffectiveHeightForNodes,
type PointedSupportSurface,
pointInPolygon,
SUPPORT_ELEVATION_EPSILON,
spatialGridManager,
type WallSlabSupportSegment,
} from './hooks/spatial-grid/spatial-grid-manager'
@@ -61,6 +68,14 @@ export {
resolveBuildingForLevel,
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export {
type FenceSupportInput,
resolveFenceSupportSlabPatch,
resolveSupportSlabPatch,
resolveWallSupportSlabPatch,
type SupportSlabPatch,
type SupportSlabPatchOptions,
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
export {
@@ -76,6 +91,7 @@ export {
closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -86,6 +102,7 @@ export {
measurementPerimeter,
measurementPrismVolume,
measurementReferenceNodeIds,
remapMeasurementAnchors,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export {
@@ -123,7 +140,6 @@ export {
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
projectAutoSlabsForPlan,
resolveAutoZonePolygon,
resumeSpaceDetection,
type Space,
@@ -146,7 +162,9 @@ export {
} from './lib/zone-quantities'
export {
getCatalogMaterialById,
getDynamicLibraryMaterials,
getLibraryMaterialIdFromRef,
getLibraryMaterialsVersion,
getMaterialPresetByRef,
getMaterialsForCategory,
getSceneMaterialIdFromRef,
@@ -157,12 +175,16 @@ export {
type MaterialCatalogItem,
type MaterialCategory,
type MaterialRef,
type MaterialSource,
type MaterialSurface,
type ParsedMaterialRef,
parseMaterialRef,
registerLibraryMaterials,
SCENE_MATERIAL_REF_PREFIX,
subscribeLibraryMaterials,
toLibraryMaterialRef,
toSceneMaterialRef,
unregisterLibraryMaterials,
} from './material-library'
export type {
FloorPlacedFootprint,
@@ -248,9 +270,7 @@ export {
} from './systems/elevator/elevator-runtime'
export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system'
export {
DEFAULT_ELEVATOR_LEVEL_HEIGHT,
type ElevatorLevelEntry,
getElevatorLevelHeight,
resolveElevatorBuildingLevels,
resolveElevatorLevels,
resolveElevatorServiceLevelIds,
@@ -269,13 +289,20 @@ export {
isSplineFence,
sampleFenceSpline,
} from './systems/fence/fence-spline'
export {
clampSlabElevationForWalls,
getSlabElevationUpperBound,
type SlabElevationClamp,
} from './systems/slab/slab-support'
export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint'
export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview'
export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync'
export { StairOpeningSystem } from './systems/stair/stair-opening-system'
export { resolveStairTotalRise } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallArcData,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
@@ -313,6 +340,11 @@ export {
type WallMoveLinkedWallTargetPlan,
type WallPlanPoint,
} from './systems/wall/wall-move'
export {
MIN_WALL_HEIGHT,
resolveWallEffectiveHeight,
resolveWallTop,
} from './systems/wall/wall-top'
export type { SceneGraph } from './utils/clone-scene-graph'
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types'
@@ -1,9 +1,10 @@
import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement'
import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement'
import {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -12,6 +13,7 @@ import {
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
remapMeasurementAnchors,
} from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
@@ -134,4 +136,33 @@ describe('measurement geometry', () => {
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
})
test('remaps and collects references for arbitrary anchor strings', () => {
const anchors: MeasurementAnchor[] = [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [4, 0, 0],
},
]
expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b'])
const remapped = remapMeasurementAnchors(
anchors,
new Map([
['wall_a', 'wall_a_copy'],
['wall_b', 'wall_b_copy'],
]),
)
const first = remapped[0]!
const last = remapped[2]!
expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy')
expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy')
})
})
+40 -10
View File
@@ -1,4 +1,5 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension'
import type {
MeasurementAnchor,
MeasurementPayload,
@@ -176,11 +177,8 @@ export function remapMeasurementReferences(
measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>,
): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
const remap = (anchor: MeasurementAnchor): MeasurementAnchor =>
remapMeasurementAnchors([anchor], idMap)[0]!
switch (measurement.kind) {
case 'distance':
@@ -205,11 +203,35 @@ export function remapMeasurementReferences(
}
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
export function remapMeasurementAnchors(
anchors: readonly MeasurementAnchor[],
idMap: ReadonlyMap<string, string>,
): MeasurementAnchor[] {
return anchors.map((anchor) => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
})
}
export function remapConstructionDimensionReferences(
dimension: ConstructionDimensionNode,
idMap: ReadonlyMap<string, string>,
): ConstructionDimensionNode {
const controllingDimensionId = dimension.controllingDimensionId
? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ??
dimension.controllingDimensionId)
: null
return {
...dimension,
anchors: remapMeasurementAnchors(dimension.anchors, idMap),
controllingDimensionId,
}
}
export function measurementAnchorReferenceNodeIds(
anchors: readonly MeasurementAnchor[],
): AnyNodeId[] {
const ids = new Set<string>()
for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
@@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An
return [...ids] as AnyNodeId[]
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
return measurementAnchorReferenceNodeIds(anchors)
}
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0]
+94 -5
View File
@@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1)
return WallNode.parse({ start, end, thickness })
}
function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) {
return SlabNode.parse(
elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation },
)
function slabOf(
polygon: Array<[number, number]>,
autoFromWalls = true,
elevation?: number,
thickness?: number,
) {
return SlabNode.parse({
polygon,
autoFromWalls,
...(elevation === undefined ? {} : { elevation }),
// Raised ROOM FLOOR fixtures pass thickness = elevation so the slab
// stays grounded (underside 0) — adoption/seam rules only apply to
// grounded slabs; the schema-default 0.05 thickness would make an
// elevated fixture a floating deck.
...(thickness === undefined ? {} : { thickness }),
})
}
function xs(polygon: Array<[number, number]>) {
@@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.34,
0.34,
)
const low = slabOf(
[
@@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.4,
0.4,
)
const legacyLow = slabOf(
[
@@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => {
})
test('stacked slabs are not mistaken for rooms across a wall', () => {
// The platform is a grounded raised floor (thickness = elevation); the
// floating-deck variant of this shape is covered by the adoption-gate
// tests below.
const floor = slabOf(roomA, false, 0.05)
const platform = slabOf(roomA, false, 0.4)
const platform = slabOf(roomA, false, 0.4, 0.4)
const walls = [
wallOf([0, 0], [4, 0]),
wallOf([4, 0], [4, 3]),
@@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => {
],
false,
0.3,
0.3,
)
const stepLow = slabOf(
[
@@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => {
],
true,
0.4,
0.4,
)
const low = slabOf(
[
@@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => {
})
})
describe('grounded adoption gate', () => {
// Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS
// tile with the walls standing on them. It applies only to grounded
// slabs (underside ≈ 0) and recessed pools; a floating deck keeps its
// drawn polygon exactly.
test('a floating deck near walls keeps its drawn polygon exactly', () => {
// Same footprint as roomA — every edge inside a wall adoption band —
// but floating at 1.5m: no edge may extend to a wall face.
const deck = slabOf(roomA, false, 1.5, 0.05)
const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] })
expect(poly).toEqual(roomA)
})
test('boundary case: underside 0.005 still counts as grounded and adopts', () => {
const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05)
const poly = getRenderableSlabPolygon(nearlyGrounded, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a slab floated just past the epsilon stops adopting', () => {
// Underside 0.02 > 0.01 epsilon — already a deck.
const justFloating = slabOf(roomA, false, 0.07, 0.05)
const poly = getRenderableSlabPolygon(justFloating, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(0)
})
test('a recessed pool keeps band adoption (unchanged)', () => {
// Recessed slabs are sunk into the ground, never floating — their
// negative elevation encodes depth, so the gate must not strip the
// wall-face extension a sunken room floor relies on.
const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true })
const poly = getRenderableSlabPolygon(pool, {
walls: [wallOf([0, 0], [4, 0])],
siblingSlabs: [],
})
expect(Math.min(...zs(poly))).toBeCloseTo(-0.05)
})
test('a grounded floor ignores a floating deck sibling as a seam target', () => {
// Deck butted across the x=4 wall band: were it a room floor, the
// grounded (lower) floor would terminate at its own wall face (3.95).
// As a deck it is no seam partner — the floor adopts the wall's outer
// face (4.05) as if alone, and the deck itself stays as drawn.
const floor = slabOf(roomA, false, 0.05)
const deck = slabOf(roomB, false, 1.5, 0.05)
const walls = [wallOf([4, 0], [4, 3])]
const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] })
const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] })
expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05)
expect(deckPoly).toEqual(roomB)
})
})
describe('snapSlabEdgeToWallBand', () => {
test('an edge inside the band snaps onto the wall centerline', () => {
const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])])
+32 -1
View File
@@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint'
* render offsets.
* - FREE — no neighbour, no wall. Rendered exactly as drawn.
*
* The whole machinery exists to make ROOM FLOORS tile with the walls
* standing on them, so it only applies to GROUNDED slabs (underside on
* the level plane) and recessed pools. A floating deck keeps its drawn
* polygon exactly — it must not grow into a wall it happens to float
* beside — and is symmetrically ignored as a seam target by its
* grounded siblings.
*
* Sub-edges of one edge with different projections are joined by a
* perpendicular STEP connector at the breakpoint. Breakpoints sit on
* candidate span boundaries — wall junctions — so the step's vertical
@@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02
const CURVED_WALL_SAMPLE_SEGMENTS = 32
const SLAB_SEAM_ELEVATION_EPSILON = 1e-4
const DEFAULT_SLAB_ELEVATION = 0.05
const DEFAULT_SLAB_THICKNESS = 0.05
/**
* A non-recessed slab whose underside (`elevation thickness`) rises
* above the level plane by more than this is a floating deck: it keeps
* its drawn polygon (no wall adoption, no seam projection) and grounded
* siblings don't seam toward it.
*/
const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01
/** Prevent near-parallel offset lines from producing unbounded corner spikes. */
const MAX_CORNER_MITER_RATIO = 10
/**
* Floating deck test — see the module header. Recessed pools are never
* floating: their negative elevation encodes depth, not placement.
*/
function isFloatingSlab(slab: SlabNode): boolean {
if (slab.recessed) return false
const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION
const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS
return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON
}
export type SlabPolygonContext = {
/** Walls on the slab's level. */
walls: WallNode[]
@@ -138,7 +164,7 @@ export function getRenderableSlabPolygon(
context: SlabPolygonContext,
): Array<[number, number]> {
const polygon = slabNode.polygon
if (polygon.length < 3) {
if (polygon.length < 3 || isFloatingSlab(slabNode)) {
return polygon.map(([x, z]) => [x, z] as [number, number])
}
@@ -368,6 +394,11 @@ function computeEdgeSubSpans(
const neighborSegments: NeighborSegment[] = []
for (const sibling of context.siblingSlabs) {
// A floating deck keeps its drawn polygon, so it can't be a seam
// partner: projecting toward it would move this slab's edge while the
// deck's stays put (asymmetric seam), and the higher/lower band rules
// only describe room floors meeting under a wall.
if (isFloatingSlab(sibling)) continue
const siblingPolygon = sibling.polygon
if (siblingPolygon.length < 2) continue
const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION
+252 -32
View File
@@ -1,7 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { resolveCeilingHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import {
detectSpacesForLevel,
initSpaceDetectionSync,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
@@ -38,16 +42,35 @@ function slab(elevation: number) {
}
describe('planAutoCeilingsForLevel', () => {
test('creates auto ceilings at the top of the room walls', () => {
test('creates auto ceilings height-less so they follow the level top', () => {
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
walls: squareWalls(),
slabs: [slab(0.05)],
storeyHeight: 2.7,
}).create[0]
expect(created?.height).toBeCloseTo(2.55)
expect(created).toBeDefined()
// Follows-mode: no stored height — the effective height derives from
// the clamp bound at read time via resolveCeilingHeight.
expect('height' in created!).toBe(false)
expect(created?.autoFromWalls).toBe(true)
})
test('updates existing auto ceiling height when the slab elevation changes', () => {
test('never writes a height onto a matched auto ceiling', () => {
const ceiling = CeilingNode.parse({
polygon: square,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
storeyHeight: 3,
})
// Same polygon, follows-mode height — nothing to update.
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(0)
expect(plan.delete).toHaveLength(0)
})
test('a leftover explicit height on a matched auto ceiling is not rewritten', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
@@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => {
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
storeyHeight: 3,
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(ceiling.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.9)
})
test('updates existing auto ceiling height when wall height changes', () => {
const ceiling = CeilingNode.parse({
polygon: square,
height: 2.55,
autoFromWalls: true,
})
const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], {
walls: squareWalls(3),
slabs: [slab(0.05)],
})
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.data.height).toBeCloseTo(3.05)
// The sync no longer re-derives auto heights; a user-set explicit
// height survives (still under the bound, so no clamp either).
expect(plan.update).toHaveLength(0)
})
test('does not replace a manual ceiling with an auto ceiling', () => {
@@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => {
autoFromWalls: false,
})
// Storey plane above the stored 2.5 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], {
walls: squareWalls(),
slabs: [slab(0.4)],
storeyHeight: 2.7,
})
expect(plan.create).toHaveLength(0)
@@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => {
const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data })
expect(demoted.autoFromWalls).toBe(false)
// Storey plane above the stored 2.55 so the stage 3-B manual re-clamp
// stays out of this test's scope (suppression only).
const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], {
walls: squareWalls(),
slabs: [slab(0.05)],
storeyHeight: 2.7,
})
expect(plan.create).toHaveLength(0)
@@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => {
})
})
// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper
// level's plane) covers the queried level below, so the clamp bound is
// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck).
function stackedDeckNodes(): Record<AnyNodeId, AnyNode> {
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const list: AnyNode[] = [
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_1',
level: 1,
height: 2.5,
parentId: 'building_a',
children: ['slab_deck'],
}),
deck,
]
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('stage 3-B ceiling clamp bound', () => {
test('height-less auto ceilings resolve under the covering-slab bound at read time', () => {
const nodes = stackedDeckNodes()
const created = planAutoCeilingsForLevel([roomPolygon()], [], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
}).create[0]
expect(created).toBeDefined()
expect('height' in created!).toBe(false)
// Follows-mode: the effective height is the deck-limited bound.
expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19)
})
test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.polygon).toBeUndefined()
expect(plan.update[0]?.data.height).toBeCloseTo(2.49)
})
test('never raises a manual ceiling sitting below the bound', () => {
const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 })
expect(plan.update).toHaveLength(0)
})
test('skips follows-mode manual ceilings (never converts them to explicit)', () => {
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.update).toHaveLength(0)
})
test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => {
// Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck
// bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01.
const nodes = stackedDeckNodes()
const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false })
const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], {
storeyHeight: 2.5,
ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon),
})
expect(plan.create).toHaveLength(0)
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.id).toBe(manual.id)
expect(plan.update[0]?.data.height).toBeCloseTo(2.19)
})
})
// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped
// scene store (getState/subscribe/temporal) whose write methods mutate the
// nodes record and re-notify, and an editor store carrying `spaces`.
function createSceneStoreStub(initialNodes: Record<string, AnyNode>) {
const listeners = new Set<(state: unknown) => void>()
const state: Record<string, unknown> & { nodes: Record<string, AnyNode> } = {
nodes: initialNodes,
}
const notify = () => {
for (const listener of [...listeners]) listener(state)
}
state.updateNodes = (updates: Array<{ id: string; data: Record<string, unknown> }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { id, data } of updates) {
const existing = next[id]
if (existing) next[id] = { ...existing, ...data } as AnyNode
}
state.nodes = next
notify()
}
state.deleteNodes = (ids: string[]) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const id of ids) delete next[id]
state.nodes = next
notify()
}
state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => {
const next: Record<string, AnyNode> = { ...state.nodes }
for (const { node, parentId } of entries) {
next[node.id] = { ...node, parentId } as AnyNode
const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined
if (parent) {
next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode
}
}
state.nodes = next
notify()
}
return {
getState: () => state,
subscribe: (listener: (state: unknown) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
temporal: { getState: () => ({ pause() {}, resume() {} }) },
setNodes(next: Record<string, AnyNode>) {
state.nodes = next
notify()
},
}
}
function createEditorStoreStub() {
const state = {
spaces: {} as Record<string, unknown>,
setSpaces(next: Record<string, unknown>) {
state.spaces = next
},
}
return { getState: () => state }
}
describe('reactive ceiling re-clamp through the detection sync', () => {
test('a flush deck created on the level above clamps the existing manual ceiling below', () => {
const walls = [
WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }),
WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }),
WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }),
WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }),
]
const manualCeiling = CeilingNode.parse({
id: 'ceiling_main',
parentId: 'level_0',
polygon: square,
height: 2.49,
autoFromWalls: false,
})
const initialNodes = Object.fromEntries(
[
BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }),
LevelNode.parse({
id: 'level_0',
level: 0,
height: 2.5,
parentId: 'building_a',
children: [...walls.map((wall) => wall.id), 'ceiling_main'],
}),
LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }),
...walls,
manualCeiling,
].map((node) => [node.id, node]),
) as Record<string, AnyNode>
const sceneStore = createSceneStoreStub(initialNodes)
const editorStore = createEditorStoreStub()
const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore)
try {
// Scenario gate 11's reactive half: the deck lands on the level
// ABOVE, so only the covering-underside part of level_0's structure
// snapshot changes — the sync must still re-run and clamp down.
const deck = SlabNode.parse({
id: 'slab_deck',
parentId: 'level_1',
polygon: square,
elevation: 0,
thickness: 0.3,
})
const current = sceneStore.getState().nodes
const levelAbove = current.level_1 as AnyNode
sceneStore.setNodes({
...current,
slab_deck: deck,
level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode,
})
const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode
expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01)
} finally {
unsubscribe()
}
})
})
describe('detectSpacesForLevel', () => {
const areaOf = (polygon: Array<{ x: number; y: number }>) => {
let area = 0
+112 -112
View File
@@ -2,12 +2,21 @@ import {
type AnyNodeId,
CeilingNode,
type CeilingNode as CeilingNodeType,
type LevelNode,
SlabNode,
type SlabNode as SlabNodeType,
type WallNode,
ZoneNode,
type ZoneNode as ZoneNodeType,
} from '../schema'
import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height'
import {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
getCeilingClampBound,
getLevelElevations,
getStoredLevelHeight,
} from '../services/storey'
import {
getSceneHistoryPauseDepth,
pauseSceneHistory,
@@ -56,10 +65,6 @@ type DetectedRoom = {
bbox: ReturnType<typeof bboxOf>
}
type DetectedCeilingRoom = DetectedRoom & {
ceilingHeight: number
}
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial<SlabNodeType> }>
@@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = {
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6
const ROOM_CURVE_TOLERANCE = 0.04
const MAX_CURVE_SUBDIVISION_DEPTH = 6
@@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08
const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6
const COVERAGE_SAMPLE_STEPS = 12
// Auto ceilings are created height-less (follows-mode: they track the
// clamp bound live through `resolveCeilingHeight`), so the planner needs
// no wall/slab inputs anymore — only the bound for the explicit-height
// reactive re-clamp below.
export type AutoCeilingPlanningContext = {
walls?: WallNode[]
slabs?: SlabNodeType[]
/** Stored storey height of the level being planned (floor-to-floor). */
storeyHeight?: number
/**
* Stage 3-B clamp-bound resolver for a polygon on the planned level:
* `min(storey plane, lowest covering-slab underside from the level
* above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent
* (pure-planner callers without a nodes record), the bound degrades to
* the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`.
*/
ceilingClampBound?: (polygon: Array<[number, number]>) => number
}
function pointFromTuple(point: [number, number]): Point2D {
@@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) {
return matchingPoints.length >= 2
}
function pointIsOnSlab(point: Point2D, slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
const slabPolygon = slab.polygon.map(pointFromTuple)
if (!pointInPolygon(point, slabPolygon)) return false
for (const hole of slab.holes ?? []) {
if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) {
return false
}
}
return true
}
function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) {
if (slab.polygon.length < 3) return false
if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) {
return true
}
return pointIsOnSlab(polygonCentroid(roomPolygon), slab)
}
function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) {
let maxElevation = 0
for (const slab of slabs) {
if (!slabSupportsRoom(roomPolygon, slab)) continue
maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION)
}
return maxElevation
}
function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) {
let maxHeight = 0
for (const wall of walls) {
if (!wallBoundsRoom(wall, roomPolygon)) continue
const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT
if (Number.isFinite(height)) {
maxHeight = Math.max(maxHeight, height)
}
}
return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT
}
function resolveAutoCeilingHeight(
roomPolygon: Point2D[],
context: AutoCeilingPlanningContext = {},
/**
* The clamp bound for a ceiling polygon under this planning context —
* the context's cross-level resolver when provided, else the plane-only
* `storeyHeight - CEILING_CLAMP_MARGIN` degradation.
*/
function resolveCeilingClampBound(
polygon: Array<[number, number]>,
context: AutoCeilingPlanningContext,
) {
return (
resolveRoomSlabElevation(roomPolygon, context.slabs) +
resolveRoomWallHeight(roomPolygon, context.walls)
)
if (context.ceilingClampBound) return context.ceilingClampBound(polygon)
return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN
}
function getWallDirection(wall: Pick<WallNode, 'start' | 'end'>) {
@@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) {
wall.end[0].toFixed(4),
wall.end[1].toFixed(4),
(wall.thickness ?? 0.2).toFixed(4),
(wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4),
// Plane-bound (no stored height) is a distinct state, not a default
// value: it resolves to the storey plane, so it must not alias an
// explicit height of the same magnitude in the trigger signature.
wall.height == null ? 'plane' : wall.height.toFixed(4),
getClampedWallCurveOffset(wall).toFixed(4),
].join('|')
}
@@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) {
].join('|')
}
// Slabs and ceilings stay out of the trigger signature: including generated
// surfaces caused delete/recreate feedback. Zones are included only so a newly
// traced room footprint can adopt its enclosing walls without waiting for the
// next remodel.
// Slab/ceiling POLYGONS stay out of the trigger signature: including
// generated footprints caused delete/recreate feedback. Zones are included
// only so a newly traced room footprint can adopt its enclosing walls
// without waiting for the next remodel. Slab ELEVATIONS and the level's
// stored storey height ARE included — both feed the explicit-ceiling
// re-clamp bound (the storey plane), and neither is rewritten by
// the sync, so regeneration triggers when they change without feedback.
// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation
// thickness, recessed pools excluded): a deck created, lowered, or
// thickened above must re-run the sync below so ceilings re-clamp under
// it. Same polygon exclusion applies — the level-above's own auto sync
// rewrites its slab footprints, and hashing them here would re-trigger
// this level on every remodel above.
function levelStructureSnapshots(nodes: Record<string, any>) {
const wallsByLevel = new Map<string, WallNode[]>()
const zonesByLevel = new Map<string, ZoneNodeType[]>()
const slabElevationsByLevel = new Map<string, string[]>()
const coveringUndersidesByLevel = new Map<string, string[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
@@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record<string, any>) {
const zones = zonesByLevel.get(levelId) ?? []
zones.push(ZoneNode.parse(node))
zonesByLevel.set(levelId, zones)
} else if ((node as any).type === 'slab') {
const elevations = slabElevationsByLevel.get(levelId) ?? []
elevations.push(
`${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`,
)
slabElevationsByLevel.set(levelId, elevations)
if ((node as any).recessed !== true) {
const undersides = coveringUndersidesByLevel.get(levelId) ?? []
const elevation = ((node as any).elevation as number | undefined) ?? 0.05
const thickness = ((node as any).thickness as number | undefined) ?? 0.05
undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`)
coveringUndersidesByLevel.set(levelId, undersides)
}
}
}
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, any>)
const snapshots = new Map<string, string>()
const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()])
for (const levelId of levelIds) {
const walls = wallsByLevel.get(levelId) ?? []
const zones = zonesByLevel.get(levelId) ?? []
const level = nodes[levelId]
const storeyKey =
level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : ''
const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';')
const aboveId = findLevelAboveId(levelId, levelElevations)
const aboveSlabKey = aboveId
? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';')
: ''
snapshots.set(
levelId,
`${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`,
`${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`,
)
}
@@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel(
return plan
}
export function projectAutoSlabsForPlan(
existingSlabs: SlabNodeType[],
plan: AutoSlabSyncPlan,
): SlabNodeType[] {
const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab]))
for (const id of plan.delete) {
slabsById.delete(id)
}
for (const update of plan.update) {
const slab = slabsById.get(update.id)
if (!slab) continue
slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data }))
}
for (const slab of plan.create) {
slabsById.set(slab.id, slab)
}
return [...slabsById.values()]
}
export function planAutoCeilingsForLevel(
roomPolygons: Point2D[][],
existingCeilings: CeilingNodeType[],
@@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel(
)
const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple))
const detectedAll: DetectedCeilingRoom[] = roomPolygons
const detectedAll: DetectedRoom[] = roomPolygons
.map((poly) => ({
poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map(
pointFromTuple,
@@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel(
centroid: polygonCentroid(room.poly),
area: Math.abs(polygonArea(room.poly)),
bbox: bboxOf(room.poly),
ceilingHeight: resolveAutoCeilingHeight(room.poly, context),
}))
const detected = detectedAll.filter(
@@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel(
const matchedCeilingIds = new Set<string>()
const matchedDetectedIdx = new Set<number>()
const updatesById = new Map<string, { polygon: [number, number][]; height: number }>()
const updatesById = new Map<string, { polygon: [number, number][] }>()
const autoBySignature = new Map<string, Array<(typeof existingAutoMeta)[number]>>()
for (const entry of existingAutoMeta) {
@@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(existing.ceiling.id)
updatesById.set(existing.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
})
@@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel(
matchedCeilingIds.add(bestMatch.entry.ceiling.id)
updatesById.set(bestMatch.entry.ceiling.id, {
polygon: room.poly.map(pointToTuple),
height: room.ceilingHeight,
})
}
@@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel(
}
}
// Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab
// created, moved, or thickened on the level above can leave an EXISTING
// manual explicit-height ceiling poking into its solid. Clamp explicit
// heights down to the bound; never raise them — a user-lowered ceiling
// is intent, only an over-bound one is a conflict. Follows-mode
// ceilings (absent height) derive under the bound by construction and
// are skipped, so the clamp can never convert one to an explicit
// height.
const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => {
if (ceiling.height == null) return []
const bound = resolveCeilingClampBound(ceiling.polygon, context)
if (!Number.isFinite(bound)) return []
return ceiling.height > bound + CEILING_HEIGHT_EPSILON
? [{ id: ceiling.id, data: { height: bound } }]
: []
})
const ceilingsToUpdate = [
// Auto ceilings only track their room's POLYGON here — their height is
// follows-mode (absent) and derives from the level top at read time.
...existingAuto
.filter((ceiling) => updatesById.has(ceiling.id))
.flatMap((ceiling) => {
const update = updatesById.get(ceiling.id)
if (!update) return []
const data: Partial<CeilingNodeType> = {}
if (!sameTuplePolygon(ceiling.polygon, update.polygon)) {
data.polygon = update.polygon
}
if (
Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) >
CEILING_HEIGHT_EPSILON
) {
data.height = update.height
}
return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }]
if (sameTuplePolygon(ceiling.polygon, update.polygon)) return []
return [{ id: ceiling.id, data: { polygon: update.polygon } }]
}),
...ceilingDemotions,
...manualClamps,
]
const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings]
@@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel(
const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling')
plannedCeilingsForNaming.push({ name })
// Height-less on purpose: auto ceilings follow the level top (the
// clamp bound) through `resolveCeilingHeight` instead of baking a
// derived height that would go stale on level-height edits.
ceilingsToCreate.push(
CeilingNode.parse({
name,
polygon: room.poly.map(pointToTuple),
holes: [],
height: room.ceilingHeight,
autoFromWalls: true,
}),
)
@@ -1402,14 +1395,21 @@ function runSpaceDetection(
}
const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab))
const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan)
syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore)
const levelNode = nodes[levelId]
const storeyHeight =
levelNode?.type === 'level'
? getStoredLevelHeight(levelNode as LevelNode)
: DEFAULT_LEVEL_HEIGHT
syncAutoCeilingsForLevel(
levelId,
roomPolygons,
ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)),
sceneStore,
{ walls, slabs: projectedSlabs },
{
storeyHeight,
ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon),
},
)
const zonePlan = planAutoZonesForLevel(
spaces,
@@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record<string, AnyNode> {
function roomNodes() {
const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon })
const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({
id: 'ceiling_room',
parentId: 'level_main',
polygon,
height: 2.5,
})
const walls = polygon.map((start, index) =>
WallNode.parse({
id: `wall_${index}`,
+15 -4
View File
@@ -1,6 +1,11 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import type { AnyNodeId } from '../schema/types'
import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height'
import { getWallPlaneTop } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint'
import { resolveWallEffectiveHeight } from '../systems/wall/wall-top'
import { detectSpacesForLevel, type Space } from './space-detection'
type Point2D = readonly [number, number]
@@ -466,13 +471,19 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport(
zone: ZoneNode,
sceneNodes: Record<string, AnyNode>,
sceneNodes: Readonly<Record<string, AnyNode>>,
): ZoneQuantityReport {
const levelId = zone.parentId
const levelNodes = levelId
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
: []
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab')
const wallEffectiveHeight = (wall: WallNode) => {
const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId)
const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT
return resolveWallEffectiveHeight(wall, planeTop, support.elevation)
}
const edgeLengths = zone.polygon.map((start, index) => {
const end = zone.polygon[(index + 1) % zone.polygon.length]
return end ? pointDistance(start, end) : 0
@@ -488,7 +499,7 @@ export function deriveZoneQuantityReport(
const ceilingCoverage = proveSurfaceCoverage(
zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
(node) => node.height,
(node) => resolveCeilingHeight(node, sceneNodes as Record<AnyNodeId, AnyNode>),
{ singular: 'ceiling', plural: 'Ceilings', datum: 'heights' },
)
@@ -517,7 +528,7 @@ export function deriveZoneQuantityReport(
? {
status: 'available' as const,
value: wallSpans!.reduce(
(sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT),
(sum, span) => sum + span.length * wallEffectiveHeight(span.wall),
0,
),
note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.',
+58 -2
View File
@@ -4,10 +4,14 @@ import {
MaterialTarget as MaterialTargetSchema,
} from './schema/material'
export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace'
export type MaterialCatalogItem = {
id: string
label: string
category: MaterialCategory
/** Origin of the entry. Absent = 'pascal' (all static catalog entries). */
source?: MaterialSource
/**
* Where this finish is appropriate. Absent = universal (e.g. flat colors).
* The paint picker may filter by the slot being painted; v1 shows everything.
@@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [
'roofing',
'ground',
'glass',
'other',
] as const
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
@@ -4149,13 +4154,64 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
},
]
const STATIC_CATALOG_IDS = new Set(MATERIAL_CATALOG.map((item) => item.id))
// Embedder-registered library materials (user/community/workspace). Core stays
// passive: hosts push entries in; nothing here fetches. Static catalog entries
// win on id collision so a registration can never shadow a built-in.
const dynamicLibraryMaterials = new Map<string, MaterialCatalogItem>()
const dynamicLibraryListeners = new Set<() => void>()
let dynamicLibraryVersion = 0
function notifyDynamicLibraryChange(): void {
dynamicLibraryVersion += 1
for (const listener of [...dynamicLibraryListeners]) {
listener()
}
}
export function registerLibraryMaterials(items: MaterialCatalogItem[]): void {
if (items.length === 0) return
for (const item of items) {
dynamicLibraryMaterials.set(item.id, item)
}
notifyDynamicLibraryChange()
}
export function unregisterLibraryMaterials(ids: string[]): void {
let changed = false
for (const id of ids) {
changed = dynamicLibraryMaterials.delete(id) || changed
}
if (changed) notifyDynamicLibraryChange()
}
export function getDynamicLibraryMaterials(): MaterialCatalogItem[] {
return [...dynamicLibraryMaterials.values()]
}
export function subscribeLibraryMaterials(listener: () => void): () => void {
dynamicLibraryListeners.add(listener)
return () => {
dynamicLibraryListeners.delete(listener)
}
}
export function getLibraryMaterialsVersion(): number {
return dynamicLibraryVersion
}
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.category === category)
const items = MATERIAL_CATALOG.filter((item) => item.category === category)
for (const item of dynamicLibraryMaterials.values()) {
if (item.category === category && !STATIC_CATALOG_IDS.has(item.id)) items.push(item)
}
return items
}
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
if (!id) return undefined
return MATERIAL_CATALOG.find((item) => item.id === id)
return MATERIAL_CATALOG.find((item) => item.id === id) ?? dynamicLibraryMaterials.get(id)
}
export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
+3
View File
@@ -65,6 +65,8 @@ export type {
Capabilities,
CapabilityCtx,
CuttableConfig,
DimensionTerminator,
DimensionTextPosition,
DistributionRole,
DragAction,
DuplicableConfig,
@@ -88,6 +90,7 @@ export type {
FloorplanPoint,
FloorplanStyle,
GeometryContext,
GroupMoveSnapArgs,
HostableConfig,
IconRef,
Issue,
+110 -1
View File
@@ -143,12 +143,121 @@ describe('cloneNodesInto', () => {
) {
const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps associative construction-dimension anchors inside the cloned subtree', () => {
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const dimension = makeNode('construction-dimension_1', 'construction-dimension', {
parentId: 'level_1',
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[1, 0, 0],
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:end' },
fallback: [2, 0, 0],
},
],
baseline: { origin: [0, 1], direction: [1, 0] },
chainMode: 'continuous',
})
const result = cloneNodesInto([wall, dimension], {
rootId: 'wall_1' as AnyNodeId,
})
const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension')
expect(clonedDimension?.type).toBe('construction-dimension')
if (clonedDimension?.type === 'construction-dimension') {
const anchor = clonedDimension.anchors[0]
expect(Array.isArray(anchor)).toBe(false)
if (anchor && !Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
const lastAnchor = clonedDimension.anchors[2]
expect(Array.isArray(lastAnchor)).toBe(false)
if (lastAnchor && !Array.isArray(lastAnchor)) {
expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('remaps a construction dimension foundation controller when both are cloned', () => {
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
drawingType: 'foundation-plan',
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
parentId: 'level_1',
anchors: [
[0, 0, 0],
[1, 0, 0],
],
baseline: { origin: [0, 1], direction: [1, 0] },
controllingDimensionId: controller.id,
})
const result = cloneNodesInto([controller, dependent], {
rootId: controller.id as AnyNodeId,
})
const clonedDependent = result.nodes.find(
(node) => node.id === result.idMap.get(dependent.id as AnyNodeId),
)
expect(clonedDependent?.type).toBe('construction-dimension')
if (clonedDependent?.type === 'construction-dimension') {
expect(clonedDependent.controllingDimensionId).toBe(
result.idMap.get(
controller.id as AnyNodeId,
) as typeof clonedDependent.controllingDimensionId,
)
}
})
test('regenerates drawing-sheet identities while preserving external level references', () => {
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
const cloned = nodes[0]
expect(cloned?.type).toBe('drawing-sheet')
if (cloned?.type === 'drawing-sheet') {
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
+12 -2
View File
@@ -1,5 +1,9 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import { generateId } from '../schema/base'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
import type { AnyNode, AnyNodeId } from '../schema/types'
// Generic, opinion-free primitives the host app composes to implement
@@ -141,7 +145,7 @@ export function cloneNodesInto(
const out: AnyNode[] = []
let root: AnyNode | null = null
for (const original of nodes) {
const cloned = JSON.parse(JSON.stringify(original)) as AnyNode
let cloned = JSON.parse(JSON.stringify(original)) as AnyNode
const freshId = idMap.get(original.id)!
;(cloned as { id: AnyNodeId }).id = freshId
// parentId: root's parentId becomes opts.parentId (or preserved
@@ -169,6 +173,12 @@ export function cloneNodesInto(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
if (original.id === opts.rootId) {
if (opts.position) {
+62 -1
View File
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs.
*/
materials?: Record<SceneMaterialId, SceneMaterial>
/** Opaque host/plugin context. Core never interprets extension values. */
extensions?: Readonly<Record<string, unknown>>
/**
* Optional view state — only populated for `def.floorplan` builders. The
* 2D floor-plan layer surfaces selection / hover here so kinds can vary
@@ -212,12 +214,18 @@ export type FloorplanPalette = {
export type FloorplanPoint = readonly [x: number, y: number]
export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot'
export type DimensionTextPosition = 'above' | 'centered'
export type FloorplanStyle = {
stroke?: string
fill?: string
strokeWidth?: number
strokeDasharray?: string
opacity?: number
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
/**
* When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth`
* as a constant screen-pixel width regardless of viewport zoom. Maps
@@ -398,6 +406,8 @@ export type FloorplanGeometry =
* of the floor-plan's scene rotation (default 90°).
*/
upright?: boolean
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Bitmap overlay — captured top-down asset thumbnail, AI-generated
@@ -426,6 +436,8 @@ export type FloorplanGeometry =
children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number }
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
/**
* Hatched fill overlay — same polygon shape as the kind's main fill but
@@ -629,16 +641,64 @@ export type FloorplanGeometry =
kind: 'dimension'
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
/** Outward-pointing unit normal — the dimension line offsets along this. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from the edge to the dimension line. */
offsetDistance: number
/** How far past the offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position relative to the baseline. Defaults above the line. */
textPosition?: DimensionTextPosition
text: string
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
}
| {
kind: 'dimension-string'
segments: readonly {
start: FloorplanPoint
end: FloorplanPoint
/**
* Optional explicit dimension-line endpoints. Use these when the
* measured origins sit at different depths, such as stepped facades or
* an exterior column row. Extension lines still originate at
* `start`/`end`, while the measurement is drawn between these aligned
* baseline points.
*/
dimensionStart?: FloorplanPoint
dimensionEnd?: FloorplanPoint
text: string
}[]
/** Outward-pointing unit normal shared by every segment in the string. */
offsetNormal: FloorplanPoint
/** Distance (plan units) from each measured origin to its dimension line. */
offsetDistance: number
/** How far past each offset point the extension line continues. */
extensionOvershoot: number
/** Optional gap before each extension line starts. Defaults to the project/document profile. */
extensionStartGap?: number
/** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */
terminator?: DimensionTerminator
/** Dimension text position shared by every segment. Defaults above the line. */
textPosition?: DimensionTextPosition
/** Optional override for the line/text colour. Defaults to the palette accent. */
stroke?: string
/** Opaque renderer/plugin metadata. Core never interprets these values. */
metadata?: Readonly<Record<string, unknown>>
}
// ─── FloorplanAffordance ─────────────────────────────────────────────
//
@@ -853,6 +913,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
schemaVersion: number
schema: S
category: NodeCategory
/** Opaque host/plugin contributions. Core stores but never interprets them. */
extensions?: Readonly<Record<string, unknown>>
surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition<S extends ZodObject<any>> = {
portConnectivityFollow?: boolean
defaults: () => Omit<z.infer<S>, 'id' | 'type'>
migrate?: Record<number, (old: unknown) => unknown>
capabilities: Capabilities
relations?: Relations
+65 -3
View File
@@ -51,8 +51,33 @@ export {
ColumnStyle,
ColumnSupportStyle,
} from './nodes/column'
export {
CONSTRUCTION_DRAWING_TYPES,
ConstructionDimensionBaseline,
ConstructionDimensionChainMode,
ConstructionDimensionDatumPolicy,
ConstructionDimensionDrawingOverride,
ConstructionDimensionDrawingPresentation,
ConstructionDimensionImperialPrecision,
ConstructionDimensionMetricNotation,
ConstructionDimensionMode,
ConstructionDimensionNode,
ConstructionDimensionTerminator,
ConstructionDimensionTextPosition,
ConstructionDrawingType,
constructionDimensionRequiredAnchorCount,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './nodes/construction-dimension'
export { CupolaNode } from './nodes/cupola'
export { DoorNode, DoorSegment } from './nodes/door'
export {
DoorNode,
DoorSegment,
OpeningConstructionType,
OpeningDimensionReference,
} from './nodes/door'
export {
DormerNode,
type DormerSurfaceMaterialRole,
@@ -60,6 +85,25 @@ export {
getEffectiveDormerSurfaceMaterial,
} from './nodes/dormer'
export { DownspoutNode } from './nodes/downspout'
export {
DrawingSheetAnnotationProfile,
DrawingSheetDocumentMarker,
DrawingSheetDocumentMarkerKind,
DrawingSheetGeneralNote,
DrawingSheetGeneralNoteSet,
DrawingSheetKeyedNote,
DrawingSheetKeyedNoteDefinition,
DrawingSheetKeyedNoteInstance,
DrawingSheetNode,
DrawingSheetOrientation,
DrawingSheetPaperSize,
DrawingSheetPlacedView,
DrawingSheetRect,
DrawingSheetScale,
DrawingSheetSchedulePlacement,
DrawingSheetTitleBlock,
remapDrawingSheetReferences,
} from './nodes/drawing-sheet'
export { DuctFittingNode } from './nodes/duct-fitting'
export { DuctSegmentNode } from './nodes/duct-segment'
export { DuctTerminalNode } from './nodes/duct-terminal'
@@ -184,7 +228,7 @@ export {
SkylightType,
type SkylightTypePreset,
} from './nodes/skylight'
export { SlabNode } from './nodes/slab'
export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab'
export {
SolarPanelMaterialRole,
SolarPanelNode,
@@ -200,9 +244,13 @@ export {
StairType,
} from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { StructuralGridNode } from './nodes/structural-grid'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent'
export type {
WallAssemblyDatumReference,
WallAssemblyDatumSide,
WallAssemblyLayer,
WallBandSurfaceSlotId,
WallFaceBand,
WallFaceBandConfig,
@@ -215,11 +263,18 @@ export {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyLayers,
getWallAssemblyThickness,
getWallBandSlotId,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
getWallFaceBandForHeight,
getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -230,11 +285,18 @@ export {
WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS,
WallAssemblyLayerRole,
WallDimensionDatum,
WallNode,
WallTreatmentSide,
WallTrimProfile,
} from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window'
export {
WindowConstructionType,
WindowDimensionReference,
WindowNode,
WindowType,
} from './nodes/window'
export { ZoneNode } from './nodes/zone'
export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material'
export type { AnyNodeId, AnyNodeType } from './types'
+5 -2
View File
@@ -1,13 +1,16 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { DrawingSheetNode } from './drawing-sheet'
import { ElevatorNode } from './elevator'
import { LevelNode } from './level'
export const BuildingNode = BaseNode.extend({
id: objectId('building'),
type: nodeType('building'),
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
children: z
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
.default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
}).describe(
@@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({
Building node - used to represent a building
- position: position in site coordinate system
- rotation: rotation in site coordinate system
- children: array of level nodes and building-level systems such as elevators
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
`,
)
@@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
+7 -1
View File
@@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
height: z.number().default(2.5), // Height in meters
// Height in meters. Absent = the ceiling follows the level top: its
// effective height is the same bound its write-clamp uses —
// min(storey plane, lowest covering-slab underside over the polygon)
// CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an
// explicit custom height, still write-clamped under that bound.
height: z.number().optional(),
autoFromWalls: z.boolean().default(false),
}).describe(
dedent`
@@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({
- polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- height: explicit height in meters; absent = follows the level top automatically
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`,
)
+2
View File
@@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({
type: nodeType('column'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
style: ColumnStyle.default('plain'),
crossSection: ColumnCrossSection.default('round'),
height: z.number().positive().default(2.5),
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'bun:test'
import {
ConstructionDimensionNode,
resolveConstructionDimensionDrawingOverride,
resolveConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingPresentation,
setConstructionDimensionDrawingSuppressedSegments,
} from './construction-dimension'
describe('ConstructionDimensionNode', () => {
test('creates valid free-anchor defaults', () => {
const node = ConstructionDimensionNode.parse({})
expect(node.type).toBe('construction-dimension')
expect(node.id).toMatch(/^construction-dimension_/)
expect(node.anchors).toEqual([
[0, 0, 0],
[1, 0, 0],
])
expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] })
expect(node.chainMode).toBe('point-to-point')
expect(node).toMatchObject({
mode: 'linear',
featureCount: 1,
showCenterMark: true,
prefix: '',
suffix: '',
textOverride: null,
datumPolicy: 'centerline',
terminator: 'architectural-tick',
textPosition: 'above',
imperialPrecision: '1/16',
metricNotation: 'meters',
extensionStartGap: 0.075,
extensionOvershoot: 0.12,
drawingType: 'floor-plan',
drawingOverrides: [],
controllingDimensionId: null,
})
})
test('accepts semantic anchors and rejects a collapsed baseline direction', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } },
fallback: [1, 0, 0],
},
[3, 0, 0],
],
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({
baseline: { origin: [0, 0], direction: [0, 0] },
}).success,
).toBe(false)
})
test('accepts continuous strings with three or more anchors', () => {
expect(
ConstructionDimensionNode.safeParse({
anchors: [
[0, 0, 0],
[2, 0, 0],
[5, 0, 0],
],
chainMode: 'continuous',
}).success,
).toBe(true)
expect(
ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' })
.success,
).toBe(false)
})
test('accepts curved and circular notation settings', () => {
expect(
ConstructionDimensionNode.safeParse({
mode: 'diameter',
featureCount: 6,
prefix: 'TYP · ',
suffix: ' CLR',
}).success,
).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true)
expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false)
})
test('accepts dimension-standard overrides and rejects invalid drafting distances', () => {
const node = ConstructionDimensionNode.parse({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(node).toMatchObject({
datumPolicy: 'finish-face',
terminator: 'filled-arrow',
textPosition: 'centered',
imperialPrecision: '1/8',
metricNotation: 'millimeters',
extensionStartGap: 0.025,
extensionOvershoot: 0.08,
})
expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false)
expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false)
})
test('coordinates one associative dimension across persistent drawing types', () => {
const node = ConstructionDimensionNode.parse({
drawingType: 'foundation-plan',
drawingOverrides: [
{ drawingType: 'floor-plan', presentation: 'controlled' },
{ drawingType: 'roof-plan', presentation: 'shown' },
],
controllingDimensionId: 'construction-dimension_foundation',
})
expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled')
expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown')
expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit')
})
test('stores only drawing presentations that differ from the primary defaults', () => {
const node = ConstructionDimensionNode.parse({})
const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown')
expect(shown).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] },
])
expect(
setConstructionDimensionDrawingPresentation(
{ ...node, drawingOverrides: shown },
'roof-plan',
'omit',
),
).toEqual([])
})
test('stores view-specific suppressed segment indexes without changing default presentation', () => {
const node = ConstructionDimensionNode.parse({})
const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments(
node,
'floor-plan',
[3, 1, 1, -1],
)
expect(drawingOverrides).toEqual([
{
drawingType: 'floor-plan',
presentation: 'shown',
suppressedSegmentIndexes: [1, 3],
},
])
expect(
resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan')
?.suppressedSegmentIndexes,
).toEqual([1, 3])
expect(
setConstructionDimensionDrawingSuppressedSegments(
{ ...node, drawingOverrides },
'floor-plan',
[],
),
).toEqual([])
})
})
@@ -0,0 +1,205 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MeasurementAnchor } from './measurement'
const FiniteCoordinate = z.number().finite()
export const ConstructionDimensionBaseline = z
.object({
origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]),
direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]),
})
.superRefine((baseline, ctx) => {
if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['direction'],
message: 'Construction dimension baseline direction must be non-zero',
})
}
})
export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous'])
export const ConstructionDimensionMode = z.enum([
'linear',
'radius',
'diameter',
'center-mark',
'chord',
'arc-length',
'angular',
'coordinate',
])
export const ConstructionDrawingType = z.enum([
'floor-plan',
'foundation-plan',
'reflected-ceiling-plan',
'roof-plan',
'site-plan',
])
export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled'])
export const ConstructionDimensionDrawingOverride = z.object({
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]),
})
export const ConstructionDimensionDatumPolicy = z.enum([
'centerline',
'wall-face',
'structural-face',
'finish-face',
])
export const ConstructionDimensionTerminator = z.enum([
'architectural-tick',
'filled-arrow',
'open-arrow',
'dot',
])
export const ConstructionDimensionTextPosition = z.enum(['above', 'centered'])
export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16'])
export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters'])
export const ConstructionDimensionNode = BaseNode.extend({
id: objectId('construction-dimension'),
type: nodeType('construction-dimension'),
anchors: z
.array(MeasurementAnchor)
.min(2)
.default([
[0, 0, 0],
[1, 0, 0],
]),
baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }),
chainMode: ConstructionDimensionChainMode.default('point-to-point'),
mode: ConstructionDimensionMode.default('linear'),
featureCount: z.number().int().min(1).max(999).default(1),
showCenterMark: z.boolean().default(true),
prefix: z.string().max(40).default(''),
suffix: z.string().max(40).default(''),
textOverride: z.string().trim().min(1).max(120).nullable().default(null),
datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'),
terminator: ConstructionDimensionTerminator.default('architectural-tick'),
textPosition: ConstructionDimensionTextPosition.default('above'),
imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'),
metricNotation: ConstructionDimensionMetricNotation.default('meters'),
extensionStartGap: z.number().finite().min(0).max(1).default(0.075),
extensionOvershoot: z.number().finite().min(0).max(1).default(0.12),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]),
controllingDimensionId: objectId('construction-dimension').nullable().default(null),
}).describe(
dedent`
Construction dimension node - an associative floor-plan construction dimension
- anchors: two or more free or semantic feature anchors that supply the witness origins
- baseline.origin: a point on the independently placed dimension line
- baseline.direction: the fixed plan direction used to project the witness origins
- chainMode: point-to-point for one segment or continuous for adjacent dimension strings
- mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate
- featureCount: repeated-feature multiplier used by diameter/radius and other notation
- showCenterMark: displays the resolved circle/angle center where applicable
- prefix/suffix/textOverride: document notation overrides without changing geometry
- datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides
- drawingType: the primary persistent drawing that owns the dimension
- drawingOverrides: omit, show, or foundation-control presentation per drawing type
- controllingDimensionId: foundation dimension whose associative geometry controls this dimension
`,
)
export type ConstructionDimensionBaseline = z.infer<typeof ConstructionDimensionBaseline>
export type ConstructionDimensionChainMode = z.infer<typeof ConstructionDimensionChainMode>
export type ConstructionDimensionMode = z.infer<typeof ConstructionDimensionMode>
export type ConstructionDrawingType = z.infer<typeof ConstructionDrawingType>
export type ConstructionDimensionDrawingPresentation = z.infer<
typeof ConstructionDimensionDrawingPresentation
>
export type ConstructionDimensionDrawingOverride = z.infer<
typeof ConstructionDimensionDrawingOverride
>
export type ConstructionDimensionDatumPolicy = z.infer<typeof ConstructionDimensionDatumPolicy>
export type ConstructionDimensionTerminator = z.infer<typeof ConstructionDimensionTerminator>
export type ConstructionDimensionTextPosition = z.infer<typeof ConstructionDimensionTextPosition>
export type ConstructionDimensionImperialPrecision = z.infer<
typeof ConstructionDimensionImperialPrecision
>
export type ConstructionDimensionMetricNotation = z.infer<
typeof ConstructionDimensionMetricNotation
>
export type ConstructionDimensionNode = z.infer<typeof ConstructionDimensionNode>
export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
export function resolveConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingPresentation {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit')
}
export function resolveConstructionDimensionDrawingOverride(
node: Pick<ConstructionDimensionNode, 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
): ConstructionDimensionDrawingOverride | null {
let override: ConstructionDimensionDrawingOverride | undefined
for (const entry of node.drawingOverrides) {
if (entry.drawingType === drawingType) override = entry
}
return override ?? null
}
export function setConstructionDimensionDrawingPresentation(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
presentation: ConstructionDimensionDrawingPresentation,
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = {
drawingType,
presentation,
suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [],
}
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function setConstructionDimensionDrawingSuppressedSegments(
node: Pick<ConstructionDimensionNode, 'drawingType' | 'drawingOverrides'>,
drawingType: ConstructionDrawingType,
suppressedSegmentIndexes: readonly number[],
): ConstructionDimensionDrawingOverride[] {
const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit'
const existing = resolveConstructionDimensionDrawingOverride(node, drawingType)
const presentation = existing?.presentation ?? defaultPresentation
const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes)
const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType)
const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed }
return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation)
? withoutDrawing
: [...withoutDrawing, next]
}
export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number {
return mode === 'arc-length' || mode === 'angular' ? 3 : 2
}
function isDefaultConstructionDimensionDrawingOverride(
override: ConstructionDimensionDrawingOverride,
defaultPresentation: ConstructionDimensionDrawingPresentation,
): boolean {
return (
override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0
)
}
function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] {
return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort(
(left, right) => left - right,
)
}
+23
View File
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment>
export const DoorCategory = z.enum(['interior', 'garage'])
export const OpeningConstructionType = z.enum(['framed', 'masonry'])
export const OpeningDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export const DoorType = z.enum([
'hinged',
'double',
@@ -34,6 +41,8 @@ export const DoorType = z.enum([
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
export type DoorCategory = z.infer<typeof DoorCategory>
export type OpeningConstructionType = z.infer<typeof OpeningConstructionType>
export type OpeningDimensionReference = z.infer<typeof OpeningDimensionReference>
export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
@@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({
width: z.number().default(0.9),
height: z.number().default(2.1),
// Construction-document identity. `mark` overrides the deterministic
// level fallback (101, 102, ...). Rough-opening dimensions stay optional
// because they are manufacturer/framing inputs, not safe derivations from
// the nominal modeled size.
mark: z.string().trim().max(16).optional(),
constructionType: OpeningConstructionType.default('framed'),
dimensionReference: OpeningDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Door family
doorCategory: DoorCategory.default('interior'),
doorType: DoorType.default('hinged'),
@@ -0,0 +1,222 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode } from './building'
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
describe('DrawingSheetNode', () => {
test('creates persistent sheet defaults', () => {
const sheet = DrawingSheetNode.parse({})
expect(sheet.type).toBe('drawing-sheet')
expect(sheet.id).toMatch(/^drawing-sheet_/)
expect(sheet).toMatchObject({
sheetNumber: 'A1.0',
sheetTitle: 'Floor Plan',
paperSize: 'arch-b',
orientation: 'landscape',
customPaperWidth: null,
customPaperHeight: null,
annotationProfile: 'architectural-default',
placedViews: [],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [],
keyedNoteInstances: [],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
titleBlock: {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
},
})
})
test('stores placed views, notes, schedules, and title-block metadata', () => {
const sheet = DrawingSheetNode.parse({
sheetNumber: 'A2.1',
sheetTitle: 'Enlarged Plans',
paperSize: 'custom',
customPaperWidth: 24,
customPaperHeight: 36,
placedViews: [
{
id: 'drawing-view_main',
drawingType: 'floor-plan',
drawingNumber: '2',
title: 'Main Floor Plan',
levelId: 'level_main',
scale: '1/4"=1\'-0"',
viewport: { x: 1, y: 1, width: 12, height: 8 },
},
],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
},
],
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
keyedNoteDefinitions: [
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
],
keyedNoteInstances: [
{
id: 'keyed-note-instance_patch-slab-1',
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
},
{
id: 'keyed-note-instance_patch-slab-2',
definitionId: 'keyed-note_patch-slab',
position: [5, 4],
},
],
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
documentMarkers: [
{
id: 'sheet-marker_wall-a',
kind: 'wall-tag',
label: 'W1',
placedViewId: 'drawing-view_main',
position: [2, 3],
},
{
id: 'sheet-marker_revision-a',
kind: 'revision-cloud',
label: '1',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
},
],
schedules: [
{
id: 'sheet-schedule_room',
scheduleType: 'room',
title: 'Room Schedule',
region: { x: 15, y: 1, width: 6, height: 5 },
},
],
titleBlock: {
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
},
})
expect(sheet.placedViews[0]).toMatchObject({
drawingType: 'floor-plan',
levelId: 'level_main',
annotationProfile: 'architectural-default',
showNorthArrow: true,
showGraphicScale: true,
})
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
expect(sheet.generalNoteSets[0]).toMatchObject({
id: 'sheet-note-set_project',
name: 'Project Notes',
notes: [{ text: 'COORDINATE WITH OWNER.' }],
})
expect(sheet.keyedNoteLegend[0]).toEqual({
key: 'A',
text: 'ALIGN WITH EXISTING WALL.',
})
expect(sheet.keyedNoteDefinitions[0]).toEqual({
id: 'keyed-note_patch-slab',
key: 'A',
text: 'PATCH EXISTING SLAB.',
})
expect(sheet.keyedNoteInstances).toHaveLength(2)
expect(sheet.keyedNoteInstances[0]).toMatchObject({
definitionId: 'keyed-note_patch-slab',
placedViewId: 'drawing-view_main',
position: [3.25, 2.5],
})
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
expect(sheet.documentMarkers).toHaveLength(2)
expect(sheet.documentMarkers[0]).toMatchObject({
kind: 'wall-tag',
label: 'W1',
position: [2, 3],
})
expect(sheet.documentMarkers[1]).toMatchObject({
kind: 'revision-cloud',
revisionId: 'A',
points: [
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
})
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
expect(sheet.titleBlock).toMatchObject({
projectName: 'House',
projectNumber: '2401',
clientName: 'Owner',
drawnBy: '',
})
})
test('can live under a building instead of a level', () => {
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
'level_main',
sheet.id,
])
})
test('remaps sheet-local identities and their references together', () => {
const sheet = DrawingSheetNode.parse({
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
generalNoteSetIds: ['sheet-note-set_project'],
generalNoteSets: [
{
id: 'sheet-note-set_project',
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
},
],
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a1',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
},
],
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
schedules: [{ id: 'sheet-schedule_a' }],
})
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
sheet.generalNoteSets[0]?.notes[0]?.id,
)
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
})
})
@@ -0,0 +1,263 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, generateId, nodeType, objectId } from '../base'
import { ConstructionDrawingType } from './construction-dimension'
const PositiveFinite = z.number().finite().positive()
const SheetCoordinate = z.number().finite().min(0)
export const DrawingSheetPaperSize = z.enum([
'letter',
'tabloid',
'arch-a',
'arch-b',
'arch-c',
'a4',
'a3',
'custom',
])
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
export const DrawingSheetScale = z.enum([
'1:20',
'1:25',
'1:50',
'1:75',
'1:100',
'1/8"=1\'-0"',
'1/4"=1\'-0"',
'1/2"=1\'-0"',
'1"=1\'-0"',
])
export const DrawingSheetAnnotationProfile = z.enum([
'architectural-default',
'presentation',
'permit',
])
export const DrawingSheetRect = z.object({
x: SheetCoordinate.default(0),
y: SheetCoordinate.default(0),
width: PositiveFinite.default(1),
height: PositiveFinite.default(1),
})
export const DrawingSheetPlacedView = z.object({
id: objectId('drawing-view'),
drawingType: ConstructionDrawingType.default('floor-plan'),
drawingNumber: z.string().trim().min(1).max(24).default('1'),
title: z.string().trim().min(1).max(80).default('Floor Plan'),
levelId: objectId('level').nullable().default(null),
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
showNorthArrow: z.boolean().default(true),
showGraphicScale: z.boolean().default(true),
})
export const DrawingSheetGeneralNote = z.object({
id: objectId('sheet-note'),
number: z.number().int().positive().default(1),
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
})
export const DrawingSheetGeneralNoteSet = z.object({
id: objectId('sheet-note-set'),
name: z.string().trim().min(1).max(80).default('General Notes'),
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
})
export const DrawingSheetKeyedNote = z.object({
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteDefinition = z.object({
id: objectId('keyed-note'),
key: z.string().trim().min(1).max(16).default('1'),
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
})
export const DrawingSheetKeyedNoteInstance = z.object({
id: objectId('keyed-note-instance'),
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
})
export const DrawingSheetDocumentMarkerKind = z.enum([
'wall-tag',
'glazing-tag',
'assembly-tag',
'section-callout',
'elevation-callout',
'detail-reference',
'delta-marker',
'revision-cloud',
])
export const DrawingSheetDocumentMarker = z.object({
id: objectId('sheet-marker'),
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
label: z.string().trim().min(1).max(32).default('1'),
title: z.string().trim().max(120).default(''),
sheetReference: z.string().trim().max(24).default(''),
drawingReference: z.string().trim().max(24).default(''),
revisionId: z.string().trim().max(16).default(''),
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
points: z
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
.max(64)
.default([]),
})
export const DrawingSheetSchedulePlacement = z.object({
id: objectId('sheet-schedule'),
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
title: z.string().trim().min(1).max(80).default('Room Schedule'),
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
})
export const DrawingSheetTitleBlock = z.object({
projectName: z.string().trim().max(120).default(''),
projectNumber: z.string().trim().max(40).default(''),
clientName: z.string().trim().max(120).default(''),
drawnBy: z.string().trim().max(40).default(''),
checkedBy: z.string().trim().max(40).default(''),
issueDate: z.string().trim().max(40).default(''),
revision: z.string().trim().max(20).default(''),
})
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
projectName: '',
projectNumber: '',
clientName: '',
drawnBy: '',
checkedBy: '',
issueDate: '',
revision: '',
}
export const DrawingSheetNode = BaseNode.extend({
id: objectId('drawing-sheet'),
type: nodeType('drawing-sheet'),
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
paperSize: DrawingSheetPaperSize.default('arch-b'),
orientation: DrawingSheetOrientation.default('landscape'),
customPaperWidth: PositiveFinite.nullable().default(null),
customPaperHeight: PositiveFinite.nullable().default(null),
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
}).describe(
dedent`
Drawing sheet node - persistent construction-document sheet metadata
- sheetNumber/sheetTitle: sheet identity in the drawing set
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
- schedules/titleBlock: sheet-level documentation content and title-block metadata
`,
)
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
/**
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
* External scene references are preserved when they are not present in
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
*/
export function remapDrawingSheetReferences(
sheet: DrawingSheetNode,
sceneIdMap: ReadonlyMap<string, string>,
): DrawingSheetNode {
const placedViewIds = new Map(
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
)
const noteSetIds = new Map(
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
)
const noteIds = new Map(
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
(note) => [note.id, generateId('sheet-note')] as const,
),
)
const keyedDefinitionIds = new Map(
sheet.keyedNoteDefinitions.map(
(definition) => [definition.id, generateId('keyed-note')] as const,
),
)
return {
...sheet,
placedViews: sheet.placedViews.map((view) => ({
...view,
id: placedViewIds.get(view.id)!,
levelId: view.levelId
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
: null,
})),
generalNoteSetIds: sheet.generalNoteSetIds.map(
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
),
generalNoteSets: sheet.generalNoteSets.map((set) => ({
...set,
id: noteSetIds.get(set.id)!,
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
})),
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
...definition,
id: keyedDefinitionIds.get(definition.id)!,
})),
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
...instance,
id: generateId('keyed-note-instance'),
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
instance.definitionId) as typeof instance.definitionId,
placedViewId: instance.placedViewId
? ((placedViewIds.get(instance.placedViewId) ??
instance.placedViewId) as typeof instance.placedViewId)
: null,
})),
documentMarkers: sheet.documentMarkers.map((marker) => ({
...marker,
id: generateId('sheet-marker'),
placedViewId: marker.placedViewId
? ((placedViewIds.get(marker.placedViewId) ??
marker.placedViewId) as typeof marker.placedViewId)
: null,
})),
schedules: sheet.schedules.map((schedule) => ({
...schedule,
id: generateId('sheet-schedule'),
})),
}
}
@@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians.
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'),
// Which surface the terminal mounts on. Drives face orientation and
// which way the collar (and its port) points.
+4
View File
@@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({
tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
// Persisted slab-support host — the fence sits on that slab's walking
// surface (see ItemNode.supportSlabId for the host rules).
supportSlabId: z.string().optional(),
curveOffset: z.number().optional(),
baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2),
@@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({
- path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them
- tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent
- height/thickness: overall fence dimensions in meters
- supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation)
- curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set)
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
@@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Yaw in radians.
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'),
// Cabinet dimensions in meters. Defaults match a typical upflow
// furnace cabinet (~22" × 28" footprint, ~43" tall).
+14
View File
@@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Persisted floor-support host (canonical doc — the same field on other
// floor-placed kinds and walls follows these rules). Written at
// placement/commit ONLY when overlapping slabs disagree on elevation
// (ambiguity); absent/null means "elect the support fresh on every
// read", which is the historical behavior. Read paths PREFER this slab
// while it still exists and still overlaps the node's footprint, and
// silently fall back to election otherwise. Deleting the host slab
// strips the field (deleteNodesAction); a host merely reshaped away is
// deliberately kept so hosting resumes if the slab's polygon returns.
// The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the
// level base — written when a pointer-capped commit elected the ground
// while a slab (e.g. an elevated deck) still overlapped the footprint.
supportSlabId: z.string().optional(),
// Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -56,4 +56,9 @@ describe('LevelNode', () => {
expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child'])
})
test('does not materialize height on parse — absence marks unmigrated legacy data', () => {
expect('height' in LevelNode.parse({})).toBe(false)
expect(LevelNode.parse({ height: 3 }).height).toBe(3)
})
})
+11
View File
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import type { CeilingNode } from './ceiling'
import type { ColumnNode } from './column'
import type { ConstructionDimensionNode } from './construction-dimension'
import type { DuctFittingNode } from './duct-fitting'
import type { DuctSegmentNode } from './duct-segment'
import type { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import type { ShelfNode } from './shelf'
import type { SlabNode } from './slab'
import type { SpawnNode } from './spawn'
import type { StairNode } from './stair'
import type { StructuralGridNode } from './structural-grid'
import type { WallNode } from './wall'
import type { ZoneNode } from './zone'
@@ -29,6 +31,8 @@ type CoreLevelChildId =
| WallNode['id']
| FenceNode['id']
| ColumnNode['id']
| ConstructionDimensionNode['id']
| StructuralGridNode['id']
| ItemNode['id']
| ZoneNode['id']
| SlabNode['id']
@@ -60,11 +64,18 @@ export const LevelNode = BaseNode.extend({
children: z.array(LevelChildId).default([]),
// Specific props
level: z.number().default(0),
/**
* Stored storey height in meters (floor-to-floor). No zod default on
* purpose: absence marks unmigrated legacy data and gates the load-time
* migration; a schema default would materialize silently through .parse().
*/
height: z.number().optional(),
}).describe(
dedent`
Level node - used to represent a level in the building
- children: array of architectural, equipment, and MEP distribution nodes
- level: level number
- height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data
`,
)
+2
View File
@@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({
children: z.array(ItemNode.shape.id).default([]),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
// Dimensions (meters). Schema-level defaults intentionally reproduce
// the v1 wall-shelf so existing v1 scenes that omit the v2-introduced
+11 -2
View File
@@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's
// 0.01 underside offset. Applies to edits only; migration writes legacy
// intervals verbatim (including degenerate zero-thickness slabs).
export const MIN_SLAB_THICKNESS = 0.02
export const SlabNode = BaseNode.extend({
id: objectId('slab'),
type: nodeType('slab'),
@@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({
polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
elevation: z.number().default(0.05), // Elevation in meters
elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane
thickness: z.number().default(0.05), // Grows downward from the surface
recessed: z.boolean().default(false),
autoFromWalls: z.boolean().default(false),
}).describe(
dedent`
@@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({
- polygon: array of [x, z] points defining the slab boundary
- holes: array of [x, z] polygons representing cutouts in the slab
- holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts
- elevation: elevation in meters
- elevation: the walking surface (slab top), in meters above the level plane
- thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation]
- recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
`,
)
+2
View File
@@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({
type: nodeType('spawn'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
})
export type SpawnNode = z.infer<typeof SpawnNode>
+8 -1
View File
@@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
stairType: StairType.default('straight'),
fromLevelId: z.string().nullable().default(null),
toLevelId: z.string().nullable().default(null),
// Destination deck (a slab id). When set, the stair's rise follows that
// slab's elevation live. An explicit `totalRise` still wins when BOTH are
// set (edge case — the panel clears the custom rise when attaching).
deckSlabId: z.string().optional(),
slabOpeningMode: StairSlabOpeningMode.default('none'),
openingOffset: z.number().default(0),
width: z.number().default(1.0),
totalRise: z.number().default(2.5),
totalRise: z.number().optional(),
stepCount: z.number().default(10),
thickness: z.number().default(0.25),
fillToFloor: z.boolean().default(true),
@@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({
- rotation: rotation around Y axis
- stairType: straight (segment-based), curved (arc-based), or spiral
- fromLevelId / toLevelId: source and destination levels used for auto slab cutouts
- deckSlabId: destination deck (slab) — the rise derives from its elevation while set
- slabOpeningMode: whether a destination-level slab opening is generated for this stair
- openingOffset: extra opening expansion applied after the cutout polygon is computed
- width: stair width
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { LevelNode } from './level'
import { StructuralGridNode } from './structural-grid'
describe('StructuralGridNode', () => {
test('fills stable construction-document defaults', () => {
const grid = StructuralGridNode.parse({})
expect(grid.id).toStartWith('structural-grid_')
expect(grid).toMatchObject({
type: 'structural-grid',
start: [0, 0],
end: [0, 5],
label: '1',
showStartBubble: true,
showEndBubble: true,
})
})
test('is accepted as a level child', () => {
expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([
'structural-grid_axis-1',
])
})
test('rejects empty labels and zero-length concerns stay in authoring', () => {
expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow()
expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({
start: [1, 1],
end: [1, 1],
})
})
})
@@ -0,0 +1,22 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
export const StructuralGridNode = BaseNode.extend({
id: objectId('structural-grid'),
type: nodeType('structural-grid'),
start: z.tuple([z.number(), z.number()]).default([0, 0]),
end: z.tuple([z.number(), z.number()]).default([0, 5]),
label: z.string().trim().min(1).max(12).default('1'),
showStartBubble: z.boolean().default(true),
showEndBubble: z.boolean().default(true),
}).describe(
dedent`
Structural grid node - a persistent floor-plan datum axis with identification bubbles
- start/end: level-local plan coordinates defining the grid axis extent
- label: axis identifier, commonly numeric in one direction and alphabetic in the other
- showStartBubble/showEndBubble: independently control the two endpoint identifiers
`,
)
export type StructuralGridNode = z.infer<typeof StructuralGridNode>
+240 -24
View File
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test'
import {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getWallAssemblyDatumReferenceId,
getWallAssemblyFaceOffsets,
getWallAssemblyThickness,
getWallDatumEligibleLayers,
getWallFaceBandConfig,
resolveWallAssemblyDatumReference,
resolveWallAssemblyDatumReferences,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
@@ -13,7 +19,8 @@ import {
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig,
type WallNode,
WallNode,
type WallNode as WallNodeType,
WallTrimConfig,
} from './wall'
@@ -41,16 +48,19 @@ describe('wall face bands', () => {
})
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
getWallFaceBandConfig(
{
height: 2.5,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
},
},
}),
2.5,
),
).toMatchObject({
count: 3,
lowerTop: 0.84,
@@ -60,16 +70,19 @@ describe('wall face bands', () => {
test('four bands adds an upper split below the final top band', () => {
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.6,
upperHeight: 0.7,
getWallFaceBandConfig(
{
height: 2.5,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.6,
upperHeight: 0.7,
},
},
}),
2.5,
),
).toMatchObject({
count: 4,
lowerTop: 0.5,
@@ -93,7 +106,7 @@ describe('wall face bands', () => {
lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({
enabled: true,
@@ -129,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -153,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
} as Pick<WallNodeType, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
@@ -181,7 +194,7 @@ describe('wall face bands', () => {
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
3,
)
@@ -213,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
} as Pick<WallNodeType, 'faceBands' | 'slots'>,
4,
)
@@ -254,3 +267,206 @@ describe('wall trim profiles', () => {
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
})
})
describe('wall assembly layers', () => {
test('defaults to legacy thickness when no assembly layers are modeled', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(wall.assemblyLayers).toEqual([])
expect(getWallAssemblyThickness(wall)).toBe(0.14)
})
test('stores role, side, thickness, material reference, and datum eligibility', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face', 'finish-face'],
},
],
})
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
'interior-gwb',
'brick-veneer',
])
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
{ id: 'stud-core', role: 'structure', side: 'core' },
])
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
interior: -0.061,
exterior: 0.135,
})
})
test('resolves stable datum references for legacy single-thickness walls', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
thickness: 0.14,
})
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
{
id: 'wall:structural-face:interior',
datum: 'structural-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:structural-face:exterior',
datum: 'structural-face',
side: 'exterior',
offset: 0.07,
},
{
id: 'wall:finish-face:interior',
datum: 'finish-face',
side: 'interior',
offset: -0.07,
},
{
id: 'wall:finish-face:exterior',
datum: 'finish-face',
side: 'exterior',
offset: 0.07,
},
])
})
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
const wall = WallNode.parse({
start: [0, 0],
end: [4, 0],
assemblyLayers: [
{
id: 'stud-core',
role: 'structure',
side: 'core',
thickness: 0.09,
materialRef: 'library:wood-framing',
datumEligible: ['centerline', 'structural-face'],
},
{
id: 'interior-gwb',
role: 'interior-finish',
side: 'interior',
thickness: 0.016,
materialRef: 'library:gypsum-board',
datumEligible: ['finish-face'],
},
{
id: 'exterior-sheathing',
role: 'exterior-sheathing',
side: 'exterior',
thickness: 0.012,
materialRef: 'library:sheathing',
datumEligible: ['finish-face'],
},
{
id: 'brick-veneer',
role: 'masonry-veneer',
side: 'exterior',
thickness: 0.09,
materialRef: 'library:brick',
datumEligible: ['veneer-face'],
},
],
})
const references = resolveWallAssemblyDatumReferences(wall)
expect(references).toContainEqual({
id: 'wall:centerline:center',
datum: 'centerline',
side: 'center',
offset: 0,
})
expect(references).toContainEqual({
id: 'wall:structural-face:interior:stud-core',
datum: 'structural-face',
side: 'interior',
layerId: 'stud-core',
offset: -0.045,
})
expect(references).toContainEqual({
id: 'wall:structural-face:exterior:stud-core',
datum: 'structural-face',
side: 'exterior',
layerId: 'stud-core',
offset: 0.045,
})
expect(references).toContainEqual({
id: 'wall:finish-face:interior:interior-gwb',
datum: 'finish-face',
side: 'interior',
layerId: 'interior-gwb',
offset: -0.061,
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
),
).toMatchObject({
datum: 'finish-face',
side: 'exterior',
layerId: 'exterior-sheathing',
})
expect(
references.find(
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
)?.offset,
).toBeCloseTo(0.057)
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
})
expect(
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
?.offset,
).toBeCloseTo(0.147)
expect(
resolveWallAssemblyDatumReference(
wall,
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
),
).toMatchObject({
datum: 'veneer-face',
side: 'exterior',
layerId: 'brick-veneer',
offset: 0.147,
})
})
})
+269 -3
View File
@@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallAssemblyLayerRole = z.enum([
'structure',
'interior-finish',
'exterior-sheathing',
'exterior-finish',
'masonry-veneer',
'air-space',
'concrete-block',
'structural-masonry',
'solid-concrete',
'furring',
])
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
export const WallDimensionDatum = z.enum([
'centerline',
'structural-face',
'finish-face',
'veneer-face',
])
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
export const WallAssemblyLayer = z.object({
id: z.string().trim().min(1).max(80).default('structure'),
role: WallAssemblyLayerRole.default('structure'),
side: z.enum(['core', 'interior', 'exterior']).default('core'),
thickness: z.number().finite().positive().default(0.1),
materialRef: z.string().trim().max(120).default(''),
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
})
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
export type WallAssemblyDatumReference = {
id: string
datum: WallDimensionDatum
side: WallAssemblyDatumSide
layerId?: string
offset: number
}
export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
@@ -149,8 +191,11 @@ export const WallNode = BaseNode.extend({
// in a follow-up once migrated scenes are the norm.
slots: z.record(z.string(), z.string()).optional(),
thickness: z.number().optional(),
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
height: z.number().optional(),
curveOffset: z.number().optional(),
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
faceBands: WallFaceBandConfig.optional(),
skirting: WallTrimConfig.optional(),
crown: WallTrimConfig.optional(),
@@ -165,6 +210,7 @@ export const WallNode = BaseNode.extend({
dedent`
Wall node - used to represent a wall in the building
- thickness: thickness in meters
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
- height: height in meters
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
- start: start point of the wall in level coordinate system
@@ -188,6 +234,222 @@ export type WallBandSurfaceSlotId =
| 'upperExterior'
| 'topExterior'
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
return wall.assemblyLayers ?? []
}
export function getWallAssemblyThickness(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): number {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return wall.thickness ?? 0.1
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
}
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
interior: number
exterior: number
} {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return { interior: -halfThickness, exterior: halfThickness }
}
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const interiorFinishThickness = layers
.filter((layer) => layer.side === 'interior')
.reduce((sum, layer) => sum + layer.thickness, 0)
const exteriorFinishThickness = layers
.filter((layer) => layer.side === 'exterior')
.reduce((sum, layer) => sum + layer.thickness, 0)
return {
interior: -coreThickness / 2 - interiorFinishThickness,
exterior: coreThickness / 2 + exteriorFinishThickness,
}
}
export function getWallDatumEligibleLayers(
wall: Pick<WallNode, 'assemblyLayers'>,
datum: WallDimensionDatum,
): WallAssemblyLayer[] {
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
}
export function getWallAssemblyDatumReferenceId(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
layerId?: string,
): string {
return ['wall', datum, side, layerId].filter(Boolean).join(':')
}
type WallAssemblyLayerSpan = {
layer: WallAssemblyLayer
interiorOffset: number
exteriorOffset: number
}
function getWallAssemblyLayerSpans(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyLayerSpan[] {
const layers = wall.assemblyLayers ?? []
if (layers.length === 0) return []
const coreLayers = layers.filter((layer) => layer.side === 'core')
const coreThickness =
coreLayers.length > 0
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
: (wall.thickness ?? 0.1)
const coreInteriorFace = -coreThickness / 2
const coreExteriorFace = coreThickness / 2
const spans: WallAssemblyLayerSpan[] = []
let coreOffset = coreInteriorFace
for (const layer of coreLayers) {
const interiorOffset = coreOffset
const exteriorOffset = coreOffset + layer.thickness
spans.push({ layer, interiorOffset, exteriorOffset })
coreOffset = exteriorOffset
}
let interiorOffset = coreInteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
const exteriorOffset = interiorOffset
const nextInteriorOffset = exteriorOffset - layer.thickness
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
interiorOffset = nextInteriorOffset
}
let exteriorOffset = coreExteriorFace
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
const interiorFaceOffset = exteriorOffset
const nextExteriorOffset = interiorFaceOffset + layer.thickness
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
exteriorOffset = nextExteriorOffset
}
return spans
}
function createWallAssemblyDatumReference(
datum: WallDimensionDatum,
side: WallAssemblyDatumSide,
offset: number,
layerId?: string,
): WallAssemblyDatumReference {
return {
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
datum,
side,
...(layerId ? { layerId } : {}),
offset,
}
}
export function resolveWallAssemblyDatumReferences(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
): WallAssemblyDatumReference[] {
const layers = wall.assemblyLayers ?? []
const references: WallAssemblyDatumReference[] = [
createWallAssemblyDatumReference('centerline', 'center', 0),
]
if (layers.length === 0) {
const halfThickness = (wall.thickness ?? 0.1) / 2
return [
...references,
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
]
}
const spans = getWallAssemblyLayerSpans(wall)
for (const span of spans) {
if (span.layer.datumEligible.includes('structural-face')) {
if (span.layer.side === 'core') {
references.push(
createWallAssemblyDatumReference(
'structural-face',
'interior',
span.interiorOffset,
span.layer.id,
),
createWallAssemblyDatumReference(
'structural-face',
'exterior',
span.exteriorOffset,
span.layer.id,
),
)
} else {
const side = span.layer.side
references.push(
createWallAssemblyDatumReference(
'structural-face',
side,
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
span.layer.id,
),
)
}
}
if (span.layer.datumEligible.includes('finish-face')) {
const side = span.layer.side === 'core' ? 'center' : span.layer.side
const offset =
span.layer.side === 'interior'
? span.interiorOffset
: span.layer.side === 'exterior'
? span.exteriorOffset
: (span.interiorOffset + span.exteriorOffset) / 2
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
}
if (span.layer.datumEligible.includes('veneer-face')) {
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
}
}
if (!references.some((reference) => reference.datum === 'structural-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
)
}
if (!references.some((reference) => reference.datum === 'finish-face')) {
const halfThickness = getWallAssemblyThickness(wall) / 2
references.push(
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
)
}
return references
}
export function resolveWallAssemblyDatumReference(
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
referenceId: string,
): WallAssemblyDatumReference | null {
return (
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
null
)
}
// Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
// slot declaration (nodes) and the material resolver (viewer) share one value.
@@ -198,8 +460,11 @@ export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
}
export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands'>) {
const wallHeight = wall.height ?? 2.5
export function getWallFaceBandConfig(
wall: Pick<WallNode, 'height' | 'faceBands'>,
effectiveWallHeight: number,
) {
const wallHeight = Math.max(0, effectiveWallHeight)
const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) }
const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1
const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0
@@ -223,8 +488,9 @@ export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands
export function getWallFaceBandForHeight(
wall: Pick<WallNode, 'height' | 'faceBands'>,
y: number,
effectiveWallHeight: number,
): WallFaceBand {
const bands = getWallFaceBandConfig(wall)
const bands = getWallFaceBandConfig(wall, effectiveWallHeight)
if (!bands.enabled) return 'upper'
if (y < bands.lowerTop) return 'lower'
if (y < bands.middleTop) return 'middle'
+22
View File
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
])
export type WindowType = z.infer<typeof WindowType>
export const WindowConstructionType = z.enum(['framed', 'masonry'])
export const WindowDimensionReference = z.enum([
'nominal',
'rough-opening',
'masonry-opening',
'finish-opening',
])
export type WindowConstructionType = z.infer<typeof WindowConstructionType>
export type WindowDimensionReference = z.infer<typeof WindowDimensionReference>
export const WindowNode = BaseNode.extend({
id: objectId('window'),
type: nodeType('window'),
@@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5),
height: z.number().default(1.5),
// Construction-document identity and optional manufacturer rough opening.
// Legacy scenes omit these fields and continue to parse unchanged.
mark: z.string().trim().max(16).optional(),
constructionType: WindowConstructionType.default('framed'),
dimensionReference: WindowDimensionReference.default('nominal'),
roughOpeningWidth: z.number().positive().optional(),
roughOpeningHeight: z.number().positive().optional(),
masonryOpeningWidth: z.number().positive().optional(),
masonryOpeningHeight: z.number().positive().optional(),
finishOpeningWidth: z.number().positive().optional(),
finishOpeningHeight: z.number().positive().optional(),
// Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'),
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test'
import { ZoneNode } from './zone'
describe('ZoneNode architectural room data', () => {
test('keeps legacy zones generic while supplying room-safe defaults', () => {
const zone = ZoneNode.parse({
id: 'zone_legacy',
name: 'Landscape area',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
})
expect(zone).toMatchObject({
spaceRole: 'generic',
roomNumber: '',
enclosureStatus: 'auto',
floorFinish: '',
wallFinish: '',
ceilingFinish: '',
ceilingHeight: 2.7,
occupancy: '',
clearDimensionPolicy: 'none',
})
})
test('persists a complete architectural room profile', () => {
const room = ZoneNode.parse({
id: 'zone_office',
name: 'Office',
polygon: [
[0, 0],
[4, 0],
[4, 3],
],
spaceRole: 'room',
roomNumber: '101',
enclosureStatus: 'enclosed',
floorFinish: 'Timber',
wallFinish: 'Paint',
ceilingFinish: 'ACT',
ceilingHeight: 3,
occupancy: 'Business',
clearDimensionPolicy: 'inside-faces',
})
expect(room.spaceRole).toBe('room')
expect(room.roomNumber).toBe('101')
expect(room.clearDimensionPolicy).toBe('inside-faces')
})
})
+15
View File
@@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({
// stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]),
// Generic zones remain available for sites and analysis. Architectural
// room documentation is opt-in so legacy zone behavior is unchanged.
spaceRole: z.enum(['generic', 'room']).default('generic'),
roomNumber: z.string().trim().max(32).default(''),
enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'),
floorFinish: z.string().trim().max(120).default(''),
wallFinish: z.string().trim().max(120).default(''),
ceilingFinish: z.string().trim().max(120).default(''),
ceilingHeight: z.number().min(0.1).default(2.7),
occupancy: z.string().trim().max(80).default(''),
clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'),
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
@@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({
- polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure
- spaceRole: generic site/analysis zone or architectural room
- roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata
- enclosureStatus: auto-detected, explicitly enclosed, or open
- clearDimensionPolicy: optional room clear-dimension datum preference
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
+6
View File
@@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column'
import { ConstructionDimensionNode } from './nodes/construction-dimension'
import { CupolaNode } from './nodes/cupola'
import { DoorNode } from './nodes/door'
import { DormerNode } from './nodes/dormer'
import { DownspoutNode } from './nodes/downspout'
import { DrawingSheetNode } from './nodes/drawing-sheet'
import { DuctFittingNode } from './nodes/duct-fitting'
import { DuctSegmentNode } from './nodes/duct-segment'
import { DuctTerminalNode } from './nodes/duct-terminal'
@@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
import { SpawnNode } from './nodes/spawn'
import { StairNode } from './nodes/stair'
import { StairSegmentNode } from './nodes/stair-segment'
import { StructuralGridNode } from './nodes/structural-grid'
import { TurbineVentNode } from './nodes/turbine-vent'
import { WallNode } from './nodes/wall'
import { WindowNode } from './nodes/window'
@@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ElevatorNode,
LevelNode,
ColumnNode,
ConstructionDimensionNode,
StructuralGridNode,
WallNode,
FenceNode,
CabinetNode,
@@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [
SkylightNode,
DormerNode,
DownspoutNode,
DrawingSheetNode,
DuctSegmentNode,
DuctFittingNode,
DuctTerminalNode,
@@ -0,0 +1,89 @@
// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4).
// Auto slab polygon derives from wall centerlines, putting wall samples exactly on
// the polygon boundary — the shape that exposed ray-cast side dependence.
export const wallPlaneTopBoundaryRepro = {
building_rr4rx7weux2fpdbh: {
id: 'building_rr4rx7weux2fpdbh',
type: 'building',
object: 'node',
visible: true,
children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'],
metadata: {},
parentId: null,
position: [0, 0, 0],
rotation: [0, 0, 0],
},
level_pomuk0sbwec15mf3: {
id: 'level_pomuk0sbwec15mf3',
type: 'level',
level: 0,
height: 2.7,
object: 'node',
visible: true,
children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'],
metadata: {},
parentId: null,
},
level_5msog1z8hy2lyvxr: {
id: 'level_5msog1z8hy2lyvxr',
type: 'level',
level: 1,
height: 2.5,
object: 'node',
visible: true,
children: ['slab_j3i4ebjg4nsu8xk7'],
metadata: {},
parentId: 'building_rr4rx7weux2fpdbh',
},
wall_39bnnq29h824ryy0: {
id: 'wall_39bnnq29h824ryy0',
end: [-1, 4],
name: 'Wall 1',
type: 'wall',
start: [3, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
wall_on4rj410n69n3rzf: {
id: 'wall_on4rj410n69n3rzf',
end: [-1, -1],
name: 'Wall 2',
type: 'wall',
start: [-1, 4],
object: 'node',
visible: true,
backSide: 'exterior',
children: [],
metadata: {},
parentId: 'level_pomuk0sbwec15mf3',
frontSide: 'interior',
},
slab_j3i4ebjg4nsu8xk7: {
id: 'slab_j3i4ebjg4nsu8xk7',
name: 'Room 1 Slab',
type: 'slab',
holes: [],
object: 'node',
polygon: [
[-1, 4],
[-1, -1],
[4, -1],
[4, 1],
[3, 1],
[3, 4],
],
visible: true,
metadata: {},
parentId: 'level_5msog1z8hy2lyvxr',
recessed: false,
elevation: 0.19757210573188194,
thickness: 0.5,
holeMetadata: [],
autoFromWalls: true,
},
}
+14 -1
View File
@@ -46,7 +46,7 @@ export {
DEFAULT_LEVEL_HEIGHT,
getCeilingAt,
getCeilingHeightAt,
getLevelHeight,
resolveCeilingHeight,
} from './level-height'
export {
type AxisLock,
@@ -102,6 +102,19 @@ export {
snapVec3ToGrid,
snapWorldXZToBuildingLocal,
} from './snap'
export {
CEILING_CLAMP_MARGIN,
findLevelAboveId,
findLevelBelowId,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
type LevelElevation,
} from './storey'
export {
buildPortComponents,
type SystemSummary,
@@ -0,0 +1,228 @@
import { describe, expect, it } from 'bun:test'
import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height'
function createFixture(): Record<AnyNodeId, AnyNode> {
const nodes: AnyNode[] = [
LevelNode.parse({ id: 'level_empty', children: [] }),
LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }),
WallNode.parse({
id: 'wall_no_slab',
parentId: 'level_no_slab',
start: [10, 0],
end: [12, 0],
}),
LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }),
SlabNode.parse({
id: 'slab_standard',
parentId: 'level_standard_slab',
polygon: [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
],
elevation: 0.05,
}),
WallNode.parse({
id: 'wall_standard',
parentId: 'level_standard_slab',
start: [1, 2],
end: [3, 2],
}),
LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }),
SlabNode.parse({
id: 'slab_raised',
parentId: 'level_tall_wall',
polygon: [
[20, 0],
[24, 0],
[24, 4],
[20, 4],
],
elevation: 0.35,
}),
WallNode.parse({
id: 'wall_tall',
parentId: 'level_tall_wall',
start: [21, 2],
end: [23, 2],
height: 3.2,
}),
LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }),
WallNode.parse({
id: 'wall_below_ceiling',
parentId: 'level_ceiling',
start: [40, 0],
end: [42, 0],
}),
CeilingNode.parse({
id: 'ceiling_tall',
parentId: 'level_ceiling',
polygon: [
[40, 0],
[42, 0],
[42, 2],
[40, 2],
],
height: 3.4,
}),
LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }),
SlabNode.parse({
id: 'slab_negative',
parentId: 'level_negative_slab',
polygon: [
[30, 0],
[34, 0],
[34, 4],
[30, 4],
],
elevation: -0.4,
}),
WallNode.parse({
id: 'wall_negative',
parentId: 'level_negative_slab',
start: [31, 2],
end: [33, 2],
height: 2.8,
}),
]
return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('deriveLegacyLevelHeight', () => {
const nodes = createFixture()
const cases = [
['level_no_slab', 2.5],
['level_standard_slab', 2.5],
['level_tall_wall', 3.55],
['level_ceiling', 3.4],
['level_negative_slab', 2.8],
['level_empty', 2.5],
] as const
for (const [levelId, expected] of cases) {
it(`derives ${expected} for ${levelId}`, () => {
expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected)
})
}
})
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
// Post-migration stack: two stored-height levels; the upper level carries a
// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower
// level's ceiling clamp bound is 2.5 0.3 0.01 = 2.19 under the deck.
function createResolverFixture(options: { deck?: boolean } = {}): Record<AnyNodeId, AnyNode> {
const list: AnyNode[] = [
BuildingNode.parse({
id: 'building_a',
children: ['level_low', 'level_high'],
}),
LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }),
LevelNode.parse({
id: 'level_high',
level: 1,
height: 2.5,
parentId: 'building_a',
children: options.deck ? ['slab_deck'] : [],
}),
]
if (options.deck) {
list.push(
SlabNode.parse({
id: 'slab_deck',
parentId: 'level_high',
polygon: SQUARE,
elevation: 0,
thickness: 0.3,
}),
)
}
return Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
}
describe('resolveCeilingHeight', () => {
it('returns the explicit height verbatim when stored', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 })
expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0)
})
it('resolves an absent height to the level-top clamp bound', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
})
it('tracks a level height change without any ceiling write', () => {
const nodes = createResolverFixture()
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49)
const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number }
const raised = {
...nodes,
level_low: { ...level, height: 3.2 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19)
})
it('resolves under a covering deck from the level above', () => {
const nodes = createResolverFixture({ deck: true })
const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19)
})
it('falls back to the default plane when the level is unresolvable', () => {
const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE })
expect(resolveCeilingHeight(ceiling, {} as Record<AnyNodeId, AnyNode>)).toBeCloseTo(2.49)
})
})
describe('getCeilingAt lowest-wins with mixed follows/explicit', () => {
it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => {
const base = createResolverFixture()
const follows = CeilingNode.parse({
id: 'ceiling_follows',
parentId: 'level_low',
polygon: SQUARE,
})
const explicitLow = CeilingNode.parse({
id: 'ceiling_low',
parentId: 'level_low',
polygon: SQUARE,
height: 2.0,
})
const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] }
const nodes = {
...base,
level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode,
ceiling_follows: follows,
ceiling_low: explicitLow,
} as Record<AnyNodeId, AnyNode>
// Explicit 2.0 undercuts the 2.49 follows bound.
expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id)
// Raise the explicit one above the bound comparison: 2.6 stored — the
// follows ceiling (2.49) is now the lowest surface over the point.
const nodesHighExplicit = {
...nodes,
ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode,
} as Record<AnyNodeId, AnyNode>
expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id)
})
})
+55 -23
View File
@@ -1,40 +1,68 @@
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support'
import { resolveWallTop } from '../systems/wall/wall-top'
// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe:
// both sides only reference the other inside function bodies.
import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey'
export const DEFAULT_LEVEL_HEIGHT = 2.5
/**
* Optional resolver for a wall's rendered base Y (mesh elevation).
*
* `packages/core` is pure domain logic and must not read viewer/Three.js
* state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have
* registry access (viewer systems, node tools) may pass a resolver so the
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
* omit it and get a deterministic result from serialized node data alone.
* Effective ceiling height in level-local meters. An explicit stored
* `height` wins; absent height means the ceiling follows the level top —
* the same bound its write-clamp uses: min(storey plane, lowest
* covering-slab underside over its polygon) CEILING_CLAMP_MARGIN (see
* {@link getCeilingClampBound}). Falls back to the default plane minus
* the same margin when the owning level is unresolvable.
*/
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
export function resolveCeilingHeight(
ceiling: Pick<CeilingNode, 'height' | 'parentId' | 'polygon'>,
nodes: Record<AnyNodeId, AnyNode>,
): number {
if (ceiling.height != null) return ceiling.height
const bound =
typeof ceiling.parentId === 'string'
? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon)
: Number.POSITIVE_INFINITY
return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN
}
export function getLevelHeight(
export function deriveLegacyLevelHeight(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
resolveWallBaseY?: WallBaseYResolver,
): number {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT
const levelChildren = level.children
.map((childId) => nodes[childId as keyof typeof nodes])
.filter((child): child is AnyNode => child !== undefined)
const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab')
const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall')
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (!child) continue
for (const child of levelChildren) {
if (child.type === 'ceiling') {
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (ch > maxTop) maxTop = ch
// Absence here is the PRE-migration legacy schema default (2.5), not
// follows-mode — this derivation runs before the level has a height
// for a follows-mode bound to track.
const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
if (baseY < 0) baseY = 0
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
const wall = child as WallNode
const electedElevation = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation)
if (top > maxTop) maxTop = top
}
}
@@ -58,14 +86,18 @@ export function getCeilingAt(
if (!level) return null
let best: CeilingNode | null = null
let bestHeight = Number.POSITIVE_INFINITY
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling
const h = resolveCeilingHeight(ceiling, nodes)
if (best === null || h < bestHeight) {
best = ceiling
bestHeight = h
}
}
return best
}
@@ -82,5 +114,5 @@ export function getCeilingHeightAt(
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
return ceiling ? resolveCeilingHeight(ceiling, nodes) : null
}
+527
View File
@@ -0,0 +1,527 @@
import { describe, expect, test } from 'bun:test'
import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
import {
CEILING_CLAMP_MARGIN,
getCeilingClampBound,
getCoveringSlabUndersideAt,
getLevelAbove,
getLevelBelow,
getLevelElevations,
getStoredLevelHeight,
getWallPlaneTop,
} from './storey'
const buildNodes = (list: AnyNode[]): Record<AnyNodeId, AnyNode> =>
Object.fromEntries(list.map((node) => [node.id, node])) as Record<AnyNodeId, AnyNode>
const level = (
id: string,
ordinal: number,
opts: { height?: number; parentId?: string | null; children?: string[] } = {},
): LevelNode =>
LevelNode.parse({
id,
level: ordinal,
parentId: opts.parentId ?? null,
children: opts.children ?? [],
...(opts.height === undefined ? {} : { height: opts.height }),
})
const building = (id: string, children: string[]): BuildingNode =>
BuildingNode.parse({ id, children })
const slabNode = (
id: string,
opts: {
polygon?: Array<[number, number]>
holes?: Array<Array<[number, number]>>
elevation?: number
thickness?: number
recessed?: boolean
},
): SlabNode =>
SlabNode.parse({
id,
polygon:
opts.polygon ??
([
[0, 0],
[4, 0],
[4, 4],
[0, 4],
] as Array<[number, number]>),
holes: opts.holes ?? [],
...(opts.elevation === undefined ? {} : { elevation: opts.elevation }),
...(opts.thickness === undefined ? {} : { thickness: opts.thickness }),
...(opts.recessed === undefined ? {} : { recessed: opts.recessed }),
})
describe('getStoredLevelHeight', () => {
test('returns the stored height when present', () => {
expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25)
})
test('falls back to the default for unmigrated legacy levels', () => {
expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT)
expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5)
})
})
describe('getLevelElevations', () => {
test('single building matches a hand-computed prefix sum', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']),
level('level_0', 0, { height: 3, parentId: 'building_a' }),
level('level_1', 1, { height: 2.5, parentId: 'building_a' }),
level('level_2', 2, { height: 2.75, parentId: 'building_a' }),
level('level_3', 3, { height: 4, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')).toEqual({
baseY: 0,
height: 3,
buildingId: 'building_a',
ordinal: 0,
})
expect(elevations.get('level_1')?.baseY).toBe(3)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_3')?.baseY).toBe(8.25)
})
test('stacks two buildings independently with interleaved, unsorted ordinals', () => {
const nodes = buildNodes([
level('level_b1', 1, { height: 2.5, parentId: 'building_b' }),
level('level_a2', 2, { height: 3, parentId: 'building_a' }),
building('building_a', ['level_a0', 'level_a1', 'level_a2']),
level('level_a0', 0, { height: 3.5, parentId: 'building_a' }),
building('building_b', ['level_b0', 'level_b1']),
level('level_b0', 0, { height: 4, parentId: 'building_b' }),
level('level_a1', 1, { height: 3.25, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_a0')?.baseY).toBe(0)
expect(elevations.get('level_a1')?.baseY).toBe(3.5)
expect(elevations.get('level_a2')?.baseY).toBe(6.75)
expect(elevations.get('level_b0')?.baseY).toBe(0)
expect(elevations.get('level_b1')?.baseY).toBe(4)
expect(elevations.get('level_a2')?.buildingId).toBe('building_a')
expect(elevations.get('level_b1')?.buildingId).toBe('building_b')
})
test('negative ordinals stack from the lowest level up', () => {
const nodes = buildNodes([
building('building_a', ['level_basement', 'level_ground', 'level_upper']),
level('level_upper', 1, { height: 3, parentId: 'building_a' }),
level('level_basement', -1, { height: 2.25, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_basement')?.baseY).toBe(0)
expect(elevations.get('level_ground')?.baseY).toBe(2.25)
expect(elevations.get('level_upper')?.baseY).toBe(4.75)
})
test('duplicate and fractional ordinals stack stably without NaN', () => {
const nodes = buildNodes([
building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']),
level('level_dup_b', 1, { height: 3, parentId: 'building_a' }),
level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }),
level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }),
level('level_ground', 0, { height: 2.5, parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_ground')?.baseY).toBe(0)
expect(elevations.get('level_mezz')?.baseY).toBe(2.5)
// Stable sort: equal ordinals keep nodes-record insertion order.
expect(elevations.get('level_dup_b')?.baseY).toBe(4)
expect(elevations.get('level_dup_a')?.baseY).toBe(7)
for (const elevation of elevations.values()) {
expect(Number.isFinite(elevation.baseY)).toBe(true)
expect(Number.isFinite(elevation.height)).toBe(true)
}
})
test('levels missing height fall back to 2.5 for both height and stacking', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_1', 'level_2']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_1', 1, { height: 3, parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_0')?.height).toBe(2.5)
expect(elevations.get('level_1')?.baseY).toBe(2.5)
expect(elevations.get('level_2')?.baseY).toBe(5.5)
expect(elevations.get('level_2')?.height).toBe(2.5)
})
test('resolves buildings via parentId, legacy children membership, and non-building parents', () => {
const nodes = buildNodes([
// level_direct is not in children; level_site has a non-building parentId.
building('building_x', ['level_legacy', 'level_site']),
level('level_direct', 0, { height: 3, parentId: 'building_x' }),
level('level_legacy', 1, { height: 2.5, parentId: null }),
level('level_site', 2, { height: 2.75, parentId: 'site_main' }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_direct')?.buildingId).toBe('building_x')
expect(elevations.get('level_legacy')?.buildingId).toBe('building_x')
expect(elevations.get('level_site')?.buildingId).toBe('building_x')
expect(elevations.get('level_direct')?.baseY).toBe(0)
expect(elevations.get('level_legacy')?.baseY).toBe(3)
expect(elevations.get('level_site')?.baseY).toBe(5.5)
})
test('levels with no resolvable building share one legacy stack from 0', () => {
const nodes = buildNodes([
level('level_orphan_1', 1, { height: 3 }),
level('level_orphan_0', 0, { height: 2.75 }),
])
const elevations = getLevelElevations(nodes)
expect(elevations.get('level_orphan_0')).toEqual({
baseY: 0,
height: 2.75,
buildingId: null,
ordinal: 0,
})
expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75)
})
})
describe('getLevelAbove', () => {
test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2')
expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5')
expect(getLevelAbove('level_5', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelAbove('level_a0', nodes)).toBeNull()
expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1')
})
test('orphan levels resolve within the shared legacy stack', () => {
const nodes = buildNodes([
level('level_orphan_0', 0, { height: 2.75 }),
level('level_orphan_1', 1, { height: 3 }),
])
expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1')
expect(getLevelAbove('level_orphan_1', nodes)).toBeNull()
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelAbove('level_missing', nodes)).toBeNull()
})
})
describe('getLevelBelow', () => {
test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => {
const nodes = buildNodes([
building('building_a', ['level_0', 'level_2', 'level_5']),
level('level_0', 0, { parentId: 'building_a' }),
level('level_5', 5, { parentId: 'building_a' }),
level('level_2', 2, { parentId: 'building_a' }),
])
expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2')
expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0')
expect(getLevelBelow('level_0', nodes)).toBeNull()
})
test('never crosses into another building', () => {
const nodes = buildNodes([
building('building_a', ['level_a0']),
building('building_b', ['level_b0', 'level_b1']),
level('level_a0', 0, { parentId: 'building_a' }),
level('level_b0', 0, { parentId: 'building_b' }),
level('level_b1', 1, { parentId: 'building_b' }),
])
expect(getLevelBelow('level_a0', nodes)).toBeNull()
expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0')
})
test('returns null for an unknown level id', () => {
const nodes = buildNodes([level('level_0', 0)])
expect(getLevelBelow('level_missing', nodes)).toBeNull()
})
})
// Two stacked levels in one building; `slabs` become children of the level
// above the queried one.
const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) =>
buildNodes([
building('building_a', ['level_0', 'level_1']),
level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }),
level('level_1', 1, {
height: 2.5,
parentId: 'building_a',
children: slabs.map((node) => node.id),
}),
...slabs,
])
describe('getCoveringSlabUndersideAt', () => {
test('expresses a flush deck underside in the queried level local Y', () => {
// Flush deck occupying [-0.3, 0] above the plane: underside sits at
// storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
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()
})
test('a hole in the slab vetoes coverage', () => {
const nodes = stackedNodes([
slabNode('slab_deck', {
elevation: 0,
thickness: 0.3,
holes: [
[
[1, 1],
[3, 1],
[3, 3],
[1, 3],
],
],
}),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2)
})
test('recessed pools never cover', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull()
})
test('the lowest underside wins among overlapping covering slabs', () => {
const nodes = stackedNodes([
// Default floor slab occupying [0, 0.05]: underside at the plane (2.5).
slabNode('slab_floor', {}),
slabNode('slab_deck', { elevation: 0, thickness: 0.3 }),
])
expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2)
})
test('returns null when there is no level above', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull()
})
})
describe('getWallPlaneTop', () => {
const wallAt = (
start: [number, number],
end: [number, number],
): { start: [number, number]; end: [number, number] } => ({ start, end })
test('no covering slab → the stored level height', () => {
const nodes = stackedNodes([], 3)
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3)
})
test('a flush thick deck above clamps the plane to its underside', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
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.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[3.5, 0],
[6, 0],
[6, 4],
[3.5, 4],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a recessed slab above is ignored', () => {
const nodes = stackedNodes([
slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }),
])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5)
})
test('falls back to the default height when the level does not resolve', () => {
const nodes = stackedNodes([])
expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe(
DEFAULT_LEVEL_HEIGHT,
)
})
test('repro project: both boundary walls clamp to the covering slab underside', () => {
// Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's
// polygon derives from the level-0 wall CENTERLINES, so every perimeter
// wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge)
// clamped while Wall 1 (max-z edge) ran full height — ray-cast
// pointInPolygon includes min-side boundaries and excludes max-side ones.
const nodes = reproFixture as unknown as Record<AnyNodeId, AnyNode>
const levelId = 'level_pomuk0sbwec15mf3'
const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode
const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode
// storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5)
const underside = 2.7 + (0.19757210573188194 - 0.5)
expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside)
expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside)
})
test('all four rectangle walls under a same-footprint covering slab clamp', () => {
// The repro shape distilled: wall centerlines lie exactly on the covering
// slab's polygon edges. Every orientation must clamp identically.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const walls: Array<[[number, number], [number, number]]> = [
[
[0, 0],
[4, 0],
],
[
[4, 0],
[4, 4],
],
[
[4, 4],
[0, 4],
],
[
[0, 4],
[0, 0],
],
]
for (const [start, end] of walls) {
expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2)
}
})
test('a diagonal wall under the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2)
})
test('a wall fully outside the covering slab keeps the storey height', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5)
})
test('a wall partially overlapping the covering slab clamps', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2)
})
})
describe('getCeilingClampBound', () => {
const ceilingPolygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
test('with no covering slab the bound is the storey plane minus the margin', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.5 - CEILING_CLAMP_MARGIN,
)
})
test('a covering deck lowers the bound to its underside minus the margin', () => {
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - 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.
const nodes = stackedNodes([
slabNode('slab_deck', {
polygon: [
[1.5, 1.5],
[2.5, 1.5],
[2.5, 2.5],
[1.5, 2.5],
],
elevation: 0,
thickness: 0.3,
}),
])
expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
test('returns Infinity for an unresolvable level', () => {
const nodes = stackedNodes([])
expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe(
Number.POSITIVE_INFINITY,
)
})
test('vertices on the covering slab boundary clamp identically on every side', () => {
// Two mirrored strips share an edge with the 4x4 deck: one along its
// min-z edge, one along its max-z edge. Their interiors and centroids sit
// outside the deck, so only the shared-edge vertices can register —
// ray-cast pointInPolygon used to admit the min-side vertices and reject
// the max-side ones, giving orientation-dependent clamps.
const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })])
const minSideStrip: Array<[number, number]> = [
[0, -1],
[4, -1],
[4, 0],
[0, 0],
]
const maxSideStrip: Array<[number, number]> = [
[0, 4],
[4, 4],
[4, 5],
[0, 5],
]
expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo(
2.2 - CEILING_CLAMP_MARGIN,
)
})
})
+358
View File
@@ -0,0 +1,358 @@
import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
import {
pointInPolygon,
pointOnPolygonBoundary,
wallOverlapsSlabFootprint,
} from '../systems/slab/slab-support'
import { DEFAULT_LEVEL_HEIGHT } from './level-height'
/**
* Gap kept between a ceiling's stored height and its clamp bound (storey
* plane or covering-slab underside), so the ceiling surface never
* coincides with the solid above it.
*/
export const CEILING_CLAMP_MARGIN = 0.01
/**
* Stored storey height in meters (floor-to-floor). Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height`
* field is absent.
*/
export function getStoredLevelHeight(level: Pick<LevelNode, 'height'>): number {
return level.height ?? DEFAULT_LEVEL_HEIGHT
}
export type LevelElevation = {
/** World Y of the level's floor: prefix sum of the storey heights below it. */
baseY: number
/** Stored storey height of this level (fallback applied). */
height: number
buildingId: string | null
ordinal: number
}
/**
* Resolves the owning building: explicit `parentId` pointing at a building
* wins; legacy levels that only appear in a building's `children` array
* resolve through that membership.
*/
function resolveLevelBuildingId(
levelId: LevelNode['id'],
parentId: string | null,
buildings: readonly BuildingNode[],
): string | null {
const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined
if (directParent) return directParent.id
return buildings.find((building) => building.children.includes(levelId))?.id ?? null
}
/**
* 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.
*
* Pure — operates on the serialized nodes record only.
*/
export function getLevelElevations(nodes: Record<AnyNodeId, AnyNode>): Map<string, LevelElevation> {
const buildings = Object.values(nodes).filter(
(node): node is BuildingNode => node?.type === 'building',
)
const entries: Array<{ 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,
baseY: 0,
height: getStoredLevelHeight(level),
buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings),
ordinal: level.level,
})
}
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
elevations.set(entry.levelId, {
baseY,
height: entry.height,
buildingId: entry.buildingId,
ordinal: entry.ordinal,
})
cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height)
}
return elevations
}
/**
* The id of the level directly above `levelId` in its own stack (same
* resolved building, or the shared legacy stack for building-less levels):
* the level with the lowest ordinal strictly greater than the queried
* level's. `null` when the level is topmost or unresolvable.
*/
export function findLevelAboveId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let aboveId: string | null = null
let aboveOrdinal = Number.POSITIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) {
aboveOrdinal = candidate.ordinal
aboveId = candidateId
}
}
return aboveId
}
/**
* The level directly above `levelId` — see {@link findLevelAboveId}.
* `null` when topmost or unresolvable. Pure.
*/
export function getLevelAbove(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes))
if (!aboveId) return null
const above = nodes[aboveId as LevelNode['id']]
return above?.type === 'level' ? (above as LevelNode) : null
}
/**
* The id of the level directly below `levelId` in its own stack — mirror of
* {@link findLevelAboveId}: the level with the highest ordinal strictly less
* than the queried level's. `null` when the level is lowest or unresolvable.
*/
export function findLevelBelowId(
levelId: string,
elevations: Map<string, LevelElevation>,
): string | null {
const entry = elevations.get(levelId)
if (!entry) return null
let belowId: string | null = null
let belowOrdinal = Number.NEGATIVE_INFINITY
for (const [candidateId, candidate] of elevations) {
if (candidateId === levelId) continue
if (candidate.buildingId !== entry.buildingId) continue
if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) {
belowOrdinal = candidate.ordinal
belowId = candidateId
}
}
return belowId
}
/**
* The level directly below `levelId` — see {@link findLevelBelowId}.
* `null` when lowest or unresolvable. Pure.
*/
export function getLevelBelow(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): LevelNode | null {
const belowId = findLevelBelowId(levelId, getLevelElevations(nodes))
if (!belowId) return null
const below = nodes[belowId as LevelNode['id']]
return below?.type === 'level' ? (below as LevelNode) : null
}
type CoveringSlabContext = {
/** Stored storey height of the QUERIED level. */
storeyHeight: number
/** Non-recessed slab children of the level above. */
slabs: SlabNode[]
}
/**
* Storey height of the queried level plus the level-above's covering
* (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level.
* A missing level above yields an empty slab list, not `null` — the
* storey height is still meaningful for the clamp bound.
*/
function resolveCoveringSlabContext(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): CoveringSlabContext | null {
const level = nodes[levelId as LevelNode['id']]
if (level?.type !== 'level') return null
const above = getLevelAbove(levelId, nodes)
const slabs: SlabNode[] = []
for (const childId of above?.children ?? []) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'slab') continue
const slab = child as SlabNode
// Recessed slabs (pools) are open shells, not covering solids.
if (slab.recessed === true) continue
if (slab.polygon.length < 3) continue
slabs.push(slab)
}
return { storeyHeight: 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.
*/
function coveringUndersideY(storeyHeight: number, slab: SlabNode): number {
return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05))
}
/**
* Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`.
* Ray-cast pointInPolygon flips arbitrarily for points exactly ON the
* boundary (min-side edges read inside, max-side edges outside), so
* boundary contact counts as covered explicitly — the same convention as
* the slab-support interval classification. A point on a hole's rim keeps
* coverage (mirrors the support election's hole handling).
*
* Raw stored polygon + holes on purpose (mirrors getCeilingAt): the
* clamp bound doesn't need the rendered footprint's junction trims,
* and staying off the render path keeps this query cheap and pure.
*/
function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean {
if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) {
return false
}
for (const hole of slab.holes ?? []) {
if (hole.length < 3) continue
if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false
}
return true
}
/**
* Lowest underside among `slabs` covering `[x, z]`, in the queried
* level's local Y, or `null` when none covers the point.
*/
function lowestCoveringUndersideAt(
context: CoveringSlabContext,
x: number,
z: number,
): number | null {
let lowest: number | null = null
for (const slab of context.slabs) {
if (!slabCoversPoint(slab, x, z)) continue
const underside = coveringUndersideY(context.storeyHeight, slab)
if (lowest === null || underside < lowest) lowest = underside
}
return lowest
}
/**
* 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
* (pools) never cover. `null` when no covering slab (or no level above).
*
* Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ
* transform and the viewer's LevelSystem writes only `position.y`), so a
* level-local `[x, z]` is valid in every level of the stack unchanged.
*/
export function getCoveringSlabUndersideAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return null
return lowestCoveringUndersideAt(context, x, z)
}
/**
* Top plane for a plane-bound wall on `levelId`, in level-local Y:
* `min(stored storey height, lowest covering-slab underside over the wall's
* span)` — a thick or flush slab on the level above SHORTENS the walls below
* instead of colliding with them (Revit-style automatic attach).
*
* Coverage: the wall's thickness band (centerline + face lines, arc-aware)
* is clipped against each covering slab's stored polygon minus holes via
* {@link wallOverlapsSlabFootprint} — the same overlap machinery as the
* support election. Point sampling is deliberately avoided: auto-slab
* polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON
* the polygon boundary, where ray-cast point-in-polygon flips with the
* edge's orientation (one wall clamped, its neighbor didn't). Boundary
* contact counts as covered on every side of the slab.
*
* This is THE plane for a plane-bound wall (`height` absent). Explicit-height
* walls ignore the value (`resolveWallTop` returns their stored height), so
* passing it wherever a raw storey height feeds `resolveWallTop` /
* `resolveWallEffectiveHeight` is always safe. Falls back to
* {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level.
*/
export function getWallPlaneTop(
wall: Pick<WallNode, 'start' | 'end'> & Partial<Pick<WallNode, 'thickness' | 'curveOffset'>>,
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return DEFAULT_LEVEL_HEIGHT
let plane = context.storeyHeight
for (const slab of context.slabs) {
const underside = coveringUndersideY(context.storeyHeight, slab)
if (underside >= plane) continue
if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue
plane = underside
}
return plane
}
/**
* Upper bound for a ceiling's stored height over `polygon` on `levelId`:
* `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`.
* The covering underside is sampled at every polygon vertex plus the
* centroid — cheap, and a slab overlapping a convex-ish ceiling almost
* always covers one of those points; exact polygon-vs-polygon overlap is
* not worth its cost for a clamp bound. Ceiling outlines share footprint
* edges with the slabs above them the same way walls do, so vertices
* sitting exactly on a slab's boundary count as covered on every side
* (see `slabCoversPoint`) instead of flipping with the edge orientation.
*
* Returns `Infinity` when `levelId` doesn't resolve, so callers clamp
* against nothing rather than a garbage plane.
*/
export function getCeilingClampBound(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
polygon: ReadonlyArray<[number, number]>,
): number {
const context = resolveCoveringSlabContext(levelId, nodes)
if (!context) return Number.POSITIVE_INFINITY
let bound = context.storeyHeight
if (polygon.length > 0) {
let cx = 0
let cz = 0
for (const [x, z] of polygon) {
cx += x
cz += z
}
const samples: Array<[number, number]> = [
...polygon,
[cx / polygon.length, cz / polygon.length],
]
for (const [x, z] of samples) {
const underside = lowestCoveringUndersideAt(context, x, z)
if (underside !== null && underside < bound) bound = underside
}
}
return bound - CEILING_CLAMP_MARGIN
}
@@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode {
return sanitized.value as AnyNode
}
// An explicit `key: undefined` in update data REMOVES the key: optional
// fields like wall.height encode a mode by their absence (absent =
// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so
// a plain spread would leave a lingering own key that breaks `'height' in
// node` checks.
function mergeNodeUpdate(currentNode: AnyNode, patch: Partial<AnyNode>): AnyNode {
const merged: Record<string, unknown> = { ...currentNode, ...patch }
for (const key of Object.keys(patch)) {
if ((patch as Record<string, unknown>)[key] === undefined) delete merged[key]
}
return merged as AnyNode
}
function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode {
const candidate = { ...currentNode, ...data }
const candidate = mergeNodeUpdate(currentNode, data)
const parsed = AnyNodeSchema.safeParse(candidate)
if (parsed.success) return parsed.data
@@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial<AnyNode>): AnyNode
const sanitized = sanitizeNumericValue(schema, data, currentNode, [])
if (sanitized.issues.length === 0) {
return candidate as AnyNode
return candidate
}
warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues)
return { ...currentNode, ...(sanitized.value as Partial<AnyNode>) } as AnyNode
return mergeNodeUpdate(currentNode, sanitized.value as Partial<AnyNode>)
}
function shouldRefreshDefaultRidgeVents(data: Partial<AnyNode>) {
@@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) {
(a.parentId ?? null) === (b.parentId ?? null) &&
Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 &&
Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 &&
Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 &&
// Absent height means plane-bound (follows the storey), which must never
// merge with an explicit height — even one that currently matches the plane.
(a.height == null) === (b.height == null) &&
Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 &&
aInterior === bInterior &&
aExterior === bExterior &&
a.frontSide === b.frontSide &&
@@ -1129,6 +1145,31 @@ export const deleteNodesAction = (
}
}
// Deleting a slab strips `supportSlabId` / `deckSlabId` references from
// surviving nodes in the same undo commit (mirrors the collectionIds
// cleanup below), so those nodes re-elect their support / re-derive
// their rise. Deletion is the ONLY writer — a host merely reshaped away
// keeps the field and the read path falls back, letting hosting resume
// if the slab returns.
const deletedSlabIds = new Set<string>()
for (const id of allIds) {
if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id)
}
if (deletedSlabIds.size > 0) {
for (const [nodeId, node] of Object.entries(nextNodes)) {
if (allIds.has(nodeId as AnyNodeId)) continue
const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {}
const hostId = (node as { supportSlabId?: string }).supportSlabId
if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined
const deckId = (node as { deckSlabId?: string }).deckSlabId
if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined
if (Object.keys(patch).length > 0) {
nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode
nodesToMarkDirty.add(nodeId as AnyNodeId)
}
}
}
for (const id of allIds) {
const node = nextNodes[id]
if (!node) continue
@@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number
const SHELF_ID = 'shelf_sanitize' as AnyNodeId
const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId
const WALL_ID = 'wall_keyremoval' as AnyNodeId
function makeWall(): AnyNode {
return {
id: WALL_ID,
type: 'wall',
parentId: null,
object: 'node',
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [4, 0],
height: 2.5,
} as unknown as AnyNode
}
function makeShelf(overrides: Partial<AnyNode> = {}): AnyNode {
return {
@@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => {
expect(Number.isFinite(created.thickness)).toBe(true)
})
})
describe('node update explicit-undefined key removal', () => {
beforeEach(() => {
useScene.setState({
nodes: { [WALL_ID]: makeWall() },
rootNodeIds: [WALL_ID],
dirtyNodes: new Set(),
collections: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('an undefined value in update data removes the key from the stored node', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
})
test('undo restores a key removed via an undefined update value', () => {
useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial<AnyNode>)
expect('height' in (useScene.getState().nodes[WALL_ID] as Record<string, unknown>)).toBe(false)
useScene.temporal.getState().undo()
const wall = useScene.getState().nodes[WALL_ID] as { height?: number }
expect('height' in wall).toBe(true)
expect(wall.height).toBe(2.5)
})
test('other keys in the same patch still apply when one is removed', () => {
useScene.getState().updateNode(WALL_ID, {
height: undefined,
name: 'Plane-bound wall',
} as Partial<AnyNode>)
const wall = useScene.getState().nodes[WALL_ID] as Record<string, unknown>
expect('height' in wall).toBe(false)
expect(wall.name).toBe('Plane-bound wall')
})
})
@@ -7,6 +7,14 @@ import { create } from 'zustand'
export type LiveTransform = {
position: [number, number, number]
rotation: number // Y-axis rotation (plan-view rotation)
/**
* Pointer-decided support cap (level-local Y) published by 3D drags:
* the elevation of the surface the cursor ray actually points at. The
* floor-elevation system passes it to the slab-support election so a
* deck above the aimed-at floor never lifts the dragged node. Absent
* for 2D floorplan drags (no camera ray) — election stays uncapped.
*/
supportElevationCap?: number
}
type LiveTransformState = {
@@ -666,7 +666,9 @@ describe('scene commit boundary', () => {
const snapshot = currentSnapshot()
snapshot.nodes = {
...snapshot.nodes,
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode,
// Marker must survive the load migration: level ordinals renumber on
// load, so the stored storey height marks the applied snapshot instead.
[LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode,
}
snapshot.installedPlugins = ['pascal:trees']
const commits: SceneCommit[] = []
@@ -674,7 +676,7 @@ describe('scene commit boundary', () => {
useScene.getState().dirtyNodes.clear()
expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true)
expect(levelNumber()).toBe(8)
expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8)
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(commits.map((commit) => commit.origin)).toEqual(['host'])
expect(useScene.temporal.getState().pastStates).toHaveLength(0)
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene construction-dimension migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('normalizes the legacy reference presentation before parsing', () => {
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['construction-dimension_test'],
level: 0,
},
'construction-dimension_test': {
object: 'node',
id: 'construction-dimension_test',
type: 'construction-dimension',
parentId: 'level_test',
visible: true,
metadata: {},
reference: true,
referenceStyle: 'suffix',
drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }],
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
Record<string, unknown>
expect(dimension.reference).toBeUndefined()
expect(dimension.referenceStyle).toBeUndefined()
expect(dimension.drawingOverrides).toEqual([
{ drawingType: 'roof-plan', presentation: 'shown' },
])
})
})
@@ -0,0 +1,376 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
type RawNode = Record<string, unknown>
function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode {
return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra }
}
function site(children: string[]): RawNode {
return baseNode('site_test', 'site', null, { children })
}
function building(id: string, children: string[]): RawNode {
return baseNode(id, 'building', 'site_test', { children })
}
function level(
id: string,
buildingId: string,
ordinal: number,
children: string[],
extra: RawNode = {},
): RawNode {
return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra })
}
function wall(
id: string,
levelId: string,
start: [number, number],
end: [number, number],
height?: number,
): RawNode {
return baseNode(id, 'wall', levelId, {
start,
end,
children: [],
...(height !== undefined ? { height } : {}),
})
}
function slab(
id: string,
levelId: string,
polygon: Array<[number, number]>,
elevation = 0.05,
): RawNode {
return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation })
}
function ceiling(
id: string,
levelId: string,
polygon: Array<[number, number]>,
height: number,
extra: RawNode = {},
): RawNode {
return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra })
}
function stair(id: string, levelId: string, extra: RawNode = {}): RawNode {
return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra })
}
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 4],
[0, 4],
]
function loadScene(nodes: Record<string, RawNode>): Record<string, AnyNode> {
useScene.getState().setScene(nodes as unknown as Record<string, AnyNode>, ['site_test'] as never)
return useScene.getState().nodes as Record<string, AnyNode>
}
type LevelResult = Extract<AnyNode, { type: 'level' }>
type WallResult = Extract<AnyNode, { type: 'wall' }>
type StairResult = Extract<AnyNode, { type: 'stair' }>
type SlabResult = Extract<AnyNode, { type: 'slab' }>
type CeilingResult = Extract<AnyNode, { type: 'ceiling' }>
describe('scene vertical model migration', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('hole pattern: walls within 0.20 of the plane become plane-bound', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]),
})
// Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short.
expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65)
expect('height' in (nodes.wall_tall as WallResult)).toBe(false)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect('height' in (nodes.wall_b as WallResult)).toBe(false)
})
test('intentional short walls at or beyond 0.20 keep their explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1),
})
expect((nodes.level_a as LevelResult).height).toBe(2.5)
expect((nodes.wall_a as WallResult).height).toBe(2.3)
expect((nodes.wall_b as WallResult).height).toBe(2.1)
})
test('absent-height wall well short of the plane materializes the 2.5 default', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.wall_a as WallResult).height).toBe(2.5)
})
test('ordinal renumber compacts per building, anchored at zero', () => {
const nodes = loadScene({
site_test: site(['building_a', 'building_b']),
building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']),
building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']),
// Duplicate fractional ordinals (MCP wrote elevation params here).
level_a1: level('level_a1', 'building_a', 2.5, []),
level_a2: level('level_a2', 'building_a', 2.5, []),
level_a3: level('level_a3', 'building_a', 5, []),
// Basements compact upward toward -1, non-negatives down to 0.
level_b1: level('level_b1', 'building_b', -3, []),
level_b2: level('level_b2', 'building_b', -1, []),
level_b3: level('level_b3', 'building_b', 0, []),
level_b4: level('level_b4', 'building_b', 4, []),
})
expect((nodes.level_a1 as LevelResult).level).toBe(0)
expect((nodes.level_a2 as LevelResult).level).toBe(1)
expect((nodes.level_a3 as LevelResult).level).toBe(2)
expect((nodes.level_b1 as LevelResult).level).toBe(-2)
expect((nodes.level_b2 as LevelResult).level).toBe(-1)
expect((nodes.level_b3 as LevelResult).level).toBe(0)
expect((nodes.level_b4 as LevelResult).level).toBe(1)
})
test('near-bound ceiling heights become follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
// Legacy default: ceiling 2.5 drives the derived level height 2.5,
// so the clamp bound is 2.49 and |2.5 2.49| < 0.20 → follows.
level_a: level('level_a', 'building_a', 0, ['ceiling_a']),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5),
// Already write-clamped default: 2.49 under a derived 2.49 level
// (bound 2.48) → follows too.
level_b: level('level_b', 'building_a', 1, ['ceiling_b']),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49),
})
expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false)
expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false)
})
test('an intentional low ceiling keeps its explicit height', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under
// the 2.99 bound — a deliberate dropped ceiling, kept explicit.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0),
})
expect((nodes.level_a as LevelResult).height).toBe(3.0)
expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0)
})
test('autoFromWalls ceilings always convert to follows-mode', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// 2.2 is far from the 2.99 bound, but auto heights were always
// derived by the sync — never user intent — so it drops anyway.
level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }),
})
expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false)
})
test('migrated scene keeps a near-bound ceiling height (gate respected)', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
// Post-migration scene (level carries height): a stored 2.49 IS a
// deliberately typed value and must survive reloads.
level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }),
ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49),
ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }),
})
expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49)
expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49)
})
test('legacy scene drops totalRise 2.5 but keeps other rises', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
})
expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false)
expect((nodes.stair_b as StairResult).totalRise).toBe(3.1)
})
test('migrated scene keeps a deliberately typed totalRise 2.5', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
})
expect((nodes.stair_a as StairResult).totalRise).toBe(2.5)
})
test('already-migrated level and its walls are untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]),
wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5),
})
expect((nodes.level_a as LevelResult).height).toBe(4.0)
expect('height' in (nodes.wall_a as WallResult)).toBe(false)
expect((nodes.wall_b as WallResult).height).toBe(2.5)
})
test('slab split writes thickness = elevation exactly for legacy solids', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']),
slab_a: slab('slab_a', 'level_a', SQUARE, 0.3),
slab_b: slab('slab_b', 'level_a', SQUARE, 0),
})
const raised = nodes.slab_a as SlabResult
expect(raised.elevation).toBe(0.3)
expect(raised.thickness).toBe(0.3)
expect(raised.recessed).not.toBe(true)
// Degenerate zero-elevation slab keeps its zero occupied interval —
// migration never clamps to MIN_SLAB_THICKNESS.
const flush = nodes.slab_b as SlabResult
expect(flush.elevation).toBe(0)
expect(flush.thickness).toBe(0)
})
test('slab split defaults an absent elevation to the effective 0.05 thickness', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }),
})
expect((nodes.slab_a as SlabResult).thickness).toBe(0.05)
})
test('legacy pool becomes recessed with its elevation unchanged', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
slab_a: slab('slab_a', 'level_a', SQUARE, -0.15),
})
const pool = nodes.slab_a as SlabResult
expect(pool.elevation).toBe(-0.15)
expect(pool.recessed).toBe(true)
expect(pool.thickness).toBe(0.05)
})
test('slab with thickness already present is untouched', () => {
const nodes = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a']),
level_a: level('level_a', 'building_a', 0, ['slab_a']),
// A below-plane SOLID (already-split scene): the gate must not
// reinterpret its negative elevation as a pool.
slab_a: baseNode('slab_a', 'slab', 'level_a', {
polygon: SQUARE,
holes: [],
elevation: -0.15,
thickness: 0.3,
}),
})
const deck = nodes.slab_a as SlabResult
expect(deck.elevation).toBe(-0.15)
expect(deck.thickness).toBe(0.3)
expect('recessed' in deck).toBe(false)
})
test('migration is idempotent', () => {
const first = loadScene({
site_test: site(['building_a']),
building_a: building('building_a', ['level_a', 'level_b']),
level_a: level('level_a', 'building_a', 2.5, [
'slab_a',
'wall_tall',
'wall_a',
'stair_a',
'stair_b',
]),
level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']),
slab_a: slab('slab_a', 'level_a', SQUARE),
wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65),
wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]),
stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }),
stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }),
ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0),
wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]),
})
const second = loadScene(structuredClone(first) as unknown as Record<string, RawNode>)
expect(second).toEqual(first)
})
})
+211 -2
View File
@@ -32,6 +32,10 @@ import {
type SceneMaterialId,
} from '../schema/scene-material'
import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types'
import { deriveLegacyLevelHeight } from '../services/level-height'
import { getCeilingClampBound } from '../services/storey'
import { computeWallSlabSupport } from '../systems/slab/slab-support'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint'
import { healSceneNodes } from '../utils/heal-scene-graph'
import * as nodeActions from './actions/node-actions'
import {
@@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number
}
function normalizeStairNode(node: Record<string, unknown>) {
const hasTotalRise = 'totalRise' in node
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
@@ -101,7 +106,7 @@ function normalizeStairNode(node: Record<string, unknown>) {
slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'),
openingOffset: getFiniteNumber(node.openingOffset, 0),
width: getFiniteNumber(node.width, 1),
totalRise: getFiniteNumber(node.totalRise, 2.5),
totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined,
stepCount: getFiniteNumber(node.stepCount, 10),
thickness: getFiniteNumber(node.thickness, 0.25),
fillToFloor: getBoolean(node.fillToFloor, true),
@@ -117,7 +122,13 @@ function normalizeStairNode(node: Record<string, unknown>) {
}
const parsed = StairNodeSchema.safeParse(sanitized)
return parsed.success ? parsed.data : null
if (!parsed.success) return null
if (hasTotalRise) return parsed.data
// Absent `totalRise` means "rise derives from the storey height" and must
// survive the load: safeParse echoes the sanitized explicit-undefined key,
// which would flip `'totalRise' in node` checks — strip it back off.
const { totalRise: _totalRise, ...rest } = parsed.data
return rest
}
function normalizeStairSegmentNode(node: Record<string, unknown>) {
@@ -559,6 +570,36 @@ function migrateRoofSurfaceMaterials(node: Record<string, any>) {
return next
}
function migrateConstructionDimension(node: Record<string, any>) {
const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : []
const hasLegacyDrawingOverride = drawingOverrides.some(
(entry) =>
entry &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
entry.presentation === 'reference',
)
if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node
const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node
return {
...dimension,
drawingOverrides: drawingOverrides.map((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry
}),
}
}
// Walls whose top lands within this of the storey plane become plane-bound;
// ceilings whose stored height lands within this of their clamp bound become
// follows-mode (step 3f) — same census-backed threshold for both.
// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to
// a taller wall) must snap to the plane, while intentional 0.20-short walls
// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit
// height — hence 0.20 with a strictly-less-than comparison.
const PLANE_BOUND_EPSILON = 0.2
function migrateNodes(nodes: Record<string, any>): {
nodes: Record<string, AnyNode>
mintedMaterials: Record<SceneMaterialId, SceneMaterial>
@@ -667,6 +708,10 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
if (node.type === 'construction-dimension') {
patchedNodes[id] = migrateConstructionDimension(node)
}
if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {
@@ -886,6 +931,169 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
// Pass 3: vertical building model.
// A level without `height` marks a scene saved before the vertical model
// landed. Computed before this pass mutates anything: the stair-rise
// cleanup below must never run on already-migrated scenes.
const isLegacyScene = Object.values(patchedNodes).some(
(node) => node?.type === 'level' && !('height' in node),
)
// 3a. Ordinal renumber — always runs, per building (idempotent
// self-healing; MCP's create-level historically wrote its elevation PARAM
// into the ordinal, so fractional/duplicate ordinals exist in the wild).
const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building')
const levelsByBuilding = new Map<string | null, Array<{ id: string; ordinal: number }>>()
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'level') continue
// Mirrors the building resolution in services/storey.ts: an explicit
// parentId pointing at a building wins, membership in a building's
// children array is the legacy fallback, and unresolvable levels share
// one orphan bucket.
const buildingId =
buildingNodes.find((building) => building.id === node.parentId)?.id ??
buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ??
null
const bucket = levelsByBuilding.get(buildingId) ?? []
bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) })
levelsByBuilding.set(buildingId, bucket)
}
for (const bucket of levelsByBuilding.values()) {
// Anchored at zero on purpose: ordinals are semantic — `level < 0`
// renders "Basement N" and `level === 0` is the ground-floor default —
// so negatives compact upward toward 1 and non-negatives compact down
// to 0. A blind 0..n renumber would rename basements.
const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal)
const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length
sorted.forEach((entry, index) => {
const nextOrdinal = index - negativeCount
const current = patchedNodes[entry.id]
if (current.level !== nextOrdinal) {
patchedNodes[entry.id] = { ...current, level: nextOrdinal }
}
})
}
// 3b. Stored storey heights: materialize the legacy stacked height verbatim
// (never rounded or snapped — snapping would move existing buildings).
// All planes derive before any wall height below mutates.
const legacyLevelIds = Object.entries(patchedNodes)
.filter(([, node]) => node?.type === 'level' && !('height' in node))
.map(([id]) => id)
const derivedHeights = new Map<string, number>()
for (const levelId of legacyLevelIds) {
derivedHeights.set(
levelId,
deriveLegacyLevelHeight(levelId, patchedNodes as Record<AnyNodeId, AnyNode>),
)
}
for (const levelId of legacyLevelIds) {
const plane = derivedHeights.get(levelId)!
const level = patchedNodes[levelId]
patchedNodes[levelId] = { ...level, height: plane }
// 3c. Wall-top classification against the just-written plane, using the
// same slab-support election as deriveLegacyLevelHeight (call shape
// mirrored from services/level-height.ts). Walls whose top meets the
// plane drop their explicit height and follow the level from now on;
// walls ending short (or tall) keep an explicit height — materializing
// the 2.5 default onto absent-height walls that end short of the plane.
const children = getStringArray(level.children)
.map((childId) => patchedNodes[childId])
.filter((child) => child !== undefined)
const slabs = children.filter((child) => child.type === 'slab')
const walls = children.filter((child) => child.type === 'wall')
for (const wall of walls) {
const electedBase = computeWallSlabSupport(
{
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
},
slabs,
walls,
).elevation
const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT
const top = Math.max(0, electedBase) + effectiveHeight
if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) {
if ('height' in wall) {
const { height: _height, ...planeBound } = wall
patchedNodes[wall.id] = planeBound
}
} else {
patchedNodes[wall.id] = { ...wall, height: effectiveHeight }
}
}
}
// 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old
// schema default, not a user choice — drop it so the rise derives from the
// storey height. Gated on isLegacyScene because on a post-migration scene
// a stored 2.5 IS a deliberately typed value and must survive reloads.
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'stair') continue
if (node.totalRise !== 2.5) continue
const { totalRise: _totalRise, ...derivedRise } = node
patchedNodes[id] = derivedRise
}
}
// 3e. Slab placement/thickness split. `elevation` stays the walking surface;
// the new `thickness` grows downward so the solid occupies
// [elevation thickness, elevation]. Legacy solids extruded [0, elevation],
// so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS
// applies to edits only, never here) keeps the occupied interval identical.
// Legacy pools (elevation < 0) become explicit `recessed` intent with
// elevation unchanged. Gated per slab on a missing `thickness` — the
// migration output is cast, so schema defaults never materialize on load.
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'slab' || 'thickness' in node) continue
const elevation = getFiniteNumber(node.elevation, 0.05)
patchedNodes[id] =
elevation < 0
? { ...node, thickness: 0.05, recessed: true }
: { ...node, thickness: elevation }
}
// 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs
// after 3b/3e so the clamp bound sees stored level heights and split slab
// thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its
// clamp bound (min(storey plane, covering-slab underside) margin, via
// getCeilingClampBound) is the legacy default tracking the level top, not
// a choice — drop it so the ceiling follows the level from now on.
// autoFromWalls ceilings always convert: their height was derived by the
// space-detection sync, never user intent. Gated on isLegacyScene, which
// is exact — nothing shipped between the level-height migration and this
// one — and makes the step idempotent. Known accepted edge: a
// post-migration user typing a custom height exactly equal to the bound
// keeps it (the gate prevents re-classification on later loads).
if (isLegacyScene) {
for (const [id, node] of Object.entries(patchedNodes)) {
if (node?.type !== 'ceiling' || !('height' in node)) continue
const dropHeight = () => {
const { height: _height, ...follows } = node
patchedNodes[id] = follows
}
if (node.autoFromWalls === true) {
dropHeight()
continue
}
if (typeof node.parentId !== 'string') continue
const bound = getCeilingClampBound(
node.parentId,
patchedNodes as Record<AnyNodeId, AnyNode>,
Array.isArray(node.polygon) ? node.polygon : [],
)
const stored = getFiniteNumber(node.height, Number.NaN)
if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) {
dropHeight()
}
}
}
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
}
@@ -1163,6 +1371,7 @@ const useScene: UseSceneStore = create<SceneState>()(
const level0 = LevelNode.parse({
level: 0,
children: [],
height: 2.5,
})
const building = BuildingNode.parse({
@@ -1,13 +1,5 @@
import type {
AnyNode,
AnyNodeId,
CeilingNode,
ElevatorNode,
LevelNode,
WallNode,
} from '../../schema'
export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema'
import { getStoredLevelHeight } from '../../services/storey'
export type ElevatorLevelEntry = {
id: LevelNode['id']
@@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels(
return levels.slice(minIndex, maxIndex + 1)
}
export function getElevatorLevelHeight(levelId: string, nodes: Record<string, AnyNode>): number {
const level = nodes[levelId as AnyNodeId] as LevelNode | undefined
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
} else if (child.type === 'wall') {
const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT
if (height > maxTop) maxTop = height
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
export function resolveElevatorLevels(
elevator: ElevatorNode,
nodes: Record<string, AnyNode>,
@@ -119,7 +89,7 @@ export function resolveElevatorLevels(
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getElevatorLevelHeight(level.id, nodes)
cumulativeY += getStoredLevelHeight(level)
}
const serviceLevels = resolveElevatorServiceLevels(elevator, nodes)
@@ -0,0 +1,140 @@
import { describe, expect, it } from 'bun:test'
import { SlabNode, WallNode } from '../../schema'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
import {
clampSlabElevationForWalls,
computeWallSlabSupport,
getSlabElevationUpperBound,
} from './slab-support'
// 4×3 room slab drawn on the wall centerlines, like an auto-slab.
const SQUARE: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
const STOREY_HEIGHT = 2.7
const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT
function roomSlab(elevation: number) {
return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true })
}
function roomWalls(height?: number) {
return [
WallNode.parse({ start: [0, 0], end: [4, 0], height }),
WallNode.parse({ start: [4, 0], end: [4, 3], height }),
WallNode.parse({ start: [4, 3], end: [0, 3], height }),
WallNode.parse({ start: [0, 3], end: [0, 0], height }),
]
}
describe('clampSlabElevationForWalls', () => {
it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(true)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('leaves proposals at or below the bound untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(BOUND)
})
it('passes negative (recessed-committing) proposals through untouched', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(-0.6)
})
it('does not clamp when the walls all carry explicit heights', () => {
const slab = roomSlab(0.05)
const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
it('does not clamp a slab covering no walls', () => {
const island = SlabNode.parse({
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.05,
})
const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT)
expect(result.clamped).toBe(false)
expect(result.elevation).toBeCloseTo(2.5)
})
})
describe('getSlabElevationUpperBound', () => {
it('bounds a slab electable by plane-bound walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND)
})
it('is unbounded under explicit-height walls', () => {
const slab = roomSlab(0.05)
expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe(
Number.POSITIVE_INFINITY,
)
})
})
describe('computeWallSlabSupport preferred host', () => {
const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] }
const low = SlabNode.parse({
id: 'slab_low',
polygon: SQUARE,
elevation: 0.1,
autoFromWalls: true,
})
const high = SlabNode.parse({
id: 'slab_high',
polygon: SQUARE,
elevation: 0.6,
autoFromWalls: true,
})
it('elects the highest supporting elevation without a preference', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [])
expect(support.elevation).toBeCloseTo(0.6)
})
it('pins the elected elevation to a still-supporting preferred slab', () => {
const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low')
expect(support.elevation).toBeCloseTo(0.1)
// Fill-down machinery still derives from ALL supporting slabs.
expect(support.baseSegments).toHaveLength(1)
expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6)
})
it('ignores a preferred slab that no longer supports the wall', () => {
const island = SlabNode.parse({
id: 'slab_island',
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
elevation: 0.9,
})
const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island')
expect(support.elevation).toBeCloseTo(0.6)
})
})
@@ -0,0 +1,688 @@
import { getRenderableSlabPolygon } from '../../lib/slab-polygon'
import type { SlabNode, WallNode } from '../../schema'
import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve'
import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint'
import { MIN_WALL_HEIGHT } from '../wall/wall-top'
export type SlabElevationClamp = {
elevation: number
clamped: boolean
}
/**
* Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall
* (no stored `height`) keeps its top at the storey plane, so a slab that
* rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that
* wall's base would squeeze the wall body below its minimum (and at the
* plane, to nothing). Walls with explicit heights don't constrain — their
* top rides the elected base, not the plane. Negative proposals (the
* drag-through-zero path that commits the `recessed` intent) pass
* through untouched: this is a purely numeric upper bound.
*
* The election runs against `levelSlabs` with `proposedElevation`
* substituted into `slab`, so a slab that would only WIN the election at
* the proposed elevation still clamps, and a slab out-elected by a
* sibling doesn't. Pure.
*/
export function clampSlabElevationForWalls(
proposedElevation: number,
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): SlabElevationClamp {
const bound = storeyHeight - MIN_WALL_HEIGHT
if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false }
if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false }
const substituted = levelSlabs.some((candidate) => candidate.id === slab.id)
? levelSlabs.map((candidate) =>
candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate,
)
: [...levelSlabs, { ...slab, elevation: proposedElevation }]
for (const wall of levelWalls) {
if (wall.height != null) continue
const wallLike: WallOverlapInput = {
start: wall.start,
end: wall.end,
curveOffset: wall.curveOffset,
thickness: wall.thickness,
}
// Cheap pre-filter: a wall that never reaches the slab's footprint
// can't elect it, whatever the election says about sibling slabs.
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
const support = computeWallSlabSupport(wallLike, substituted, levelWalls)
if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) {
return { elevation: bound, clamped: true }
}
}
return { elevation: proposedElevation, clamped: false }
}
/**
* Static upper bound for a slab-elevation drag: probe the election with
* the slab raised above every sibling and the storey plane. If any
* plane-bound wall would elect it there, the drag may not pass
* `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above.
*/
export function getSlabElevationUpperBound(
slab: SlabNode,
levelWalls: WallNode[],
levelSlabs: readonly SlabNode[],
storeyHeight: number,
): number {
const probe =
Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1
return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped
? storeyHeight - MIN_WALL_HEIGHT
: Number.POSITIVE_INFINITY
}
/**
* Point-in-polygon test using ray casting algorithm.
*/
export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean {
let inside = false
const n = polygon.length
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = polygon[i]![0],
zi = polygon[i]![1]
const xj = polygon[j]![0],
zj = polygon[j]![1]
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
inside = !inside
}
}
return inside
}
function pointSegmentDistance(
px: number,
pz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number {
const dx = bx - ax
const dz = bz - az
const lengthSquared = dx * dx + dz * dz
if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az)
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared))
return Math.hypot(px - (ax + dx * t), pz - (az + dz * t))
}
// Ray-cast pointInPolygon is unreliable for points exactly on the polygon
// boundary: the answer flips depending on which side of the polygon the edge
// is on. Interval classification below therefore treats "within this distance
// of the boundary" as inside explicitly, so walls sitting exactly on a slab
// edge (the common case — auto-slab polygons derive from wall centerlines)
// classify identically on every side of the slab.
const ON_BOUNDARY_EPSILON = 1e-4
export function pointOnPolygonBoundary(
px: number,
pz: number,
polygon: Array<[number, number]>,
): boolean {
const n = polygon.length
for (let i = 0; i < n; i++) {
const [ax, az] = polygon[i]!
const [bx, bz] = polygon[(i + 1) % n]!
if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true
}
return false
}
/** Sub-interval along a segment or polyline: [start, end] in length units. */
type LengthInterval = [number, number]
function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] {
if (intervals.length <= 1) return intervals
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]]
for (let i = 1; i < sorted.length; i++) {
const [intervalStart, intervalEnd] = sorted[i]!
const last = merged[merged.length - 1]!
if (intervalStart <= last[1] + 1e-9) {
last[1] = Math.max(last[1], intervalEnd)
} else {
merged.push([intervalStart, intervalEnd])
}
}
return merged
}
/** Total length of a merged (sorted, disjoint) interval list. */
function intervalsLength(intervals: readonly LengthInterval[]): number {
let total = 0
for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart
return total
}
/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */
function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] {
if (base.length === 0 || cut.length === 0) return mergeIntervals(base)
const cuts = mergeIntervals(cut)
const result: LengthInterval[] = []
for (const [baseStart, baseEnd] of mergeIntervals(base)) {
let cursor = baseStart
for (const [cutStart, cutEnd] of cuts) {
if (cutEnd <= cursor) continue
if (cutStart >= baseEnd) break
if (cutStart > cursor) result.push([cursor, cutStart])
cursor = cutEnd
if (cursor >= baseEnd) break
}
if (cursor < baseEnd) result.push([cursor, baseEnd])
}
return result
}
/**
* Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and,
* when `includeBoundary`, on its boundary), as [t0, t1] fractions of the
* segment. The segment is split at every crossing with a polygon edge and
* each sub-interval is classified by its midpoint, so no test point ever
* sits on a crossing.
*/
function segmentInsideIntervals(
ax: number,
az: number,
bx: number,
bz: number,
polygon: Array<[number, number]>,
includeBoundary: boolean,
): LengthInterval[] {
const dx = bx - ax
const dz = bz - az
const length = Math.hypot(dx, dz)
if (length < 1e-9) return []
const ts = [0, 1]
const n = polygon.length
for (let i = 0; i < n; i++) {
const [px, pz] = polygon[i]!
const [qx, qz] = polygon[(i + 1) % n]!
const ex = qx - px
const ez = qz - pz
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at
const t = ((px - ax) * ez - (pz - az) * ex) / denom
const s = ((px - ax) * dz - (pz - az) * dx) / denom
if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t)
}
ts.sort((a, b) => a - b)
const inside: LengthInterval[] = []
for (let i = 1; i < ts.length; i++) {
const t0 = ts[i - 1]!
const t1 = ts[i]!
if (t1 - t0 < 1e-9) continue
const tm = (t0 + t1) / 2
const mx = ax + dx * tm
const mz = az + dz * tm
const midpointInside = pointOnPolygonBoundary(mx, mz, polygon)
? includeBoundary
: pointInPolygon(mx, mz, polygon)
if (midpointInside) inside.push([t0, t1])
}
return inside
}
function polylineLength(points: Array<{ x: number; y: number }>): number {
let total = 0
for (let i = 1; i < points.length; i++) {
total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y)
}
return total
}
/**
* Inside sub-intervals of a polyline against a polygon, in cumulative
* arc-length units from the polyline start (merged, disjoint). Boundary
* contact counts as inside for slab support (walls sit exactly on slab
* edges — see ON_BOUNDARY_EPSILON above); hole callers pass
* `includeBoundary: false` so a wall running along a stairwell hole's
* rim keeps the rim's support.
*/
function polylineInsideIntervals(
points: Array<{ x: number; y: number }>,
polygon: Array<[number, number]>,
includeBoundary = true,
): LengthInterval[] {
const intervals: LengthInterval[] = []
let offset = 0
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!
const b = points[i]!
const segmentLength = Math.hypot(b.x - a.x, b.y - a.y)
if (segmentLength < 1e-9) continue
for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) {
intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength])
}
offset += segmentLength
}
return mergeIntervals(intervals)
}
export type WallOverlapInput = {
start: [number, number]
end: [number, number]
curveOffset?: number
thickness?: number
}
// Minimum length of wall that must lie on/inside a slab polygon before the
// wall counts as overlapping it. Point contact (a perpendicular wall butting
// into a room's edge) clips to ~zero length and never reaches this, so such
// walls don't follow the slab's elevation.
const WALL_SLAB_MIN_OVERLAP = 0.05
/**
* Centerline of the wall plus its two face lines (centerline offset by
* ±halfThickness). The face lines catch walls whose centerline sits on or
* just outside the slab boundary but whose body reaches onto the slab —
* e.g. slab polygons drawn to the room's interior faces.
*/
function wallTestPolylines(
start: [number, number],
end: [number, number],
curveOffset: number,
halfThickness: number,
): Array<Array<{ x: number; y: number }>> {
const wallLike = { start, end, curveOffset }
if (curveOffset !== 0 && isCurvedWall(wallLike)) {
const count = 16
const center: Array<{ x: number; y: number }> = []
const left: Array<{ x: number; y: number }> = []
const right: Array<{ x: number; y: number }> = []
for (let i = 0; i <= count; i++) {
const frame = getWallCurveFrameAt(wallLike, i / count)
center.push(frame.point)
left.push({
x: frame.point.x + frame.normal.x * halfThickness,
y: frame.point.y + frame.normal.y * halfThickness,
})
right.push({
x: frame.point.x - frame.normal.x * halfThickness,
y: frame.point.y - frame.normal.y * halfThickness,
})
}
return halfThickness > 0 ? [center, left, right] : [center]
}
const center = [
{ x: start[0], y: start[1] },
{ x: end[0], y: end[1] },
]
const dx = end[0] - start[0]
const dz = end[1] - start[1]
const len = Math.hypot(dx, dz)
if (len < 1e-10 || halfThickness <= 0) return [center]
const nx = (-dz / len) * halfThickness
const nz = (dx / len) * halfThickness
return [
center,
[
{ x: start[0] + nx, y: start[1] + nz },
{ x: end[0] + nx, y: end[1] + nz },
],
[
{ x: start[0] - nx, y: start[1] - nz },
{ x: end[0] - nx, y: end[1] - nz },
],
]
}
/**
* Test whether a wall overlaps a slab polygon along a segment of its length.
*
* The wall's centerline and both face lines are clipped against the polygon;
* the wall overlaps when the longest clipped inside-or-on-boundary length
* exceeds a threshold (5cm, halved for very short walls). Because interval
* midpoints classify "on the boundary" as inside explicitly (never by
* ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves
* identically on every side of the slab.
*
* A wall that only touches the polygon at a point — a perpendicular wall
* butting into a room's edge, or a corner-to-corner touch — clips to ~zero
* length and does NOT overlap.
*/
export function wallOverlapsPolygon(
startOrWall: [number, number] | WallOverlapInput,
endOrPolygon: [number, number] | Array<[number, number]>,
polygonArg?: Array<[number, number]>,
): boolean {
// Two call shapes:
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
let start: [number, number]
let end: [number, number]
let polygon: Array<[number, number]>
let curveOffset = 0
let thickness = DEFAULT_WALL_THICKNESS
if (Array.isArray(startOrWall)) {
start = startOrWall as [number, number]
end = endOrPolygon as [number, number]
polygon = polygonArg as Array<[number, number]>
} else {
start = startOrWall.start
end = startOrWall.end
curveOffset = startOrWall.curveOffset ?? 0
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
polygon = endOrPolygon as Array<[number, number]>
}
return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon)
}
/**
* {@link wallOverlapsPolygon} with the slab's stored holes subtracted from
* the covered length: a wall whose band only reaches the polygon inside a
* hole does not overlap. Hole boundaries keep coverage (rim convention —
* see {@link computeWallSlabSupport}). Polygon boundary contact counts as
* covered, so a wall sitting exactly on a slab edge resolves identically
* on every side of the slab. Pure.
*/
export function wallOverlapsSlabFootprint(
wallLike: WallOverlapInput,
polygon: Array<[number, number]>,
holes?: ReadonlyArray<Array<[number, number]>>,
): boolean {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const centerLength = polylineLength(polylines[0]!)
if (centerLength < 1e-9) return false
let overlap = 0
for (const line of polylines) {
let intervals = polylineInsideIntervals(line, polygon)
for (const hole of holes ?? []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
overlap = Math.max(overlap, intervalsLength(intervals))
}
const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5))
return overlap >= threshold
}
/**
* Tolerance for the pointer-decided support cap: a slab still counts as
* "the surface you're pointing at (or below)" when its walking surface is
* within this many meters ABOVE the pointed elevation. Absorbs elevation
* noise between the ray hit and slab tops without letting a deck hanging
* clearly above the hit point capture the election. Defined here (rather
* than in the spatial-grid manager, which re-exports it) so the wall
* election below can honour the same cap without an import cycle.
*/
export const SUPPORT_ELEVATION_EPSILON = 0.05
// A slab elevation must support at least this fraction of the wall's
// length before it can dictate the wall's base. Below majority, a raised
// slab reaching one endpoint would hoist the whole wall off the floor
// that actually carries it.
const WALL_SLAB_SUPPORT_MAJORITY = 0.5
// Slabs whose elevations differ by less than this pool their support:
// a wall shared between two rooms' slabs is covered roughly half by
// each, and must still follow their common elevation.
const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4
/**
* Base elevation for a wall, decided by which slabs actually SUPPORT it.
*
* Support is measured as covered length: the wall's centerline and face
* lines are clipped against each slab's RENDERED footprint
* (`getRenderableSlabPolygon` with the level walls + siblings, not the
* stored polygon — legacy polygons stored at wall faces or with old
* baked offsets fall short of the wall body, but their band-adopted
* rendered edge reaches the wall's outer face) minus the slab's stored
* holes (holes are data, never render-offset). A slab supporting less
* than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point
* contact, endpoint grazes).
*
* Same-elevation slabs pool their coverage. `elevation` preserves the
* existing wall-relative origin: the highest elevation covering at
* least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered
* elevation when none reaches majority. `baseElevation` only fills down
* where a lower support remains exposed on a wall face after higher,
* overlapping support is accounted for. Coincident floor/platform slabs
* therefore keep the wall on the platform, while slabs on opposite wall
* sides bridge correctly. A slab touching only one endpoint never enters
* either result. Pure;
* exported for tests.
*/
export type WallSlabSupport = {
/** Existing wall-relative floor elevation used by hosted children and wall height. */
elevation: number
/** Slab whose elevation won the election, or null when the wall has no support. */
electedSlabId: string | null
/** Lowest exposed adjacent support; wall geometry fills down to this elevation. */
baseElevation: number
/** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */
baseSegments: WallSlabSupportSegment[]
}
export type WallSlabSupportSegment = {
start: number
end: number
elevation: number
}
/**
* `preferredSlabId` is a persisted support host (`wall.supportSlabId`):
* while that slab is still in the candidate set (still overlaps the wall
* band with enough covered length), the elected `elevation` is pinned to
* it instead of the majority/best-coverage election. `baseSegments` /
* `baseElevation` (fill-down) still derive from ALL supporting slabs
* unchanged. A preferred slab that no longer qualifies is silently
* ignored — deliberately never cleared here, so the host resumes if the
* slab's polygon returns (only slab deletion strips the stored field).
*
* `maxElevation` is the pointer-decided support cap (level-local Y, same
* semantics as the item election): when set, elevation groups whose
* walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON`
* are excluded from the majority/best election — a deck hanging above the
* surface the cursor ray actually hit never captures the elected base.
* `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and
* an explicit `preferredSlabId` still wins over the cap.
*/
export function computeWallSlabSupport(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
preferredSlabId?: string | null,
maxElevation?: number | null,
): WallSlabSupport {
const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike
const halfThickness = Math.max(thickness / 2, 0)
const polylines = wallTestPolylines(start, end, curveOffset, halfThickness)
const polylineLengths = polylines.map(polylineLength)
const wallLength = polylineLengths[0]!
if (wallLength < 1e-9) {
return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] }
}
const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5))
type ElevationGroup = {
elevation: number
slabIds: string[]
perPolyline: LengthInterval[][]
}
const groups: ElevationGroup[] = []
let preferredElevation: number | null = null
let preferredElectedSlabId: string | null = null
for (const slab of slabs) {
if (slab.polygon.length < 3) continue
const renderedPolygon = getRenderableSlabPolygon(slab, {
walls: levelWalls,
siblingSlabs: slabs.filter((other) => other.id !== slab.id),
})
let supported = 0
const perPolyline = polylines.map((line) => {
let intervals = polylineInsideIntervals(line, renderedPolygon)
for (const hole of slab.holes || []) {
if (intervals.length === 0) break
if (hole.length < 3) continue
intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false))
}
supported = Math.max(supported, intervalsLength(intervals))
return intervals
})
if (supported < minSupport) continue
const elevation = slab.elevation ?? 0.05
if (preferredSlabId != null && slab.id === preferredSlabId) {
preferredElevation = elevation
preferredElectedSlabId = slab.id
}
let group = groups.find(
(candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON,
)
if (!group) {
group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) }
groups.push(group)
}
group.slabIds.push(slab.id)
for (let i = 0; i < perPolyline.length; i++) {
group.perPolyline[i]!.push(...perPolyline[i]!)
}
}
type EvaluatedGroup = ElevationGroup & {
coverage: number
mergedPerPolyline: LengthInterval[][]
}
const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => {
let coverage = 0
const mergedPerPolyline = group.perPolyline.map(mergeIntervals)
for (let i = 0; i < group.perPolyline.length; i++) {
const lineLength = polylineLengths[i]!
if (lineLength < 1e-9) continue
coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength)
}
return { ...group, coverage, mergedPerPolyline }
})
const electableGroups =
maxElevation == null
? evaluatedGroups
: evaluatedGroups.filter(
(group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON,
)
let majorityElevation = Number.NEGATIVE_INFINITY
let bestElevation = Number.NEGATIVE_INFINITY
let bestCoverage = -1
for (const group of electableGroups) {
if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) {
majorityElevation = Math.max(majorityElevation, group.elevation)
}
if (
group.coverage > bestCoverage + 1e-6 ||
(Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation)
) {
bestCoverage = group.coverage
bestElevation = group.elevation
}
}
const elevation =
preferredElevation !== null
? preferredElevation
: majorityElevation !== Number.NEGATIVE_INFINITY
? majorityElevation
: bestElevation === Number.NEGATIVE_INFINITY
? 0
: bestElevation
const electedSlabId =
preferredElectedSlabId ??
electableGroups
.find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON)
?.slabIds.slice()
.sort()[0] ??
null
const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => {
const lineLength = polylineLengths[polylineIndex]!
if (lineLength < 1e-9) return []
return group.mergedPerPolyline[polylineIndex]!.map(
([intervalStart, intervalEnd]) =>
[intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval,
)
}
const normalizedByGroup = evaluatedGroups.map((group) => ({
elevation: group.elevation,
perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)),
}))
const breakpoints = [0, 1]
for (const group of normalizedByGroup) {
for (const intervals of group.perPolyline) {
for (const [intervalStart, intervalEnd] of intervals) {
breakpoints.push(intervalStart, intervalEnd)
}
}
}
breakpoints.sort((left, right) => left - right)
const uniqueBreakpoints = breakpoints.filter(
(value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7,
)
const highestAt = (polylineIndex: number, t: number) => {
let highest = Number.NEGATIVE_INFINITY
for (const group of normalizedByGroup) {
if (
group.perPolyline[polylineIndex]?.some(
([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7,
)
) {
highest = Math.max(highest, group.elevation)
}
}
return highest
}
const baseSegments: WallSlabSupportSegment[] = []
for (let index = 1; index < uniqueBreakpoints.length; index++) {
const start = uniqueBreakpoints[index - 1]!
const end = uniqueBreakpoints[index]!
if (end - start < 1e-7) continue
const midpoint = (start + end) / 2
const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY
const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY
const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite)
const segmentElevation =
faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0)
const previous = baseSegments[baseSegments.length - 1]
if (
previous &&
Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON
) {
previous.end = end
} else {
baseSegments.push({ start, end, elevation: segmentElevation })
}
}
if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation })
const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation))
return { elevation, electedSlabId, baseElevation, baseSegments }
}
export function computeWallSlabElevation(
wallLike: WallOverlapInput,
slabs: readonly SlabNode[],
levelWalls: WallNode[],
): number {
return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation
}
@@ -9,8 +9,10 @@ import type {
StairSegmentNode,
SurfaceHoleMetadata,
} from '../../schema'
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
import { resolveCeilingHeight } from '../../services/level-height'
import { getLevelElevations } from '../../services/storey'
import { computeSegmentTransforms, rotateXZ } from './stair-footprint'
import { resolveStairTotalRise } from './stair-rise'
type SegmentTransform = {
position: [number, number, number]
@@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface(
const layouts = getStraightStairLayouts(stair, nodes)
if (layouts.length === 0) return []
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1)
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0)
const openingRects: AxisAlignedRect[] = []
@@ -605,17 +607,16 @@ function getTargetSlabElevationForStair(
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const slabLevel = getLevelNumber(slabLevelId, nodes)
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const slabElevation = elevations.get(slabLevelId)
if (fromLevel === undefined || slabLevel === undefined) {
if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) {
return slab.elevation ?? 0.05
}
return (
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(slab.elevation ?? 0.05) -
(stair.position[1] ?? 0)
slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0)
)
}
@@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair(
nodes: Record<string, AnyNode>,
) {
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
const fromLevel = getLevelNumber(fromLevelId, nodes)
const ceilingLevel = getLevelNumber(ceilingLevelId, nodes)
const elevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined
const ceilingElevation = elevations.get(ceilingLevelId)
if (fromLevel === undefined || ceilingLevel === undefined) {
return ceiling.height ?? DEFAULT_WALL_HEIGHT
const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record<AnyNodeId, AnyNode>)
if (
!fromElevation ||
!ceilingElevation ||
fromElevation.buildingId !== ceilingElevation.buildingId
) {
return ceilingHeight
}
return (
(ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
(ceiling.height ?? DEFAULT_WALL_HEIGHT) -
(stair.position[1] ?? 0)
)
return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0)
}
function shouldApplyStairToSlab(
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef } from 'react'
import type { AnyNode } from '../../schema'
import type { AnyNode, AnyNodeId } from '../../schema'
import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control'
import useLiveNodeOverrides from '../../store/use-live-node-overrides'
import useLiveTransforms from '../../store/use-live-transforms'
@@ -12,6 +12,7 @@ import {
hasLiveStairOpeningInputs,
} from './stair-opening-preview'
import { syncAutoStairOpenings } from './stair-opening-sync'
import { syncStairRises } from './stair-rise'
function isOpeningRelevantNode(node: AnyNode | undefined) {
return (
@@ -47,7 +48,7 @@ export const StairOpeningSystem = () => {
const previewControllerRef = useRef(createSurfaceOpeningPreviewController())
useEffect(() => {
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }>) => {
if (updates.length === 0) return
syncingAutoOpeningsRef.current = true
pauseSceneHistory(useScene)
@@ -103,14 +104,40 @@ export const StairOpeningSystem = () => {
)
}
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
refreshLivePreview()
const runAutoSync = () => {
// Rise first: straight stairs converge their flight heights to the
// resolved rise (level height or deck elevation), and the opening pass
// reads those segment heights — so it must run against the post-rise
// nodes.
applyUpdates(syncStairRises(useScene.getState().nodes))
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
}
let disposed = false
let autoSyncQueued = false
const scheduleAutoSync = () => {
if (autoSyncQueued) return
autoSyncQueued = true
// One microtask later so every other scene-store listener for the
// triggering transition (and, at mount, the editor's spatial-grid
// init) runs first — the spatial-grid sync in particular. The
// deck-attached rise elects the stair's floor-stack base elevation
// through the spatial grid; syncing before the grid listener would
// rescale flights against the pre-transition slab state.
queueMicrotask(() => {
autoSyncQueued = false
if (disposed) return
runAutoSync()
refreshLivePreview()
})
}
scheduleAutoSync()
const unsubscribeScene = useScene.subscribe((state, prevState) => {
if (syncingAutoOpeningsRef.current) return
if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return
applyUpdates(syncAutoStairOpenings(state.nodes))
refreshLivePreview()
scheduleAutoSync()
})
const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => {
@@ -122,6 +149,7 @@ export const StairOpeningSystem = () => {
})
return () => {
disposed = true
unsubscribeScene()
unsubscribeLiveTransforms()
unsubscribeLiveOverrides()
@@ -0,0 +1,465 @@
import { beforeEach, describe, expect, it } from 'bun:test'
import { z } from 'zod'
import {
GROUND_SUPPORT_ID,
getFloorPlacedElevation,
} from '../../hooks/spatial-grid/floor-placed-elevation'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
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 { resolveStairTotalRise, syncStairRises } from './stair-rise'
// The deck branch elects the stair's floor-stack base through the node
// registry + spatial grid singletons — reset them so tests are hermetic
// (base elects 0 unless a test registers a stair footprint and slabs).
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
})
function buildScene(levelHeight: number | undefined, totalRise: number | undefined) {
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
...(totalRise !== undefined ? { totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
children: ['stair_1'],
...(levelHeight !== undefined ? { height: levelHeight } : {}),
})
return { stair, nodes: { level_1: level, stair_1: stair } }
}
function makeDeck(elevation: number, polygon?: Array<[number, number]>) {
return SlabNode.parse({
id: 'slab_deck',
type: 'slab',
polygon: polygon ?? [
[0, 0],
[2, 0],
[2, 2],
[0, 2],
],
elevation,
thickness: 0.05,
})
}
function buildDeckScene(options: {
deckElevation: number
deckPolygon?: Array<[number, number]>
totalRise?: number
deckSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const deck = makeDeck(options.deckElevation, options.deckPolygon)
const segments = (options.segments ?? []).map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
deckSlabId: options.deckSlabId ?? deck.id,
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: 2.5,
children: ['stair_1', deck.id],
})
const nodes: Record<string, AnyNode> = {
level_1: level,
stair_1: stair,
[deck.id]: deck,
}
for (const segment of segments) nodes[segment.id] = segment
return { deck, stair, nodes }
}
function buildLevelSceneWithSegments(options: {
levelHeight: number
totalRise?: number
segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const segments = options.segments.map((segment) =>
StairSegmentNode.parse({
id: segment.id,
type: 'stair-segment',
segmentType: segment.segmentType,
width: 1,
length: 2,
height: segment.height,
stepCount: 8,
parentId: 'stair_1',
}),
)
const stair = StairNode.parse({
id: 'stair_1',
type: 'stair',
position: [0, 0, 0],
children: segments.map((segment) => segment.id),
...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}),
})
const level = LevelNode.parse({
id: 'level_1',
type: 'level',
level: 0,
height: options.levelHeight,
children: ['stair_1'],
})
const nodes: Record<string, AnyNode> = { level_1: level, stair_1: stair }
for (const segment of segments) nodes[segment.id] = segment
return { level, stair, nodes }
}
describe('resolveStairTotalRise', () => {
it('derives the rise from the containing level stored height when absent', () => {
const { stair, nodes } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(3.2)
})
it('tracks a storey height change without any stair write', () => {
const { stair, nodes } = buildScene(2.55, undefined)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.55)
const level = nodes.level_1
if (level.type !== 'level') throw new Error('expected level')
const updated = { ...nodes, level_1: { ...level, height: 3.0 } }
expect(resolveStairTotalRise(stair, updated)).toBe(3.0)
})
it('prefers an explicit totalRise over the storey height', () => {
const { stair, nodes } = buildScene(3.2, 2.5)
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
it('falls back to the default when the stair has no containing level', () => {
const { stair } = buildScene(3.2, undefined)
expect(resolveStairTotalRise(stair, {})).toBe(2.5)
})
it('derives the rise from the attached deck elevation', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
expect(resolveStairTotalRise(stair, nodes)).toBe(1.25)
})
it('tracks a deck elevation change without any stair write', () => {
const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 })
const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } }
expect(resolveStairTotalRise(stair, updated)).toBe(1.6)
})
it('prefers an explicit totalRise over the attached deck', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('falls through a stale deckSlabId to the storey height silently', () => {
const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.5)
})
})
describe('syncStairRises', () => {
it('writes the deck elevation into a single flight segment', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }])
})
it('is a no-op when the flights already match the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('scales multiple flights proportionally and leaves landings alone', () => {
const { nodes } = buildDeckScene({
deckElevation: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('distributes an explicit custom rise instead of the deck elevation', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.25,
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }])
})
it('falls a stale deckSlabId back to the storey height', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a stale-deck stair with an explicit rise untouched', () => {
const { nodes } = buildDeckScene({
deckElevation: 1.6,
deckSlabId: 'slab_gone',
totalRise: 2.0,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(nodes)).toEqual([])
})
it('converges a level-following straight stair to the storey height', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }],
})
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('converges a level-following stair after a storey height change', () => {
const scene = buildLevelSceneWithSegments({
levelHeight: 2.5,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }])
})
it('rescales level-following flights proportionally, landings untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.1,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } })
expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } })
})
it('converges back to the storey height after a deck detach', () => {
const scene = buildDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const { deckSlabId: _deckSlabId, ...detached } = scene.stair
const nodes = { ...scene.nodes, stair_1: detached as AnyNode }
expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }])
})
it('leaves a detached explicit-rise stair with hand-set segments untouched', () => {
const { nodes } = buildLevelSceneWithSegments({
levelHeight: 2.5,
totalRise: 2.0,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.9 },
{ id: 'sseg_2', segmentType: 'stair', height: 0.6 },
],
})
expect(syncStairRises(nodes)).toEqual([])
})
})
// The stair stands on a floor slab (the default 0.05 one, or whatever the
// floor-stack elects) — the deck-derived rise must be measured from that
// lifted base so the last step lands flush with the deck's walking surface.
describe('deck-attached rise with a floor-lifted base', () => {
const FLOOR_POLYGON: Array<[number, number]> = [
[-5, -5],
[5, -5],
[5, 5],
[-5, 5],
]
// Away from the stair footprint at the origin so the base election never
// sees the deck itself.
const AWAY_DECK_POLYGON: Array<[number, number]> = [
[8, 8],
[10, 8],
[10, 10],
[8, 10],
]
beforeEach(() => {
registerNode({
kind: 'stair',
schemaVersion: 1,
schema: z.object({ type: z.literal('stair') }) as never,
category: 'structure',
defaults: () => ({}) as never,
capabilities: {
floorPlaced: {
footprints: (node) => [
{
position: (node as StairNodeType).position,
dimensions: [1, 1, 2] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
},
],
},
},
} as AnyNodeDefinition)
})
function makeFloorSlab(elevation: number) {
return SlabNode.parse({
id: 'slab_floor',
type: 'slab',
polygon: FLOOR_POLYGON,
elevation,
thickness: 0.05,
})
}
function buildLiftedDeckScene(options: {
deckElevation: number
floorElevation?: number
totalRise?: number
supportSlabId?: string
segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }>
}) {
const floor = makeFloorSlab(options.floorElevation ?? 0.05)
const scene = buildDeckScene({
deckElevation: options.deckElevation,
deckPolygon: AWAY_DECK_POLYGON,
totalRise: options.totalRise,
segments: options.segments,
})
const stair = options.supportSlabId
? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair)
: scene.stair
const nodes: Record<string, AnyNode> = {
...scene.nodes,
stair_1: stair,
[floor.id]: floor,
}
spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1')
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
return { deck: scene.deck, floor, stair, nodes }
}
it('lands the last step flush: rise = deck elevation elected base', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 })
const base = getFloorPlacedElevation({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: 'level_1',
})
expect(base).toBeCloseTo(0.05)
const rise = resolveStairTotalRise(stair, nodes)
expect(rise).toBeCloseTo(1.2)
// Top surface = visual base + rise = the deck's walking surface, not 1.30.
expect(base + rise).toBeCloseTo(1.25)
})
it('rescales a flight converged under the old rule down to the flush rise', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }],
})
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2)
})
it('keeps the full deck elevation when the stair stands on bare ground', () => {
const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON })
spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1')
expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25)
})
it('lets an explicit totalRise win over the base-adjusted deck rise', () => {
const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 })
expect(resolveStairTotalRise(stair, nodes)).toBe(2.0)
})
it('re-converges to flush after a deck elevation change', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
expect(syncStairRises(scene.nodes)).toEqual([])
const movedDeck = { ...scene.deck, elevation: 1.6 }
const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode }
spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55)
})
it('re-converges to flush after the base slab elevation changes', () => {
const scene = buildLiftedDeckScene({
deckElevation: 1.25,
segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }],
})
const movedFloor = { ...scene.floor, elevation: 0.3 }
const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode }
spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1')
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(1)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95)
})
it('rescales flights proportionally from the lifted base, landings untouched', () => {
const { nodes } = buildLiftedDeckScene({
deckElevation: 2.15,
segments: [
{ id: 'sseg_1', segmentType: 'stair', height: 0.5 },
{ id: 'sseg_2', segmentType: 'landing', height: 0.1 },
{ id: 'sseg_3', segmentType: 'stair', height: 0.5 },
],
})
// Target flight rise = 2.15 0.05 (base) 0.1 (landing) = 2.0 → 1.0 each.
const updates = syncStairRises(nodes)
expect(updates).toHaveLength(2)
expect(updates[0]?.id).toBe('sseg_1' as never)
expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0)
expect(updates[1]?.id).toBe('sseg_3' as never)
expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0)
})
it('honors a persisted ground host over the floor slab election', () => {
const { stair, nodes } = buildLiftedDeckScene({
deckElevation: 1.25,
supportSlabId: GROUND_SUPPORT_ID,
})
expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25)
})
})
@@ -0,0 +1,90 @@
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'
export function resolveStairTotalRise(stair: StairNode, nodes: Record<string, AnyNode>): number {
if (stair.totalRise !== undefined) return stair.totalRise
const level = Object.values(nodes).find(
(node) => node.type === 'level' && node.children.includes(stair.id),
)
if (stair.deckSlabId) {
const deck = nodes[stair.deckSlabId]
// The deck's `elevation` IS its walking surface (level-local), but the
// stair's own base may be lifted onto a floor slab by the floor-stack
// (`FloorElevationSystem` / `syncStairGroupElevation` put the group at
// `position[1] + elected slab elevation`). The rise is measured from
// that base, so subtract it — electing the base exactly the way the
// visual systems do (persisted `supportSlabId` honored, uncapped
// election otherwise) keeps base + rise landing precisely on the deck's
// walking surface. A stale reference (deck gone) falls through to the
// level-derived rise.
if (deck?.type === 'slab') {
const baseElevation = getFloorStackedPosition({
node: stair,
nodes,
position: stair.position,
rotation: stair.rotation,
levelId: level?.id ?? null,
})[1]
return (deck.elevation ?? 0.05) - baseElevation
}
}
return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT
}
const RISE_SYNC_EPSILON = 1e-4
/**
* Keeps straight stairs' flight segments in step with the resolved rise.
* Straight-stair geometry derives from per-segment heights (not from
* `resolveStairTotalRise`), so level-height and deck-elevation changes must
* write through to the flight segments — curved/spiral stairs read the
* resolved rise directly and need no sync.
*
* Scope: stairs whose total the system owns — follows-mode stairs (absent
* `totalRise`, tracking their level or their deck) and deck-attached stairs
* (an explicit rise converges to the typed value). A detached stair with an
* explicit `totalRise` is the one place hand-edited segment chains are
* legitimate, so it is never touched. Flight heights scale proportionally
* (landings keep theirs); returns `updateNodes` patches, empty when every
* stair is already in step.
*/
export function syncStairRises(
nodes: Record<string, AnyNode>,
): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
for (const node of Object.values(nodes)) {
if (node.type !== 'stair' || node.stairType !== 'straight') continue
const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined
if (node.totalRise !== undefined && deck?.type !== 'slab') continue
const segments = (node.children ?? [])
.map((childId) => nodes[childId])
.filter((child): child is StairSegmentNode => child?.type === 'stair-segment')
const flights = segments.filter((segment) => segment.segmentType === 'stair')
if (flights.length === 0) continue
const landingRise = segments
.filter((segment) => segment.segmentType !== 'stair')
.reduce((sum, segment) => sum + segment.height, 0)
const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0)
const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise
if (targetFlightRise <= 0) continue
if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue
for (const flight of flights) {
const height =
flightRise > RISE_SYNC_EPSILON
? flight.height * (targetFlightRise / flightRise)
: targetFlightRise / flights.length
updates.push({ id: flight.id as AnyNodeId, data: { height } })
}
}
return updates
}
+1 -1
View File
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
}
}
function getWallArcData(wall: WallCurveLike) {
export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall)
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true,
parentId: 'level_test',
children: [],
assemblyLayers: [],
start,
end,
thickness: 0.1,
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top'
describe('resolveWallTop', () => {
test('explicit height on zero base keeps the stored top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('explicit height on raised base rides the base', () => {
expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1)
})
test('explicit height on sunken base keeps the absolute top', () => {
expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5)
})
test('plane-bound wall tops out at the storey plane regardless of base', () => {
expect(resolveWallTop({}, 3, 0)).toBe(3)
expect(resolveWallTop({}, 3, 0.6)).toBe(3)
expect(resolveWallTop({}, 3, -0.4)).toBe(3)
})
})
describe('resolveWallEffectiveHeight', () => {
test('explicit on raised base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5)
})
test('explicit on zero base extrudes the stored height', () => {
expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5)
})
test('plane-bound on raised base gets shorter, never taller', () => {
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4)
expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3)
})
test('plane-bound on zero base spans the full storey', () => {
expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3)
})
test('plane-bound on sunken base fills down while the top stays at the plane', () => {
expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4)
})
})
@@ -0,0 +1,53 @@
import type { WallNode } from '../../schema/nodes/wall'
/**
* Minimum wall body height in meters. Governs both the wall height
* arrow's lower drag bound and the slab-elevation clamp: a slab may not
* rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall
* elects it as its base, or the wall's extrusion (plane minus base)
* would collapse below this minimum.
*/
export const MIN_WALL_HEIGHT = 0.5
/**
* Wall-top inversion (vertical building model): a wall with no stored
* `height` is plane-bound — its top sits at the storey plane (level-local
* Y = the level's stored height), so a slab lifting the wall's base makes
* the wall shorter, never taller, and no gap can open at the top of a
* level. A wall WITH `height` is an explicit exception (half wall,
* parapet) and keeps the legacy semantics: the top rides a raised elected
* base (`electedBase + height`), while a zero or sunken base leaves the
* top at `height` (the legacy negative-slab constraint).
*
* Returns the top in level-local Y (same frame as `electedBase`).
*/
export function resolveWallTop(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
if (wall.height == null) return storeyHeight
return electedBase > 0 ? electedBase + wall.height : wall.height
}
/**
* Extruded height of the wall body: {@link resolveWallTop} minus the
* elected base. Base convention: the elected slab-support elevation itself
* — the viewer computes `effectiveBaseElevation = min(baseElevation,
* slabElevation)` and defaults `baseElevation` to the elected elevation,
* so with only the election in hand the two coincide. Fill-down below the
* elected base (`baseSegments`) is a geometry detail the extruder handles
* separately and never changes where the top sits.
*
* Equivalently: the wall-local Y of the wall's top, measured from the wall
* mesh origin (which sits at `electedBase`). May be non-positive when a
* slab reaches the storey plane; callers own the degenerate-geometry
* policy.
*/
export function resolveWallEffectiveHeight(
wall: Pick<WallNode, 'height'>,
storeyHeight: number,
electedBase: number,
): number {
return resolveWallTop(wall, storeyHeight, electedBase) - electedBase
}
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test'
import type { CollectionId } from '../schema/collections'
import type { AnyNode, AnyNodeId } from '../schema/types'
import { forkSceneGraph, type SceneGraph } from './clone-scene-graph'
import {
cloneLevelSubtree,
cloneSceneGraph,
forkSceneGraph,
type SceneGraph,
} from './clone-scene-graph'
function makeNode(id: string, type: string, extra: Record<string, unknown> = {}): AnyNode {
return {
@@ -71,3 +76,163 @@ describe('forkSceneGraph', () => {
expect(forked.installedPlugins).toEqual(['pascal:trees'])
})
})
describe('construction-dimension clone references', () => {
function sceneWithControlledDimensions(): SceneGraph {
const site = makeNode('site_1', 'site', { children: ['level_1'] })
const level = makeNode('level_1', 'level', {
parentId: 'site_1',
children: ['construction-dimension_foundation', 'construction-dimension_floor'],
})
const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
name: 'Foundation controller',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: null,
})
const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
name: 'Floor dependent',
parentId: 'level_1',
anchors: [
[0, 0, 0],
[4, 0, 0],
],
controllingDimensionId: controller.id,
})
return {
nodes: {
[site.id]: site,
[level.id]: level,
[controller.id]: controller,
[dependent.id]: dependent,
},
rootNodeIds: [site.id],
}
}
test('remaps controller IDs in whole-scene clones', () => {
const cloned = cloneSceneGraph(sceneWithControlledDimensions())
const dimensions = Object.values(cloned.nodes).filter(
(node) => node.type === 'construction-dimension',
)
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
test('remaps controller IDs in level-subtree clones', () => {
const scene = sceneWithControlledDimensions()
const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
const controller = dimensions.find((node) => node.name === 'Foundation controller')
const dependent = dimensions.find((node) => node.name === 'Floor dependent')
expect(controller?.type).toBe('construction-dimension')
expect(dependent?.type).toBe('construction-dimension')
if (
controller?.type === 'construction-dimension' &&
dependent?.type === 'construction-dimension'
) {
expect(dependent.controllingDimensionId).toBe(controller.id)
}
})
})
describe('drawing-sheet clone references', () => {
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
const level = makeNode('level_main', 'level')
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
generalNoteSetIds: [],
generalNoteSets: [],
generalNotes: [],
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
keyedNoteInstances: [
{
id: 'keyed-note-instance_a',
definitionId: 'keyed-note_a',
placedViewId: 'drawing-view_main',
position: [1, 1],
},
],
keyedNoteLegend: [],
documentMarkers: [],
schedules: [],
})
const cloned = cloneSceneGraph({
nodes: { [level.id]: level, [sheet.id]: sheet },
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
})
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
expect(clonedLevel).toBeDefined()
expect(clonedSheet?.type).toBe('drawing-sheet')
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
clonedSheet.keyedNoteDefinitions[0]?.id,
)
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
}
})
})
describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const cloned = cloneSceneGraph({
nodes: {
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: item,
},
rootNodeIds: ['level_1' as AnyNodeId],
})
const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')!
const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')!
expect(clonedSlab.id).not.toBe('slab_1')
expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id)
})
test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] })
const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' })
const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' })
const external = makeNode('item_2', 'item', {
parentId: 'level_1',
supportSlabId: 'slab_external',
})
const { clonedNodes, idMap } = cloneLevelSubtree(
{
['level_1' as AnyNodeId]: level,
['slab_1' as AnyNodeId]: slab,
['item_1' as AnyNodeId]: hosted,
['item_2' as AnyNodeId]: external,
},
'level_1' as AnyNodeId,
)
const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))!
const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))!
expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!)
expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external')
})
})
+50 -3
View File
@@ -1,7 +1,12 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode>
@@ -44,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -85,9 +90,33 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
) as string | undefined
}
// Remap supportSlabId (persisted slab-support hosts). The 'ground'
// sentinel is not a node id — keep it as-is.
if (
'supportSlabId' in clonedNode &&
typeof clonedNode.supportSlabId === 'string' &&
clonedNode.supportSlabId !== GROUND_SUPPORT_ID
) {
;(clonedNode as Record<string, unknown>).supportSlabId = idMap.get(
clonedNode.supportSlabId,
) as string | undefined
}
if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') {
;(clonedNode as Record<string, unknown>).deckSlabId = idMap.get(clonedNode.deckSlabId) as
| string
| undefined
}
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
if (clonedNode.type === 'construction-dimension') {
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
}
if (clonedNode.type === 'drawing-sheet') {
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
}
clonedNodes[newId] = clonedNode
}
@@ -202,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself
@@ -240,9 +269,27 @@ export function cloneLevelSubtree(
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
}
// Remap supportSlabId when the host slab is inside the cloned subtree;
// preserve it otherwise (like wallId, the reference may point outside).
if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') {
;(cloned as Record<string, unknown>).supportSlabId =
idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId
}
if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') {
;(cloned as Record<string, unknown>).deckSlabId =
idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId
}
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (cloned.type === 'construction-dimension') {
cloned = remapConstructionDimensionReferences(cloned, idMap)
}
if (cloned.type === 'drawing-sheet') {
cloned = remapDrawingSheetReferences(cloned, idMap)
}
clonedNodes.push(cloned)
}
+4 -2
View File
@@ -40,16 +40,16 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0",
"blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"svg2pdf.js": "^2.7.0",
"pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6",
@@ -59,8 +59,10 @@
"@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*",
"@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
"@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
const metricNotation = useViewer((s) => s.metricNotation)
const ctx = useFloorplanRender()
if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
const distLabel = formatMeasurement(distMeters, unit)
const distLabel = formatMeasurement(distMeters, unit, metricNotation)
const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
const draftLevelId = useMeasurementDraft((state) => state.levelId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
useEffect(() => {
if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
screenUpright: false,
text: formatLinearMeasurement(measurementDistance(start, end), unit),
text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
}
} else if (kind === 'angle' && livePoints.length >= 3) {
const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
text:
kind === 'area'
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
: `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
}
}
} else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
(segmentStart[1] + segmentEnd[1]) / 2,
(segmentStart[2] + segmentEnd[2]) / 2,
],
text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
text: formatLinearMeasurement(
measurementDistance(segmentStart, segmentEnd),
unit,
metricNotation,
),
}
}
}
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
polygonPoints,
segmentLabel,
}
}, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
}, [
axisGuide,
baseNormal,
extrusionHeight,
hover,
kind,
metricNotation,
points,
stage,
unit,
vertexDrag,
])
if (smartActive) return <FloorplanQuickMeasureLayer />
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
text={`${hover.semantic.label}${
hover.semantic.length === null
? ''
: ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
: ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
}`}
textColor={labelText}
unitsPerPixel={unitsPerPixel}
@@ -0,0 +1,61 @@
'use client'
import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
import {
type FloorplanToolContext,
getFloorplanNodeExtension,
} from '../../lib/floorplan/floorplan-extension'
import useEditor from '../../store/use-editor'
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
if (!tool) return null
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
if (!loader) return null
const cached = lazyToolCache.get(loader)
if (cached) return cached
const component = lazy(loader)
lazyToolCache.set(loader, component)
return component
}
export function FloorplanRegisteredToolLayer() {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const toolDefaults = useEditor((state) =>
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
const metricNotation = useViewer((state) => state.metricNotation)
const sceneApi = useMemo(() => createSceneApi(useScene), [])
const selectNode = useCallback(
(id: Parameters<FloorplanToolContext['selectNode']>[0]) =>
useViewer.getState().setSelection({ selectedIds: [id] }),
[],
)
const finishTool = useCallback(() => {
useEditor.getState().setTool(null)
useEditor.getState().setMode('select')
}, [])
if (mode !== 'build') return null
const Tool = registeredFloorplanTool(tool)
return Tool ? (
<Suspense fallback={null}>
<Tool
activeLevelId={activeLevelId}
finishTool={finishTool}
gridSnapStep={gridSnapStep}
metricNotation={metricNotation}
sceneApi={sceneApi}
selectNode={selectNode}
toolDefaults={toolDefaults}
unit={unit}
/>
</Suspense>
) : null
}
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref'
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
* Walls are excluded — their move is reached via the side-arrow
* handles emitted from `def.floorplan`, not via a menu button.
* - Curve (wall only): enters curve reshape mode. The selected wall's
* midpoint curve handle remains visible so it can be dragged in plan.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
* Hidden while in a move state (so we don't show buttons over a ghost).
* Hidden while moving or curving so the menu does not compete with the active affordance.
*/
export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion()
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
) as AnyNodeId | undefined
const movingNode = useMovingNode()
const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
// Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const canCurve = useScene((s) => {
if (!selectedId) return false
const selectedNode = s.nodes[selectedId]
if (!selectedNode) return false
const definition = nodeRegistry.get(selectedNode.type)
const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
return (
!!definition?.floorplanAffordances?.curve &&
!!canCurveNode?.({
node: selectedNode as never,
nodes: s.nodes,
})
)
})
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible =
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
isRegistryKind &&
def?.presentation?.actionMenu !== false &&
!movingNode &&
!isCurveReshape &&
isFloorplanHovered
const isWall = selectedKind === 'wall'
const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
)
}
const handleCurve = () => {
if (!canCurve) return
sfxEmitter.emit('sfx:item-pick')
useInteractionScope.getState().begin(curveReshapeScope(node.id))
}
const handleDuplicate = () => {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
>
<NodeActionMenu
onAddHole={canAddHole ? handleAddHole : undefined}
onCurve={canCurve ? handleCurve : undefined}
onDelete={canDelete ? handleDelete : undefined}
onDuplicate={canDuplicate ? handleDuplicate : undefined}
onMove={canMove ? handleMove : undefined}
@@ -0,0 +1,467 @@
import { describe, expect, test } from 'bun:test'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
collectAnnotationLayoutPreflightIssues,
floorplanAnnotationObstacleMode,
observeSvgAnnotationLayoutChanges,
polylineObstacleRectangles,
resolveAnnotationLabelRectangles,
} from './floorplan-annotation-layout'
describe('floorplanAnnotationObstacleMode', () => {
test('treats fixed annotation categories as layout obstacles', () => {
expect(
floorplanAnnotationObstacleMode({
kind: 'text',
x: 0,
y: 0,
text: 'BEDROOM',
fontSize: 0.18,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
}),
).toBe('bounds')
expect(
floorplanAnnotationObstacleMode({
kind: 'line',
x1: 0,
y1: 0,
x2: 1,
y2: 0,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
}),
).toBe('bounds')
expect(
floorplanAnnotationObstacleMode({
kind: 'polyline',
points: [
[0, 0],
[1, 0],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
}),
).toBe('outline')
})
})
describe('collectAnnotationLayoutPreflightIssues', () => {
test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => {
const issues = collectAnnotationLayoutPreflightIssues(
[
{
id: 'short',
x: 0,
y: 0,
width: 40,
height: 10,
priority: 10,
text: '1"',
labelPlacement: 'outside-end',
},
{
id: 'blocked',
x: 100,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'Blocked',
},
{
id: 'overlap-a',
x: 200,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'A',
},
{
id: 'overlap-b',
x: 205,
y: 0,
width: 40,
height: 10,
priority: 10,
text: 'B',
},
],
[
{ id: 'short', dx: 0, dy: 0, resolved: true },
{ id: 'blocked', dx: 0, dy: 0, resolved: true },
{ id: 'overlap-a', dx: 0, dy: 0, resolved: false },
{ id: 'overlap-b', dx: 0, dy: 0, resolved: true },
],
[{ x: 96, y: -2, width: 48, height: 14 }],
)
expect(issues.map((issue) => issue.kind)).toEqual([
'short-unreadable-segment',
'plan-geometry-conflict',
'unresolved-collision',
])
expect(issues.every((issue) => issue.severity === 'warning')).toBe(true)
})
})
describe('resolveAnnotationLabelRectangles', () => {
test('keeps the higher-priority label and moves the conflicting label', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 },
{ id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 },
])
expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 })
expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 })
expect(shifts.every((entry) => entry.resolved)).toBe(true)
})
test('keeps pinned labels at their drawing-view override and routes other labels around them', () => {
const shifts = resolveAnnotationLabelRectangles([
{
id: 'pinned',
x: 0,
y: 0,
width: 80,
height: 12,
priority: 1,
pinnedShift: { dx: 30, dy: 0 },
},
{ id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 },
])
expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({
id: 'pinned',
dx: 30,
dy: 0,
resolved: true,
})
expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({
dx: 0,
dy: 0,
})
})
test('does not move labels that are already clear', () => {
expect(
resolveAnnotationLabelRectangles([
{ id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 },
{ id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 },
]),
).toEqual([
{ id: 'left', dx: 0, dy: 0, resolved: true },
{ id: 'right', dx: 0, dy: 0, resolved: true },
])
})
test('preserves drawing order for labels on the same dimension string', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 },
{ id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 },
])
expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 })
expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 })
})
test('slides a colliding label along its dimension string before crossing tiers', () => {
const shifts = resolveAnnotationLabelRectangles([
{ id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 },
{
id: 'adjacent',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
tangentX: 1,
tangentY: 0,
},
])
expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true })
expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0)
})
test('tries a short dimension alternative before generic relocation', () => {
const shifts = resolveAnnotationLabelRectangles(
[
{
id: 'short-dimension',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
preferredShifts: [{ dx: 100, dy: 0 }],
},
],
[{ x: 0, y: 0, width: 40, height: 12 }],
)
expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }])
})
test('falls back to a third position when both short-dimension sides are blocked', () => {
const shifts = resolveAnnotationLabelRectangles(
[
{
id: 'short-dimension',
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10,
preferredShifts: [{ dx: 100, dy: 0 }],
},
],
[
{ x: 0, y: 0, width: 40, height: 12 },
{ x: 100, y: 0, width: 40, height: 12 },
],
)
expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true })
expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 })
expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 })
})
test('approximates diagonal outlines without blocking their full bounding box', () => {
expect(
polylineObstacleRectangles([
{ x: 0, y: 0 },
{ x: 4, y: 4 },
{ x: 8, y: 8 },
]),
).toEqual([
{ x: -1, y: -1, width: 6, height: 6 },
{ x: 3, y: 3, width: 6, height: 6 },
])
})
test('moves a dimension value clear of a fixed door-mark pill', () => {
const shifts = resolveAnnotationLabelRectangles(
[{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }],
[{ x: 100, y: 82, width: 48, height: 32 }],
)
expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })])
const shift = shifts[0]
expect(shift).not.toMatchObject({ dx: 0, dy: 0 })
expect(
94 + (shift?.dx ?? 0) + 42 + 6 <= 100 ||
148 + 6 <= 94 + (shift?.dx ?? 0) ||
88 + (shift?.dy ?? 0) + 16 + 6 <= 82 ||
114 + 6 <= 88 + (shift?.dy ?? 0),
).toBe(true)
})
test('finds separate nearby positions for a dense label cluster', () => {
const shifts = resolveAnnotationLabelRectangles(
Array.from({ length: 4 }, (_, index) => ({
id: `label-${index}`,
x: 0,
y: 0,
width: 40,
height: 12,
priority: 10 - index,
})),
)
expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4)
expect(shifts.every((entry) => entry.resolved)).toBe(true)
})
test('keeps a large dense label cluster readable', () => {
const rectangles = Array.from({ length: 12 }, (_, index) => ({
id: `label-${index}`,
x: 100 + (index % 3) * 4,
y: 100 + (index % 2) * 3,
width: 48 + (index % 4) * 8,
height: 14,
priority: 20 - index,
}))
const shifts = resolveAnnotationLabelRectangles(rectangles)
const placed = rectangles.map((rectangle) => {
const shift = shifts.find(({ id }) => id === rectangle.id)
return {
...rectangle,
x: rectangle.x + (shift?.dx ?? 0),
y: rectangle.y + (shift?.dy ?? 0),
}
})
expect(shifts.every((entry) => entry.resolved)).toBe(true)
for (let index = 0; index < placed.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) {
const left = placed[index]
const right = placed[otherIndex]
if (!left || !right) continue
const overlaps = !(
left.x + left.width + 6 <= right.x ||
right.x + right.width + 6 <= left.x ||
left.y + left.height + 6 <= right.y ||
right.y + right.height + 6 <= left.y
)
expect(overlaps).toBe(false)
}
}
})
test('resolves a construction-plan label set without blocking the view transition', () => {
const labels = Array.from({ length: 25 }, (_, index) => ({
id: `label-${index}`,
x: index % 5,
y: index % 7,
width: 80,
height: 20,
priority: 25 - index,
}))
const obstacles = Array.from({ length: 100 }, (_, index) => ({
x: (index % 10) * 2,
y: (index % 13) * 2,
width: 100,
height: 40,
}))
const startedAt = performance.now()
const shifts = resolveAnnotationLabelRectangles(labels, obstacles)
const elapsedMs = performance.now() - startedAt
expect(shifts).toHaveLength(labels.length)
expect(shifts.every((entry) => entry.resolved)).toBe(true)
expect(elapsedMs).toBeLessThan(500)
})
})
describe('observeSvgAnnotationLayoutChanges', () => {
test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
const OriginalMutationObserver = globalThis.MutationObserver
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined
let animationFrames: FrameRequestCallback[] = []
let disconnected = false
let observedOptions: MutationObserverInit | undefined
class FakeMutationObserver {
constructor(callback: MutationCallback) {
notify = callback
}
observe(_target: Node, options?: MutationObserverInit): void {
observedOptions = options
}
disconnect(): void {
disconnected = true
}
takeRecords(): MutationRecord[] {
return []
}
}
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
const flushAnimationFrame = () => {
const callbacks = animationFrames
animationFrames = []
for (const callback of callbacks) callback(0)
}
let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1
})
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(1)
expect(observedOptions).toMatchObject({
attributes: true,
childList: true,
subtree: true,
attributeFilter: expect.any(Array),
})
notify?.(
[
{
attributeName: 'style',
target: { closest: () => ({}) },
type: 'attributes',
} as unknown as MutationRecord,
],
{} as MutationObserver,
)
expect(layoutPasses).toBe(1)
stop()
expect(disconnected).toBe(true)
} finally {
globalThis.MutationObserver = OriginalMutationObserver
globalThis.requestAnimationFrame = originalRequestAnimationFrame
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
}
})
test('waits for a quiet frame instead of resolving on every mutation frame', () => {
const OriginalMutationObserver = globalThis.MutationObserver
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
let notify: MutationCallback | undefined
let animationFrames: FrameRequestCallback[] = []
class FakeMutationObserver {
constructor(callback: MutationCallback) {
notify = callback
}
observe(): void {}
disconnect(): void {}
takeRecords(): MutationRecord[] {
return []
}
}
globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
}) as typeof requestAnimationFrame
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
try {
const flushAnimationFrame = () => {
const callbacks = animationFrames
animationFrames = []
for (const callback of callbacks) callback(0)
}
let layoutPasses = 0
const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
layoutPasses += 1
})
for (let frame = 0; frame < 30; frame += 1) {
notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
flushAnimationFrame()
}
expect(layoutPasses).toBe(0)
flushAnimationFrame()
expect(layoutPasses).toBe(1)
stop()
} finally {
globalThis.MutationObserver = OriginalMutationObserver
globalThis.requestAnimationFrame = originalRequestAnimationFrame
globalThis.cancelAnimationFrame = originalCancelAnimationFrame
}
})
})
@@ -0,0 +1,695 @@
import type { FloorplanGeometry } from '@pascal-app/core'
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
export type AnnotationLabelRectangle = {
id: string
x: number
y: number
width: number
height: number
priority: number
text?: string
labelPlacement?: 'inside' | 'outside-end'
pinnedShift?: { dx: number; dy: number }
tangentX?: number
tangentY?: number
preferredShifts?: readonly { dx: number; dy: number }[]
}
export type AnnotationObstacleRectangle = Pick<
AnnotationLabelRectangle,
'x' | 'y' | 'width' | 'height'
>
export type AnnotationLabelShift = {
id: string
dx: number
dy: number
resolved: boolean
}
const LABEL_GAP_PX = 6
const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5
const OUTLINE_SAMPLE_SPACING_PX = 6
const OUTLINE_OBSTACLE_PADDING_PX = 1
const MAX_LABEL_SHIFT_CANDIDATES = 512
const PREFERRED_SHIFT_COST_STEP = 1_000_000
const COLLISION_GRID_CELL_SIZE_PX = 64
class AnnotationObstacleIndex {
private readonly cells = new Map<string, Set<AnnotationObstacleRectangle>>()
add(rectangle: AnnotationObstacleRectangle): void {
this.forEachCell(rectangle, 0, (key) => {
let cell = this.cells.get(key)
if (!cell) {
cell = new Set()
this.cells.set(key, cell)
}
cell.add(rectangle)
})
}
findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] {
const candidates = new Set<AnnotationObstacleRectangle>()
this.forEachCell(rectangle, LABEL_GAP_PX, (key) => {
for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate)
})
return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate))
}
private forEachCell(
rectangle: AnnotationObstacleRectangle,
padding: number,
visit: (key: string) => void,
): void {
const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX)
const maxCellX = Math.floor(
(rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX,
)
const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX)
const maxCellY = Math.floor(
(rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX,
)
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`)
}
}
}
export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
export type AnnotationLayoutOverrides = Readonly<Record<string, AnnotationLayoutOverride>>
export type AnnotationPreflightIssueKind =
| 'unresolved-collision'
| 'short-unreadable-segment'
| 'plan-geometry-conflict'
export type AnnotationPreflightIssue = {
id: string
kind: AnnotationPreflightIssueKind
severity: 'warning'
message: string
}
export function resolveAnnotationLabelRectangles(
rectangles: readonly AnnotationLabelRectangle[],
obstacles: readonly AnnotationObstacleRectangle[] = [],
): AnnotationLabelShift[] {
const occupied = new AnnotationObstacleIndex()
for (const obstacle of obstacles) occupied.add(obstacle)
const shifts = new Map<string, AnnotationLabelShift>()
const ordered = rectangles
.map((rectangle, order) => ({ order, rectangle }))
.sort(
(left, right) =>
Number(Boolean(right.rectangle.pinnedShift)) -
Number(Boolean(left.rectangle.pinnedShift)) ||
right.rectangle.priority - left.rectangle.priority ||
left.order - right.order,
)
for (const { rectangle } of ordered) {
const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied)
const shift = selected ?? { dx: 0, dy: 0 }
const resolved = rectangle.pinnedShift !== undefined || selected !== undefined
occupied.add({
x: rectangle.x + shift.dx,
y: rectangle.y + shift.dy,
width: rectangle.width,
height: rectangle.height,
})
shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved })
}
return rectangles.map(
(rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false },
)
}
export function resolveSvgAnnotationCollisions(
svg: SVGSVGElement,
options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
): AnnotationPreflightIssue[] {
const labels = Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
if (labels.length === 0) return []
for (const label of labels) {
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
label.removeAttribute('data-floorplan-layout-unresolved')
delete label.dataset.floorplanAnnotationLayoutDx
delete label.dataset.floorplanAnnotationLayoutDy
}
resetDimensionConnectors(svg)
const pinnedLocalById = new Map<string, { x: number; y: number }>()
const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => {
const bounds = label.getBoundingClientRect()
const matrix = label.getScreenCTM()
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
const override = options.layoutOverrides?.[id]
const pinnedLocal =
override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy)
? { dx: override.dx, dy: override.dy }
: undefined
if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy })
const pinnedShift =
pinnedLocal && matrix
? {
dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy,
dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy,
}
: undefined
const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0
const outsideStartLocalX = Number(
label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN,
)
const outsideStartLocalY = Number(
label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN,
)
const preferredShifts =
matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY)
? [
{
dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY,
dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY,
},
]
: undefined
return {
id,
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
text: label.textContent?.trim() ?? '',
labelPlacement:
label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside',
pinnedShift,
tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined,
preferredShifts,
}
})
const obstacles = Array.from(
svg.querySelectorAll<SVGGraphicsElement>('[data-floorplan-annotation-obstacle]'),
).flatMap(svgAnnotationObstacleRectangles)
const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles)
labels.forEach((label, index) => {
const rectangle = rectangles[index]
const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id)
if (!shift || (shift.dx === 0 && shift.dy === 0)) {
label.dataset.floorplanAnnotationLayoutDx = '0'
label.dataset.floorplanAnnotationLayoutDy = '0'
if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
return
}
const matrix = label.getScreenCTM()
if (!matrix) return
const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy)
label.dataset.floorplanAnnotationLayoutDx = String(local.x)
label.dataset.floorplanAnnotationLayoutDy = String(local.y)
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim())
const preferredShift = rectangle.preferredShifts?.[0]
const usedOutsideStart =
preferredShift !== undefined &&
Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5
if (usedOutsideStart) applyOutsideStartDimensionLine(label)
else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
showDimensionLeader(label, matrix, shift.dx, shift.dy)
}
if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
})
return preflightIssues
}
export function observeSvgAnnotationLayoutChanges(target: Node, onChange: () => void): () => void {
let scheduledFrame: number | null = null
let mutationVersion = 0
let observedVersion = 0
const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
const flushWhenSettled = () => {
if (observedVersion !== mutationVersion) {
observedVersion = mutationVersion
scheduledFrame = requestFrame(flushWhenSettled)
return
}
scheduledFrame = null
onChange()
}
const schedule = () => {
mutationVersion += 1
if (scheduledFrame !== null) return
observedVersion = mutationVersion - 1
scheduledFrame = requestFrame(flushWhenSettled)
}
const observer = new MutationObserver((mutations) => {
if (mutations.some(isAnnotationLayoutMutation)) schedule()
})
observer.observe(target, {
attributes: true,
attributeFilter: [
'cx',
'cy',
'd',
'dominant-baseline',
'font-family',
'font-size',
'font-weight',
'height',
'points',
'r',
'rx',
'ry',
'stroke-width',
'text-anchor',
'transform',
'visibility',
'width',
'x',
'x1',
'x2',
'y',
'y1',
'y2',
],
characterData: true,
childList: true,
subtree: true,
})
return () => {
observer.disconnect()
if (scheduledFrame === null) return
if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
else clearTimeout(scheduledFrame)
}
}
function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
if (mutation.type !== 'attributes') return true
const attribute = mutation.attributeName ?? ''
const target = mutation.target as Element
const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
if (
attribute === 'data-floorplan-annotation-id' ||
attribute === 'data-floorplan-annotation-layout-dx' ||
attribute === 'data-floorplan-annotation-layout-dy' ||
attribute === 'data-floorplan-layout-unresolved'
) {
return false
}
if (
closest?.('[data-floorplan-annotation-label]') &&
(attribute === 'style' || attribute === 'transform')
) {
return false
}
if (
closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
(attribute === 'x1' ||
attribute === 'x2' ||
attribute === 'y1' ||
attribute === 'y2' ||
attribute === 'visibility')
) {
return false
}
return true
}
export function collectAnnotationLayoutPreflightIssues(
rectangles: readonly AnnotationLabelRectangle[],
shifts: readonly AnnotationLabelShift[],
obstacles: readonly AnnotationObstacleRectangle[] = [],
): AnnotationPreflightIssue[] {
const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
const finalRectangles = rectangles.map((rectangle) => {
const shift = shiftsById.get(rectangle.id) ?? {
id: rectangle.id,
dx: 0,
dy: 0,
resolved: false,
}
return {
source: rectangle,
shift,
bounds: {
x: rectangle.x + shift.dx,
y: rectangle.y + shift.dy,
width: rectangle.width,
height: rectangle.height,
},
}
})
const issues: AnnotationPreflightIssue[] = []
const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
issues.push({ id, kind, severity: 'warning', message })
}
for (const entry of finalRectangles) {
const label = preflightLabel(entry.source)
if (entry.source.labelPlacement === 'outside-end') {
addIssue(
entry.source.id,
'short-unreadable-segment',
`${label} is too short for inline text and uses an outside label or leader.`,
)
}
if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
addIssue(
entry.source.id,
'plan-geometry-conflict',
`${label} still conflicts with fixed plan geometry after automatic layout.`,
)
}
if (!entry.shift.resolved) {
const collidesWithLabel = finalRectangles.some(
(candidate) =>
candidate.source.id !== entry.source.id &&
rectanglesOverlap(entry.bounds, candidate.bounds),
)
if (collidesWithLabel) {
addIssue(
entry.source.id,
'unresolved-collision',
`${label} still overlaps another annotation after automatic layout.`,
)
}
}
}
return issues
}
function preflightLabel(rectangle: AnnotationLabelRectangle): string {
const text = rectangle.text?.trim()
return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
}
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
const explicit = label.dataset.floorplanAnnotationId?.trim()
if (explicit) return explicit
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
const text = label.textContent?.trim() ?? ''
return `annotation:${index}:${text}:${defaultTransform}`
}
function resetDimensionConnectors(svg: SVGSVGElement): void {
for (const line of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-line]')) {
line.setAttribute(
'x1',
line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0',
)
line.setAttribute(
'y1',
line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0',
)
line.setAttribute(
'x2',
line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0',
)
line.setAttribute(
'y2',
line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0',
)
}
for (const leader of svg.querySelectorAll<SVGLineElement>('[data-floorplan-dimension-leader]')) {
leader.setAttribute('visibility', 'hidden')
}
}
function applyOutsideStartDimensionLine(label: SVGGElement): void {
const dimension = label.closest('[data-floorplan-dimension]')
const line = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
if (!line) return
const { dataset } = line
if (
dataset.floorplanDimensionOutsideStartX1 === undefined ||
dataset.floorplanDimensionOutsideStartY1 === undefined ||
dataset.floorplanDimensionOutsideStartX2 === undefined ||
dataset.floorplanDimensionOutsideStartY2 === undefined
) {
return
}
line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1)
line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1)
line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2)
line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2)
}
function showDimensionLeader(
label: SVGGElement,
labelMatrix: DOMMatrix,
dx: number,
dy: number,
): void {
const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null
const leader = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-leader]')
const dimensionLine = dimension?.querySelector<SVGLineElement>('[data-floorplan-dimension-line]')
const dimensionMatrix = dimension?.getScreenCTM()
if (!leader || !dimensionLine || !dimensionMatrix) return
const start = dimensionEndpoint(label, 'start')
const end = dimensionEndpoint(label, 'end')
if (!start || !end) return
dimensionLine.setAttribute('x1', String(start.x))
dimensionLine.setAttribute('y1', String(start.y))
dimensionLine.setAttribute('x2', String(end.x))
dimensionLine.setAttribute('y2', String(end.y))
const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy }
const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y)
const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y)
const anchor =
Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) <
Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y)
? start
: end
const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y)
leader.setAttribute('x1', String(anchor.x))
leader.setAttribute('y1', String(anchor.y))
leader.setAttribute('x2', String(labelPoint.x))
leader.setAttribute('y2', String(labelPoint.y))
leader.setAttribute('visibility', 'visible')
}
function dimensionEndpoint(
label: SVGGElement,
endpoint: 'start' | 'end',
): { x: number; y: number } | null {
const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`])
const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`])
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
}
function localPointToScreen(matrix: DOMMatrix, x: number, y: number) {
return {
x: matrix.a * x + matrix.c * y + matrix.e,
y: matrix.b * x + matrix.d * y + matrix.f,
}
}
function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) {
const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f)
return { x: local.x, y: local.y }
}
function svgAnnotationObstacleRectangles(
obstacle: SVGGraphicsElement,
): AnnotationObstacleRectangle[] {
const bounds = obstacle.getBoundingClientRect()
const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }]
if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback
const geometry = obstacle as SVGGeometryElement
const matrix = geometry.getScreenCTM()
if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback
try {
const length = geometry.getTotalLength()
const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d))
const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX))
const points = Array.from({ length: sampleCount + 1 }, (_, index) => {
const point = geometry.getPointAtLength((length * index) / sampleCount)
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}
})
return polylineObstacleRectangles(points)
} catch {
return fallback
}
}
export function polylineObstacleRectangles(
points: readonly { x: number; y: number }[],
): AnnotationObstacleRectangle[] {
if (points.length === 1) {
const point = points[0]!
return [
{
x: point.x - OUTLINE_OBSTACLE_PADDING_PX,
y: point.y - OUTLINE_OBSTACLE_PADDING_PX,
width: OUTLINE_OBSTACLE_PADDING_PX * 2,
height: OUTLINE_OBSTACLE_PADDING_PX * 2,
},
]
}
const rectangles: AnnotationObstacleRectangle[] = []
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1]!
const end = points[index]!
rectangles.push({
x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX,
y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX,
width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2,
height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2,
})
}
return rectangles
}
function resolveLabelShift(
rectangle: AnnotationLabelRectangle,
occupied: AnnotationObstacleIndex,
): { dx: number; dy: number } | undefined {
const candidates = new Map<string, { dx: number; dy: number; preference: number; cost: number }>()
const visited = new Set<string>()
const addCandidate = (dx: number, dy: number, preference = 0) => {
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return
const key = `${dx}:${dy}`
if (visited.has(key)) return
const existing = candidates.get(key)
if (!existing || preference < existing.preference) {
candidates.set(key, {
dx,
dy,
preference,
cost: candidateCost({ dx, dy, preference }, rectangle),
})
}
}
addCandidate(0, 0, -2)
for (const preferred of rectangle.preferredShifts ?? []) {
addCandidate(preferred.dx, preferred.dy, -1)
}
while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) {
let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined
for (const queued of candidates.values()) {
if (!candidate || queued.cost < candidate.cost) candidate = queued
}
if (!candidate) return undefined
const key = `${candidate.dx}:${candidate.dy}`
candidates.delete(key)
visited.add(key)
const shifted = {
...rectangle,
x: rectangle.x + candidate.dx,
y: rectangle.y + candidate.dy,
}
const blockers = occupied.findOverlaps(shifted)
if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy }
const blockerBounds = blockers.reduce(
(bounds, blocker) => ({
minX: Math.min(bounds.minX, blocker.x),
minY: Math.min(bounds.minY, blocker.y),
maxX: Math.max(bounds.maxX, blocker.x + blocker.width),
maxY: Math.max(bounds.maxY, blocker.y + blocker.height),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
)
const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x
const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x
const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y
const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y
addCandidate(candidate.dx, above)
addCandidate(candidate.dx, below)
addCandidate(left, candidate.dy)
addCandidate(right, candidate.dy)
addCandidate(left, above)
addCandidate(right, above)
addCandidate(left, below)
addCandidate(right, below)
}
return undefined
}
function candidateCost(
candidate: { dx: number; dy: number; preference?: number },
rectangle: AnnotationLabelRectangle,
): number {
const distance = Math.hypot(candidate.dx, candidate.dy)
const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP
if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) {
return preference + distance
}
const perpendicularMovement = Math.abs(
candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX,
)
return preference + distance + perpendicularMovement * 4
}
export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean {
if (floorplanAnnotationObstacleMode(geometry)) return true
if (geometry.kind !== 'group') return false
const hasPlate = geometry.children.some(
(child) => child.kind === 'rect' || child.kind === 'circle',
)
const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright)
return hasPlate && hasUprightText
}
export function floorplanAnnotationObstacleMode(
geometry: FloorplanGeometry,
): 'bounds' | 'outline' | '' | undefined {
const metadata = readFloorplanGeometryMetadata(geometry)
if (metadata.annotationObstacle) return metadata.annotationObstacle
switch (metadata.annotationRole) {
case 'room-label':
return geometry.kind === 'text' ? 'bounds' : undefined
case 'column-center':
return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined
case 'stair-annotation':
return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds'
default:
return undefined
}
}
function rectanglesOverlap(
left: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
right: Pick<AnnotationLabelRectangle, 'x' | 'y' | 'width' | 'height'>,
): boolean {
return !(
left.x + left.width + LABEL_GAP_PX <= right.x ||
right.x + right.width + LABEL_GAP_PX <= left.x ||
left.y + left.height + LABEL_GAP_PX <= right.y ||
right.y + right.height + LABEL_GAP_PX <= left.y
)
}
function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
return {
x: (matrix.d * dx - matrix.c * dy) / determinant,
y: (-matrix.b * dx + matrix.a * dy) / determinant,
}
}
@@ -0,0 +1,236 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import {
computeArchitecturalDimensionLayout,
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
floorplanDimensionAnnotationPriority,
} from './floorplan-dimension-renderer'
const dimension = {
kind: 'dimension',
start: [0, 0],
end: [4, 0],
offsetNormal: [0, 1],
offsetDistance: 0.45,
extensionOvershoot: 0.12,
text: `13'-1 1/2"`,
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
describe('architectural floor-plan dimensions', () => {
test('leaves a paper-space gap before solid extension lines', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout).not.toBeNull()
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 0.075])
expect(layout?.extensionStartTip[0]).toBe(0)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57)
expect(layout?.extensionEndTip[0]).toBe(4)
expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57)
expect(layout?.dimensionStart).toEqual([0, 0.45])
expect(layout?.dimensionEnd).toEqual([4, 0.45])
expect(layout?.dimensionLineEnd).toEqual([4, 0.45])
expect(layout?.labelPlacement).toBe('inside')
})
test('builds consistent 45-degree architectural slash terminators', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0)
expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5)
expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5)
})
test('honors dimension standard overrides for gaps, terminators, and text placement', () => {
const customDimension = {
...dimension,
extensionStartGap: 0.2,
terminator: 'dot',
textPosition: 'centered',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
const layout = computeArchitecturalDimensionLayout(customDimension, 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={customDimension} />
</svg>,
)
expect(layout?.extensionStart).toEqual([0, 0.2])
expect(markup).toContain('<circle')
expect(markup).toContain('y="0.0525"')
})
test('moves a short value beyond its end tick and extends the dimension line', () => {
const shortDimension = {
...dimension,
end: [0.23, 0] as [number, number],
text: '0.23m',
}
const layout = computeArchitecturalDimensionLayout(shortDimension, 0)
expect(layout?.labelPlacement).toBe('outside-end')
expect(layout?.labelPoint[0]).toBeGreaterThan(0.23)
expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0)
expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan(
layout?.outsideStartLabelPoint?.[0] ?? 0,
)
expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0)
expect(layout?.dimensionEnd).toEqual([0.23, 0.45])
const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01)
expect(documentLayout?.labelPlacement).toBe('outside-end')
expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={shortDimension} sceneRotationDeg={37} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
expect(markup).not.toContain('data-floorplan-dimension-leader=""')
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer
annotationUnitsPerPoint={0.01}
geometry={shortDimension}
sceneRotationDeg={37}
/>
</svg>,
)
expect(documentMarkup).toContain('data-floorplan-dimension-leader=""')
expect(documentMarkup).toContain('visibility="hidden"')
})
test('aligns stepped feature origins to an explicit exterior baseline', () => {
const layout = computeArchitecturalDimensionLayout(
{
...dimension,
start: [0, 0],
end: [4, 1],
dimensionStart: [0, 2],
dimensionEnd: [4, 2],
offsetDistance: 2,
},
0,
)
expect(layout?.dimensionStart).toEqual([0, 2])
expect(layout?.dimensionEnd).toEqual([4, 2])
expect(layout?.extensionStart).toEqual([0, 0.075])
expect(layout?.extensionEnd).toEqual([4, 1.075])
expect(layout?.extensionStartTip).toEqual([0, 2.12])
expect(layout?.extensionEndTip).toEqual([4, 2.12])
})
test('renders one uninterrupted line with the label above it', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} />
</svg>,
)
expect(markup.match(/<line/g)).toHaveLength(5)
expect(markup).not.toContain('stroke-dasharray')
expect(markup).toContain('y="-0.12"')
expect(markup).toContain('13&#x27;-1 1/2&quot;')
expect(markup).toContain('paint-order="stroke"')
expect(markup).toContain('stroke="#ffffff"')
expect(markup).toContain('data-floorplan-annotation-priority="145"')
})
test('keeps farther-out architectural strings fixed before inner strings', () => {
expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan(
floorplanDimensionAnnotationPriority(0.55),
)
})
test('keeps labels readable after the scene rotates', () => {
expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180)
})
test('resolves document annotation sizes from paper points', () => {
const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01)
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer annotationUnitsPerPoint={0.01} geometry={dimension} />
</svg>,
)
expect(layout?.extensionStart[1]).toBeCloseTo(0.03)
expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('y="-0.05"')
})
test('uses PDF-safe label plates and hairline measurement strokes for export', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionRenderer geometry={dimension} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('stroke-width="0.5"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('stroke="#ffffff"')
})
test('renders a dimension string with shared witness extension lines and ticks', () => {
const stringGeometry = {
kind: 'dimension-string',
segments: [
{
start: [0, 0],
end: [2, 0],
dimensionStart: [0, 1],
dimensionEnd: [2, 1],
text: '2m',
},
{
start: [2, 0],
end: [5, 0],
dimensionStart: [2, 1],
dimensionEnd: [5, 1],
text: '3m',
},
],
offsetNormal: [0, 1],
offsetDistance: 1,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={stringGeometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-string=""')
expect(markup.match(/<line/g)).toHaveLength(8)
expect(markup).toContain('2m')
expect(markup).toContain('3m')
})
test('offsets automatic dimension-string lines when no explicit baseline is supplied', () => {
const automaticString = {
kind: 'dimension-string',
segments: [{ start: [0, 0], end: [2, 0], text: '2m' }],
offsetNormal: [0, 1],
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
} satisfies Extract<FloorplanGeometry, { kind: 'dimension-string' }>
const markup = renderToStaticMarkup(
<svg>
<FloorplanDimensionStringRenderer geometry={automaticString} />
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
})
})
@@ -0,0 +1,608 @@
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const EXTENSION_START_GAP = 0.075
const TICK_HALF_LENGTH = 0.09
const LABEL_FONT_SIZE = 0.15
const LABEL_BASELINE_OFFSET = 0.12
const LABEL_CHARACTER_WIDTH_RATIO = 0.62
const LABEL_END_GAP = 0.075
const DOCUMENT_EXTENSION_START_GAP_PT = 3
const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
const DOCUMENT_LABEL_FONT_SIZE_PT = 8
const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
const DOCUMENT_LABEL_END_GAP_PT = 3
const LINE_STROKE_WIDTH_PX = 0.9
const TICK_STROKE_WIDTH_PX = 1.35
const PDF_LINE_STROKE_WIDTH_PT = 0.5
const PDF_TICK_STROKE_WIDTH_PT = 0.75
const SQRT_ONE_HALF = Math.SQRT1_2
type FloorplanDimensionRenderMode = 'screen' | 'pdf'
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type DimensionStringGeometry = Extract<FloorplanGeometry, { kind: 'dimension-string' }>
type DimensionTerminator = NonNullable<DimensionGeometry['terminator']>
export type ArchitecturalDimensionLayout = {
dimensionStart: FloorplanPoint
dimensionEnd: FloorplanPoint
dimensionLineStart: FloorplanPoint
dimensionLineEnd: FloorplanPoint
extensionStart: FloorplanPoint
extensionEnd: FloorplanPoint
extensionStartTip: FloorplanPoint
extensionEndTip: FloorplanPoint
tickHalfVector: FloorplanPoint
labelPoint: FloorplanPoint
labelAngleDeg: number
labelPlacement: 'inside' | 'outside-end'
outsideStartLabelPoint?: FloorplanPoint
outsideStartDimensionLineStart?: FloorplanPoint
}
export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
return 100 + Math.round(Math.abs(offsetDistance) * 100)
}
export function computeArchitecturalDimensionLayout(
geometry: DimensionGeometry,
sceneRotationDeg: number,
annotationUnitsPerPoint?: number,
): ArchitecturalDimensionLayout | null {
const extensionStartGap = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
: (geometry.extensionStartGap ?? EXTENSION_START_GAP)
const extensionOvershoot = annotationUnitsPerPoint
? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
: geometry.extensionOvershoot
const tickHalfLength = annotationUnitsPerPoint
? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
: TICK_HALF_LENGTH
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelEndGap = annotationUnitsPerPoint
? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
: LABEL_END_GAP
const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
geometry.start[0] + offsetX,
geometry.start[1] + offsetY,
]
const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
geometry.end[0] + offsetX,
geometry.end[1] + offsetY,
]
const dx = dimensionEnd[0] - dimensionStart[0]
const dy = dimensionEnd[1] - dimensionStart[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return null
const dirX = dx / length
const dirY = dy / length
const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
// A 45-degree architectural slash. Every terminator within one string uses
// this same vector instead of rotating independently around its endpoint.
const tickHalfVector: FloorplanPoint = [
(dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
(dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
]
const labelWidth = Math.max(
labelFontSize,
geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
)
const labelPlacement =
length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
const direction: FloorplanPoint = [dirX, dirY]
const labelPoint =
labelPlacement === 'inside'
? ([
(dimensionStart[0] + dimensionEnd[0]) / 2,
(dimensionStart[1] + dimensionEnd[1]) / 2,
] as FloorplanPoint)
: addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
const dimensionLineEnd =
labelPlacement === 'inside'
? dimensionEnd
: addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
const outsideStartLabelPoint =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
: undefined
const outsideStartDimensionLineStart =
labelPlacement === 'outside-end'
? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
: undefined
return {
dimensionStart,
dimensionEnd,
dimensionLineStart: dimensionStart,
dimensionLineEnd,
extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
tickHalfVector,
labelPoint,
labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
labelPlacement,
outsideStartLabelPoint,
outsideStartDimensionLineStart,
}
}
function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
return [left[0] - right[0], left[1] - right[1]]
}
function dot(left: FloorplanPoint, right: FloorplanPoint): number {
return left[0] * right[0] + left[1] * right[1]
}
function addScaled(
point: FloorplanPoint,
direction: FloorplanPoint,
distance: number,
): FloorplanPoint {
return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
}
export function FloorplanDimensionRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const layout = computeArchitecturalDimensionLayout(
geometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
if (!layout) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const terminator = geometry.terminator ?? 'architectural-tick'
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g data-floorplan-dimension="" pointerEvents="none">
<line
{...lineProps}
x1={layout.extensionStart[0]}
x2={layout.extensionStartTip[0]}
y1={layout.extensionStart[1]}
y2={layout.extensionStartTip[1]}
/>
<line
{...lineProps}
x1={layout.extensionEnd[0]}
x2={layout.extensionEndTip[0]}
y1={layout.extensionEnd[1]}
y2={layout.extensionEndTip[1]}
/>
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
{renderTerminator(
terminator,
layout.dimensionStart,
layout.dimensionEnd,
layout,
lineProps,
tickStrokeWidth,
)}
{renderTerminator(
terminator,
layout.dimensionEnd,
layout.dimensionStart,
layout,
lineProps,
tickStrokeWidth,
)}
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={geometry.text}
y={labelY}
/>
</g>
</g>
)
}
export function FloorplanDimensionStringRenderer({
geometry,
sceneRotationDeg = 0,
stroke = geometry.stroke ?? '#334155',
annotationUnitsPerPoint,
renderMode = 'screen',
}: {
geometry: DimensionStringGeometry
sceneRotationDeg?: number
stroke?: string
annotationUnitsPerPoint?: number
renderMode?: FloorplanDimensionRenderMode
}): React.ReactElement | null {
const segmentLayouts = geometry.segments.flatMap((segment, index) => {
const segmentGeometry: DimensionGeometry = {
kind: 'dimension',
start: segment.start,
end: segment.end,
dimensionStart: segment.dimensionStart,
dimensionEnd: segment.dimensionEnd,
offsetNormal: geometry.offsetNormal,
offsetDistance: geometry.offsetDistance,
extensionOvershoot: geometry.extensionOvershoot,
extensionStartGap: geometry.extensionStartGap,
terminator: geometry.terminator,
textPosition: geometry.textPosition,
text: segment.text,
stroke: geometry.stroke,
}
const layout = computeArchitecturalDimensionLayout(
segmentGeometry,
sceneRotationDeg,
annotationUnitsPerPoint,
)
return layout ? [{ index, layout, segment: segmentGeometry }] : []
})
if (segmentLayouts.length === 0) return null
const labelFontSize = annotationUnitsPerPoint
? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
: LABEL_FONT_SIZE
const labelBaselineOffset = annotationUnitsPerPoint
? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
: LABEL_BASELINE_OFFSET
const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
const lineProps = {
stroke,
strokeLinecap: 'butt' as const,
strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
vectorEffect: 'non-scaling-stroke' as const,
}
const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
const extensionLines = new Map<string, { start: FloorplanPoint; tip: FloorplanPoint }>()
const ticks = new Map<
string,
{ point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
>()
for (const { layout } of segmentLayouts) {
extensionLines.set(pointKey(layout.dimensionStart), {
start: layout.extensionStart,
tip: layout.extensionStartTip,
})
extensionLines.set(pointKey(layout.dimensionEnd), {
start: layout.extensionEnd,
tip: layout.extensionEndTip,
})
ticks.set(pointKey(layout.dimensionStart), {
point: layout.dimensionStart,
toward: layout.dimensionEnd,
tickHalfVector: layout.tickHalfVector,
})
ticks.set(pointKey(layout.dimensionEnd), {
point: layout.dimensionEnd,
toward: layout.dimensionStart,
tickHalfVector: layout.tickHalfVector,
})
}
const terminator = geometry.terminator ?? 'architectural-tick'
return (
<g data-floorplan-dimension-string="" pointerEvents="none">
{[...extensionLines.values()].map((line, index) => (
<line
{...lineProps}
key={`extension-${index}`}
x1={line.start[0]}
x2={line.tip[0]}
y1={line.start[1]}
y2={line.tip[1]}
/>
))}
{segmentLayouts.map(({ index, layout }) => (
<line
{...lineProps}
data-floorplan-dimension-default-x1={layout.dimensionLineStart[0]}
data-floorplan-dimension-default-x2={layout.dimensionLineEnd[0]}
data-floorplan-dimension-default-y1={layout.dimensionLineStart[1]}
data-floorplan-dimension-default-y2={layout.dimensionLineEnd[1]}
data-floorplan-dimension-line=""
data-floorplan-dimension-outside-start-x1={layout.outsideStartDimensionLineStart?.[0]}
data-floorplan-dimension-outside-start-x2={layout.dimensionEnd[0]}
data-floorplan-dimension-outside-start-y1={layout.outsideStartDimensionLineStart?.[1]}
data-floorplan-dimension-outside-start-y2={layout.dimensionEnd[1]}
key={`dimension-line-${index}`}
x1={layout.dimensionLineStart[0]}
x2={layout.dimensionLineEnd[0]}
y1={layout.dimensionLineStart[1]}
y2={layout.dimensionLineEnd[1]}
/>
))}
{[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
renderTerminator(
terminator,
point,
toward,
{ tickHalfVector },
lineProps,
tickStrokeWidth,
`tick-${index}`,
),
)}
{segmentLayouts.map(({ index, layout, segment }) => {
const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
const outsideStartLocalShift = layout.outsideStartLabelPoint
? rotateVector(
subtract(layout.outsideStartLabelPoint, layout.labelPoint),
(-layout.labelAngleDeg * Math.PI) / 180,
)
: undefined
return (
<g key={`label-${index}`}>
{layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
<line
{...lineProps}
data-floorplan-dimension-leader=""
visibility="hidden"
x1={layout.dimensionEnd[0]}
x2={layout.labelPoint[0]}
y1={layout.dimensionEnd[1]}
y2={layout.labelPoint[1]}
/>
) : null}
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority={floorplanDimensionAnnotationPriority(
geometry.offsetDistance,
)}
data-floorplan-dimension-label-placement={layout.labelPlacement}
data-floorplan-dimension-outside-start-local-x={outsideStartLocalShift?.[0]}
data-floorplan-dimension-outside-start-local-y={outsideStartLocalShift?.[1]}
data-floorplan-dimension-start-x={layout.dimensionStart[0]}
data-floorplan-dimension-start-y={layout.dimensionStart[1]}
data-floorplan-dimension-end-x={layout.dimensionEnd[0]}
data-floorplan-dimension-end-y={layout.dimensionEnd[1]}
transform={labelTransform}
>
<DimensionLabel
fontSize={labelFontSize}
renderMode={renderMode}
stroke={stroke}
text={segment.text}
y={labelY}
/>
</g>
</g>
)
})}
</g>
)
}
function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
const cosine = Math.cos(radians)
const sine = Math.sin(radians)
return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
}
function DimensionLabel({
text,
y,
fontSize,
stroke,
renderMode,
}: {
text: string
y: number
fontSize: number
stroke: string
renderMode: FloorplanDimensionRenderMode
}) {
const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
const plateWidth = width + fontSize * 0.5
const plateHeight = fontSize * 1.2
const plateY = y - fontSize * 0.82
return (
<>
{renderMode === 'pdf' ? (
<rect
data-floorplan-dimension-label-plate=""
fill="#ffffff"
height={plateHeight}
rx={fontSize * 0.12}
ry={fontSize * 0.12}
width={plateWidth}
x={-plateWidth / 2}
y={plateY}
/>
) : null}
<text
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={500}
paintOrder={renderMode === 'pdf' ? undefined : 'stroke'}
stroke={renderMode === 'pdf' ? undefined : '#ffffff'}
strokeLinejoin={renderMode === 'pdf' ? undefined : 'round'}
strokeWidth={renderMode === 'pdf' ? undefined : 3}
textAnchor="middle"
vectorEffect={renderMode === 'pdf' ? undefined : 'non-scaling-stroke'}
x={0}
y={y}
>
{text}
</text>
</>
)
}
function renderTerminator(
terminator: DimensionTerminator,
point: FloorplanPoint,
toward: FloorplanPoint,
layout: Pick<ArchitecturalDimensionLayout, 'tickHalfVector'>,
lineProps: {
stroke: string
strokeLinecap: 'butt'
strokeWidth: number
vectorEffect: 'non-scaling-stroke'
},
tickStrokeWidth: number,
key?: string,
): React.ReactElement | null {
const direction = normalized(point, toward)
if (!direction) return null
const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
if (terminator === 'dot') {
return (
<circle
fill={lineProps.stroke}
key={key}
r={tickHalfLength * 0.45}
vectorEffect="non-scaling-stroke"
cx={point[0]}
cy={point[1]}
/>
)
}
if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
const base = addScaled(point, direction, tickHalfLength * 1.7)
const normal: FloorplanPoint = [-direction[1], direction[0]]
const wing = tickHalfLength * 0.65
const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
if (terminator === 'filled-arrow') {
return (
<polygon
fill={lineProps.stroke}
key={key}
points={`${point[0]},${point[1]} ${left[0]},${left[1]} ${right[0]},${right[1]}`}
vectorEffect="non-scaling-stroke"
/>
)
}
return (
<g key={key}>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={left[0]}
y1={point[1]}
y2={left[1]}
/>
<line
{...lineProps}
strokeWidth={tickStrokeWidth}
x1={point[0]}
x2={right[0]}
y1={point[1]}
y2={right[1]}
/>
</g>
)
}
const [tickX, tickY] = layout.tickHalfVector
return (
<line
{...lineProps}
key={key}
strokeWidth={tickStrokeWidth}
x1={point[0] - tickX}
x2={point[0] + tickX}
y1={point[1] - tickY}
y2={point[1] + tickY}
/>
)
}
function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const magnitude = Math.hypot(dx, dy)
return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
}
function pointKey(point: FloorplanPoint): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
}
@@ -0,0 +1,369 @@
import { describe, expect, test } from 'bun:test'
import type { FloorplanGeometry } from '@pascal-app/core'
import { renderToStaticMarkup } from 'react-dom/server'
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
describe('FloorplanGeometryRenderer static labels', () => {
test('renders a measurement value together with its geometry', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' },
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 0,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('<line')
expect(markup).toContain('2.00m')
expect(markup).toContain('translate(0 -0.14)')
})
test('keeps screen-upright measurement labels readable in rotated exports', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: 'A 6.0m²',
angle: Math.PI / 3,
screenUpright: true,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} sceneRotationDeg={90} />
</svg>,
)
expect(markup).toContain('A 6.0m²')
expect(markup).toContain('rotate(-90)')
})
test('uses paper-point sizing when document scale is provided', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
})
test('uses live screen sizing without enabling document annotation styles', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'LIVE TEXT',
fontSize: 0.16,
upright: true,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
offsetPx: 14,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} screenUnitsPerPixel={0.02} />
</svg>,
)
expect(markup).toContain('font-size="0.16"')
expect(markup).toContain('font-size="0.24"')
expect(markup).toContain('translate(0 -0.28)')
})
test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => {
const geometry = {
kind: 'dimension-label',
appearance: 'outlined',
cx: 1,
cy: 2,
text: '2.00m',
angle: 0,
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={geometry}
renderMode="pdf"
screenUnitsPerPixel={0.02}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-dimension-label-plate=""')
expect(markup).toContain('fill="#111827"')
expect(markup).not.toContain('paint-order="stroke"')
expect(markup).not.toContain('fill="#ffffff" font-family=')
})
test('caps PDF annotation linework without changing live stroke widths', () => {
const geometry = {
kind: 'line',
x1: 0,
y1: 0,
x2: 2,
y2: 0,
stroke: '#334155',
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const pdfMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(liveMarkup).toContain('stroke-width="2"')
expect(pdfMarkup).toContain('stroke-width="0.5"')
})
test('removes unsupported paint-order outlines from generic PDF text', () => {
const geometry = {
kind: 'text',
x: 1,
y: 2,
text: '101',
fontSize: 0.15,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.04,
paintOrder: 'stroke',
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} renderMode="pdf" />
</svg>,
)
expect(markup).toContain('fill="#334155"')
expect(markup).not.toContain('paint-order')
expect(markup).not.toContain('stroke=')
})
test('resolves generic annotation text from paper points only in document mode', () => {
const geometry = {
kind: 'text',
x: 2,
y: 3,
text: 'VERIFY DIMENSIONS',
fontSize: 0.16,
upright: true,
} satisfies FloorplanGeometry
const liveMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
const documentMarkup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(liveMarkup).toContain('font-size="0.16"')
expect(documentMarkup).toContain('font-size="0.08"')
})
test('uses a room-label paper profile while preserving room label hierarchy', () => {
const geometry = {
kind: 'group',
children: [
{
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.2,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.18,
text: '101',
fontSize: 0.16,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
{
kind: 'text',
x: 0,
y: 0.36,
text: 'CH: 2700',
fontSize: 0.11,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
upright: true,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('font-size="0.08"')
expect(markup).toContain('font-size="0.07"')
expect(markup).toContain('font-size="0.055"')
expect(markup).toMatch(/translate\(0 0\.08625/)
expect(markup).toMatch(/translate\(0 0\.18625/)
expect(markup).toMatch(/translate\(0 0\.27375/)
})
test('uses paper stroke profiles for leaders and opening marks in document mode', () => {
const geometry = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{
kind: 'polyline',
points: [
[0, 0],
[1, 0],
[1.4, 0],
],
stroke: '#334155',
strokeWidth: 0.9,
vectorEffect: 'non-scaling-stroke',
},
{
kind: 'rect',
x: 2,
y: 2,
width: 0.42,
height: 0.32,
fill: '#ffffff',
stroke: '#334155',
strokeWidth: 0.02,
},
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer annotationUnitsPerPoint={0.01} geometry={geometry} />
</svg>,
)
expect(markup).toContain('stroke-width="0.9"')
expect(markup).toContain('vector-effect="non-scaling-stroke"')
expect(markup).toContain('height="0.14"')
expect(markup).toContain('rx="0.07"')
})
test('registers fixed mark pills as annotation obstacles', () => {
const geometry = {
kind: 'group',
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={geometry} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle=""')
})
test('registers semantic plan primitives as annotation obstacles', () => {
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer
geometry={{
kind: 'polygon',
points: [
[0, 0],
[4, 0],
[4, 0.2],
],
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
}}
/>
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
test('registers fixed annotation categories as obstacles', () => {
const roomLabel = {
kind: 'text',
x: 0,
y: 0,
text: 'KITCHEN',
fontSize: 0.18,
metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
} satisfies FloorplanGeometry
const stairArrow = {
kind: 'polyline',
points: [
[0, 0],
[0.5, 0.5],
],
metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
} satisfies FloorplanGeometry
const markup = renderToStaticMarkup(
<svg>
<FloorplanGeometryRenderer geometry={roomLabel} />
<FloorplanGeometryRenderer geometry={stairArrow} />
</svg>,
)
expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
})
})
@@ -2,6 +2,29 @@
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react'
import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
} from './floorplan-annotation-layout'
import {
FloorplanDimensionRenderer,
FloorplanDimensionStringRenderer,
} from './floorplan-dimension-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
const DOCUMENT_MARK_HEIGHT_PT = 14
const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
type FloorplanRenderMode = 'screen' | 'pdf'
/**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
pointerEventsOverride,
sceneRotationDeg = 0,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode = 'screen',
}: {
geometry: FloorplanGeometry
pointerEventsOverride?: string
sceneRotationDeg?: number
annotationUnitsPerPoint?: number
screenUnitsPerPixel?: number
renderMode?: FloorplanRenderMode
}) {
return renderNode(geometry, 0, pointerEventsOverride)
return renderNode(
geometry,
0,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
)
})
function styleAttrs(
g: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
pointerEventsOverride?: string,
annotationUnitsPerPoint?: number,
renderMode: FloorplanRenderMode = 'screen',
) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
pointerEvents?: string
cursor?: string
}
const annotationMetadata = readFloorplanGeometryMetadata(g)
const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
const strokeWidth =
renderMode === 'pdf' &&
vectorEffect === 'non-scaling-stroke' &&
resolvedStrokeWidth !== undefined
? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
: resolvedStrokeWidth
return {
fill: s.fill ?? 'none',
'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
'data-floorplan-annotation-role': annotationMetadata.annotationRole,
fill: documentStyle.fill ?? s.fill ?? 'none',
fillOpacity: s.fillOpacity,
stroke: s.stroke,
strokeWidth: s.strokeWidth,
stroke: documentStyle.stroke ?? s.stroke,
strokeWidth,
strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity,
opacity: s.opacity,
vectorEffect: s.vectorEffect,
vectorEffect,
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined,
}
}
export function resolveDocumentFloorplanAnnotationStyle(
geometry: FloorplanGeometry & { kind: Exclude<FloorplanGeometry['kind'], 'group'> },
annotationUnitsPerPoint?: number,
): {
fill?: string
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
} {
if (annotationUnitsPerPoint === undefined) return {}
const styled = geometry as FloorplanGeometry & {
stroke?: string
strokeWidth?: number
vectorEffect?: 'non-scaling-stroke'
}
if (!styled.stroke && geometry.kind !== 'text') return {}
if (geometry.kind === 'text') {
const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
const sourceStrokeWidth = geometry.strokeWidth ?? 0
const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
return {
strokeWidth:
geometry.stroke && outlineRatio > 0
? Math.max(
DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
fontSize * outlineRatio,
)
: undefined,
}
}
return {
strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
vectorEffect: 'non-scaling-stroke',
}
}
function documentTextFontSize(
geometry: Extract<FloorplanGeometry, { kind: 'text' }>,
annotationUnitsPerPoint: number,
): number {
return documentTextSizePt(geometry) * annotationUnitsPerPoint
}
function documentTextSizePt(geometry: Extract<FloorplanGeometry, { kind: 'text' }>): number {
switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
case 'room-label':
if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
case 'column-center':
case 'stair-annotation':
return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
default:
return DOCUMENT_DEFAULT_TEXT_SIZE_PT
}
}
function documentStrokeWidth(
geometry: { strokeWidth?: number },
annotationUnitsPerPoint: number,
): number {
return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
}
export function documentRectGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'rect' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
return {
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
rx: geometry.rx,
ry: geometry.ry,
}
}
const centerX = geometry.x + geometry.width / 2
const centerY = geometry.y + geometry.height / 2
const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
return {
x: centerX - width / 2,
y: centerY - height / 2,
width,
height,
rx: height / 2,
ry: height / 2,
}
}
function isAnnotationMarkRect(geometry: Extract<FloorplanGeometry, { kind: 'rect' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
}
export function documentCircleGeometryAttrs(
geometry: Extract<FloorplanGeometry, { kind: 'circle' }>,
annotationUnitsPerPoint?: number,
) {
if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
return { r: geometry.r }
}
return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
}
function isAnnotationMarkCircle(geometry: Extract<FloorplanGeometry, { kind: 'circle' }>): boolean {
return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
}
export function resolveDocumentAnnotationGroupChildren(
children: FloorplanGeometry[],
annotationUnitsPerPoint?: number,
): FloorplanGeometry[] {
if (annotationUnitsPerPoint === undefined) return children
const next = [...children]
let start = 0
while (start < next.length) {
const first = next[start]
if (!isDocumentTextLine(first)) {
start++
continue
}
let end = start + 1
while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
if (end - start > 1) {
const run = next.slice(start, end) as Extract<FloorplanGeometry, { kind: 'text' }>[]
const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
for (let index = 0; index < adjusted.length; index++) {
const line = adjusted[index]
if (line) next[start + index] = line
}
}
start = end
}
return next
}
function isDocumentTextLine(
geometry: FloorplanGeometry | undefined,
): geometry is Extract<FloorplanGeometry, { kind: 'text' }> {
return geometry?.kind === 'text' && geometry.upright === true
}
function isSameDocumentTextRun(
first: Extract<FloorplanGeometry, { kind: 'text' }>,
candidate: FloorplanGeometry | undefined,
): candidate is Extract<FloorplanGeometry, { kind: 'text' }> {
return (
isDocumentTextLine(candidate) &&
Math.abs(candidate.x - first.x) < 1e-6 &&
candidate.textAnchor === first.textAnchor &&
readFloorplanGeometryMetadata(candidate).annotationRole ===
readFloorplanGeometryMetadata(first).annotationRole
)
}
function positionDocumentTextRun(
run: Extract<FloorplanGeometry, { kind: 'text' }>[],
annotationUnitsPerPoint: number,
): Extract<FloorplanGeometry, { kind: 'text' }>[] {
const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
const steps = run.slice(0, -1).map((line, index) => {
const next = run[index + 1] ?? line
const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
return largerFontPt * 1.25 * annotationUnitsPerPoint
})
const totalHeight = steps.reduce((sum, step) => sum + step, 0)
let y = centerY - totalHeight / 2
return run.map((line, index) => {
if (index > 0) y += steps[index - 1] ?? 0
return { ...line, y }
})
}
function renderNode(
g: FloorplanGeometry,
keyHint: number,
pointerEventsOverride?: string,
sceneRotationDeg = 0,
annotationUnitsPerPoint?: number,
screenUnitsPerPixel?: number,
renderMode: FloorplanRenderMode = 'screen',
): React.ReactElement | null {
switch (g.kind) {
case 'path':
return <path d={g.d} key={keyHint} {...styleAttrs(g, pointerEventsOverride)} />
return (
<path
d={g.d}
key={keyHint}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'polygon':
return (
<polygon
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
@@ -93,34 +338,38 @@ function renderNode(
<polyline
key={keyHint}
points={pointsToAttr(g.points)}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'rect':
case 'rect': {
const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
return (
<rect
height={g.height}
height={attrs.height}
key={keyHint}
rx={g.rx}
ry={g.ry}
width={g.width}
x={g.x}
y={g.y}
{...styleAttrs(g, pointerEventsOverride)}
rx={attrs.rx}
ry={attrs.ry}
width={attrs.width}
x={attrs.x}
y={attrs.y}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
}
case 'circle':
case 'circle': {
const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
return (
<circle
cx={g.cx}
cy={g.cy}
key={keyHint}
r={g.r}
{...styleAttrs(g, pointerEventsOverride)}
r={attrs.r}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
}
case 'line':
return (
@@ -130,25 +379,65 @@ function renderNode(
x2={g.x2}
y1={g.y1}
y2={g.y2}
{...styleAttrs(g, pointerEventsOverride)}
{...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
case 'text':
case 'text': {
const fontSize =
annotationUnitsPerPoint !== undefined
? documentTextFontSize(g, annotationUnitsPerPoint)
: g.fontSize
const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
const fill =
pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
? g.stroke
: (g.fill ?? '#171717')
if (g.upright) {
return (
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={fontSize}
fontWeight={g.fontWeight}
opacity={g.opacity}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
pointerEvents={pointerEventsOverride}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
return (
<text
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
fill={fill}
fontFamily={g.fontFamily}
fontSize={g.fontSize}
fontSize={fontSize}
fontWeight={g.fontWeight}
key={keyHint}
opacity={g.opacity}
paintOrder={g.paintOrder}
stroke={g.stroke}
strokeLinecap={g.stroke ? 'round' : undefined}
strokeLinejoin={g.stroke ? 'round' : undefined}
strokeWidth={g.strokeWidth}
paintOrder={pdfOutlinedText ? undefined : g.paintOrder}
stroke={pdfOutlinedText ? undefined : g.stroke}
strokeLinecap={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeLinejoin={!pdfOutlinedText && g.stroke ? 'round' : undefined}
strokeWidth={pdfOutlinedText ? undefined : (textStyle.strokeWidth ?? g.strokeWidth)}
textAnchor={g.textAnchor ?? 'start'}
pointerEvents={pointerEventsOverride}
x={g.x}
@@ -157,6 +446,93 @@ function renderNode(
{g.text}
</text>
)
}
case 'dimension':
return (
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
annotationUnitsPerPoint={annotationUnitsPerPoint}
renderMode={renderMode}
/>
)
case 'dimension-string':
return (
<FloorplanDimensionStringRenderer
annotationUnitsPerPoint={annotationUnitsPerPoint}
geometry={g}
key={keyHint}
renderMode={renderMode}
sceneRotationDeg={sceneRotationDeg}
/>
)
case 'dimension-label': {
const unitsPerPixel =
annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
const documentMode = annotationUnitsPerPoint !== undefined
const outlined = g.appearance === 'outlined'
const pdfOutlined = outlined && renderMode === 'pdf'
const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3
const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
const textWidth = g.text.length * unitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
return (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={labelTransform}
>
{outlined && !pdfOutlined ? null : (
<rect
data-floorplan-dimension-label-plate={pdfOutlined ? '' : undefined}
fill="#ffffff"
height={plateH}
opacity={0.92}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
stroke={pdfOutlined ? undefined : '#334155'}
strokeWidth={pdfOutlined ? undefined : unitsPerPixel * 0.5}
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
)}
<text
dominantBaseline="middle"
fill={pdfOutlined ? '#111827' : outlined ? '#ffffff' : '#111827'}
fontFamily={
outlined && !pdfOutlined
? 'system-ui, -apple-system, sans-serif'
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
}
fontSize={fontSize}
fontWeight={outlined ? 500 : 600}
paintOrder={outlined && !pdfOutlined ? 'stroke' : undefined}
stroke={outlined && !pdfOutlined ? '#334155' : undefined}
strokeLinecap={outlined && !pdfOutlined ? 'round' : undefined}
strokeLinejoin={outlined && !pdfOutlined ? 'round' : undefined}
strokeWidth={outlined && !pdfOutlined ? fontSize * 0.35 : undefined}
textAnchor="middle"
x={0}
y={0}
>
{g.text}
</text>
</g>
)
}
case 'image':
return (
@@ -174,15 +550,32 @@ function renderNode(
case 'group': {
const transform = formatTransform(g.transform)
const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
return (
<g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
<g
data-floorplan-annotation-obstacle={
isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined
}
key={keyHint}
transform={transform}
>
{children.map((child, i) =>
renderNode(
child,
i,
pointerEventsOverride,
sceneRotationDeg,
annotationUnitsPerPoint,
screenUnitsPerPixel,
renderMode,
),
)}
</g>
)
}
// The interactive primitives (hatch / hit-line / endpoint-handle /
// dimension-label) need the SVG context + theme palette + units-per-
// The remaining interactive primitives (hatch / hit-line / endpoint-handle)
// need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it
@@ -3,15 +3,25 @@ import type {
AnyNode,
AnyNodeId,
FloorplanAffordanceSession,
FloorplanGeometry,
LiveNodeOverrides,
} from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { z } from 'zod'
import {
FLOORPLAN_NODE_EXTENSION_KEY,
floorplanGeometryMetadata,
} from '../../../lib/floorplan/floorplan-extension'
import {
cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes,
collectFloorplanLinkedLevelNodes,
computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance,
InteractiveGeometry,
splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer'
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
})
})
describe('floorplan annotation overlay routing', () => {
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
const noop = () => {}
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
renderToStaticMarkup(
createElement(
'svg',
null,
createElement(InteractiveGeometry, {
activeDragId: null,
activeRotateNodeId: null,
geometry,
hatchPatternId: undefined,
hoveredHandleId: null,
isMarqueeSelectionActive: false,
nodeId: 'wall_test' as AnyNodeId,
onHandleDoubleClick: noop,
onHandleHoverChange: noop,
onHandlePointerDown: noop,
onMoveHandlePointerDown: noop,
palette: undefined,
sceneRotationDeg: 180,
unitsPerPixel: 0.01,
}),
),
)
const dimensionString = (
end: readonly [number, number],
offsetNormal: readonly [number, number],
): FloorplanGeometry => ({
kind: 'dimension-string',
segments: [{ start: [0, 0], end, text: '2m' }],
offsetNormal,
offsetDistance: 0.55,
extensionOvershoot: 0.12,
textPosition: 'above',
})
expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
})
test('keeps a fixed mark pill together in the overlay pass', () => {
const mark = {
kind: 'group',
metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
children: [
{ kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
{ kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
{ kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
],
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
})
test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
const columnCenter = {
kind: 'line',
x1: 0,
y1: 0,
x2: 1,
y2: 0,
metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
} satisfies FloorplanGeometry
expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
})
})
describe('computeAffectedSiblingIds', () => {
beforeEach(() => {
nodeRegistry._reset()
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
])
})
})
describe('collectFloorplanLinkedLevelNodes', () => {
test('projects a node onto a linked destination level with its real children', () => {
nodeRegistry._reset()
registerNode({
kind: 'linked-floorplan-test',
schemaVersion: 1,
schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
category: 'structure',
defaults: () => ({}) as never,
floorplan: () => null,
extensions: {
[FLOORPLAN_NODE_EXTENSION_KEY]: {
linkedLevelIds: () => ['level_upper' as AnyNodeId],
},
},
} as unknown as AnyNodeDefinition)
const child = {
id: 'linked_child',
type: 'linked-child',
parentId: 'linked_parent',
} as unknown as AnyNode
const parent = {
id: 'linked_parent',
type: 'linked-floorplan-test',
parentId: 'level_lower',
children: [child.id],
} as unknown as AnyNode
const nodes = { [parent.id]: parent, [child.id]: child }
expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
{ id: parent.id, node: parent, children: [child] },
])
expect(
collectFloorplanLinkedLevelNodes(
nodes,
'level_upper' as AnyNodeId,
new Set([parent.id as AnyNodeId]),
),
).toEqual([])
})
})
@@ -33,6 +33,7 @@ import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -47,6 +48,16 @@ import {
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
import {
type FloorplanAnnotationVisibility,
filterFloorplanAnnotationGeometry,
} from '../../../lib/floorplan/annotation-visibility'
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
import {
createFloorplanContextExtensions,
type FloorplanWallDimensionReference,
getFloorplanNodeExtension,
} from '../../../lib/floorplan/floorplan-extension'
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import {
type ActiveInteractionScope,
@@ -55,12 +66,16 @@ import {
curveReshapeScope,
endpointReshapeScope,
holeEditScope,
isIdle,
tangentReshapeScope,
} from '../../../lib/interaction/scope'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
import useDrawingView from '../../../store/use-drawing-view'
import useEditor from '../../../store/use-editor'
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useInteractionScope, {
useEndpointReshape,
useMovingNode,
@@ -74,6 +89,14 @@ import {
startFloorplanGroupRotate,
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
import {
floorplanAnnotationObstacleMode,
isFloorplanAnnotationObstacleGeometry,
observeSvgAnnotationLayoutChanges,
resolveSvgAnnotationCollisions,
svgAnnotationLabelId,
} from './floorplan-annotation-layout'
import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
@@ -276,6 +299,8 @@ type NodeDeps = {
node: AnyNode
live: LiveTransform | undefined
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
selected: boolean
highlighted: boolean
hovered: boolean
@@ -343,7 +368,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit)
const showMeasurements = useViewer((s) => s.showMeasurements)
const metricNotation = useViewer((s) => s.metricNotation)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
@@ -423,6 +448,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// selectors freeze to `undefined` so drag publishes do not re-render the
// hidden floor-plan tree.
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
const drawingType = useDrawingView((s) => s.drawingType)
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
// Elevator builders read runtime state imperatively, so entries include this
// rare-changing ref in their cache deps.
const interactiveElevators = useInteractive((s) => s.elevators)
@@ -838,15 +866,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
if (!isNodeKindEnabled(node.type, installedPlugins)) return
const def = nodeRegistry.get(node.type)
const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
if (!drawingNode) return
const def = nodeRegistry.get(drawingNode.type)
if (!def?.floorplan) return
if (node.type === 'measurement' && !showMeasurements) return
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
out.push(descriptor)
}
@@ -863,6 +892,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
visit(levelId as AnyNodeId)
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
if (activeLevelNode) {
const collectedIds = new Set(out.map((entry) => entry.id))
for (const linked of collectFloorplanLinkedLevelNodes(
nodes,
levelId as AnyNodeId,
collectedIds,
)) {
pushEntry(linked.id, linked.node, {
children: linked.children,
siblings: [],
parent: activeLevelNode,
})
}
}
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
// as siblings of the level, not under it — the `visit(levelId)` DFS
// above doesn't reach them. Walk every node of those kinds whose
@@ -871,7 +916,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// builders that gate on the current floor — e.g. elevator service
// range — keep working). Pure registry-driven dispatch: no kind
// name appears in this file.
const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
const activeBuildingId = activeLevelNode
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
: null
@@ -907,7 +951,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
}
return { entries: out, levelNodeIdsByType }
}, [installedPlugins, levelId, nodes, showMeasurements])
}, [drawingType, installedPlugins, levelId, nodes])
// ── Generic 2D affordance dispatch ─────────────────────────────────
//
@@ -1290,6 +1334,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
@@ -1323,6 +1368,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
@@ -1341,6 +1388,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
<FloorplanRegistryEntry
activeDragId={handleIdForNode(activeDragId, entry.id)}
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
annotationVisibility={annotationVisibility}
floorplanVisible={floorplanVisible}
geometryCacheRef={geometryCacheRef}
hatchPatternId={renderCtx?.hatchPatternId}
@@ -1374,12 +1422,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
metricNotation={metricNotation}
wallDimensionReference={wallDimensionReference}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
/>
))}
</g>
<FloorplanAnnotationLayoutResolver active={floorplanVisible} />
{/* Dashed group bbox — shows what a group drag carries along while a
multi-selection exists, rides the live delta mid-drag, and doubles
as the group's whole-area drag handle. */}
@@ -1403,9 +1454,198 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
)
})
function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
const markerRef = useRef<SVGGElement>(null)
const [layoutEpoch, setLayoutEpoch] = useState(0)
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
const layoutEnabled = active && interactionIdle
useLayoutEffect(() => {
if (!layoutEnabled) return
const registryLayer = markerRef.current?.parentElement
if (!registryLayer) return
return observeSvgAnnotationLayoutChanges(registryLayer, () => {
setLayoutEpoch((epoch) => epoch + 1)
})
}, [layoutEnabled])
useLayoutEffect(() => {
// The epoch is only a trigger; collision inputs are measured from the live SVG below.
void layoutEpoch
if (!active) {
resetPreflightIssues()
return
}
if (!interactionIdle) return
const svg = markerRef.current?.ownerSVGElement
const registryLayer = markerRef.current?.parentElement
if (!(svg && registryLayer)) return
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
layoutOverrides: annotationLayoutOverrides,
})
setPreflightIssues(preflightIssues)
const labels = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
for (const [index, label] of labels.entries()) {
const id = svgAnnotationLabelId(label, index)
label.dataset.floorplanAnnotationId = id
label.style.pointerEvents = 'all'
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
}
}, [
active,
annotationLayoutOverrides,
interactionIdle,
layoutEpoch,
resetPreflightIssues,
setPreflightIssues,
])
useEffect(() => {
if (!layoutEnabled) return
const registryLayer = markerRef.current?.parentElement
if (!registryLayer) return
let cleanupPointerDrag: (() => void) | null = null
let cancelActivePointerDrag: (() => void) | null = null
const findLabel = (target: EventTarget | null): SVGGElement | null => {
if (!(target instanceof Element)) return null
const label = target.closest<SVGGElement>('[data-floorplan-annotation-label]')
return label && registryLayer.contains(label) ? label : null
}
const labelId = (label: SVGGElement): string => {
const labels = Array.from(
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'),
)
return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label)))
}
const onPointerDown = (event: PointerEvent) => {
const label = findLabel(event.target)
if (!(label && event.button === 0)) return
const matrix = label.getScreenCTM()
if (!matrix) return
event.preventDefault()
event.stopPropagation()
cancelActivePointerDrag?.()
label.style.cursor = 'grabbing'
const id = labelId(label)
const start = { x: event.clientX, y: event.clientY }
const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? {
...readFloorplanAnnotationLayoutOffset(label),
pinned: true,
}
const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true
let latest = existing
let moved = false
const onPointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== event.pointerId) return
moved = true
const local = screenVectorToFloorplanAnnotationLocal(
matrix,
moveEvent.clientX - start.x,
moveEvent.clientY - start.y,
)
latest = {
dx: existing.dx + local.x,
dy: existing.dy + local.y,
pinned: true,
}
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
label.setAttribute(
'transform',
`${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
)
}
const finishPointerDrag = (endEvent: PointerEvent) => {
if (endEvent.pointerId !== event.pointerId) return
cleanupPointerDrag?.()
label.style.cursor = moved || wasPinned ? 'grab' : 'move'
if (moved) setAnnotationLayoutOverride(id, latest)
}
const cancelPointerDrag = (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId !== event.pointerId) return
cancelActivePointerDrag?.()
}
cancelActivePointerDrag = () => {
cleanupPointerDrag?.()
label.style.cursor = wasPinned ? 'grab' : 'move'
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
label.setAttribute(
'transform',
`${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(),
)
}
cleanupPointerDrag = () => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', finishPointerDrag)
window.removeEventListener('pointercancel', cancelPointerDrag)
cleanupPointerDrag = null
cancelActivePointerDrag = null
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', finishPointerDrag)
window.addEventListener('pointercancel', cancelPointerDrag)
}
const onDoubleClick = (event: MouseEvent) => {
const label = findLabel(event.target)
if (!label) return
event.preventDefault()
event.stopPropagation()
label.style.cursor = 'move'
setAnnotationLayoutOverride(labelId(label), null)
}
registryLayer.addEventListener('pointerdown', onPointerDown)
registryLayer.addEventListener('dblclick', onDoubleClick)
return () => {
cancelActivePointerDrag?.()
registryLayer.removeEventListener('pointerdown', onPointerDown)
registryLayer.removeEventListener('dblclick', onDoubleClick)
for (const label of registryLayer.querySelectorAll<SVGGElement>(
'[data-floorplan-annotation-label]',
)) {
label.style.pointerEvents = ''
label.style.cursor = ''
}
}
}, [layoutEnabled, setAnnotationLayoutOverride])
return <g pointerEvents="none" ref={markerRef} />
}
function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
const determinant = matrix.a * matrix.d - matrix.b * matrix.c
if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
return {
x: (matrix.d * dx - matrix.c * dy) / determinant,
y: (-matrix.b * dx + matrix.a * dy) / determinant,
}
}
function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
return {
dx: Number.isFinite(dx) ? dx : 0,
dy: Number.isFinite(dy) ? dy : 0,
}
}
type FloorplanRegistryEntryProps = {
activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
annotationVisibility: FloorplanAnnotationVisibility
ctxOverrides: FloorplanContextOverrides | undefined
floorplanVisible: boolean
geometryCacheRef: { current: Map<string, CacheEntry> }
@@ -1453,6 +1693,8 @@ type FloorplanRegistryEntryProps = {
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
siblingEpoch: number
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
unitsPerPixel: number
visibilityRootId: AnyNodeId | undefined
}
@@ -1460,6 +1702,7 @@ type FloorplanRegistryEntryProps = {
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
activeDragId,
activeRotateNodeId,
annotationVisibility,
ctxOverrides,
floorplanVisible,
geometryCacheRef,
@@ -1493,6 +1736,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
setMovingNodeOrigin,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
unitsPerPixel,
visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null {
@@ -1599,16 +1844,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
selected,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
visibilityRootId,
})
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
const visibleGeometry = rawGeometry
? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
: null
// Multi-selection shows highlight only: strip this member's edit handles /
// dimension chrome (all of which live in the overlay pass) while keeping
// its highlighted body geometry.
const geometry =
rawGeometry && suppressHandles && pass === 'overlay'
? stripHandleChrome(rawGeometry)
: rawGeometry
visibleGeometry && suppressHandles && pass === 'overlay'
? stripHandleChrome(visibleGeometry)
: visibleGeometry
if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1664,6 +1914,8 @@ type BuildFloorplanEntryGeometryArgs = {
selected: boolean
siblingEpoch: number
unit: 'metric' | 'imperial'
metricNotation: 'meters' | 'millimeters'
wallDimensionReference: FloorplanWallDimensionReference
visibilityRootId: AnyNodeId | undefined
}
@@ -1707,6 +1959,8 @@ function buildFloorplanEntryGeometry({
selected,
siblingEpoch,
unit,
metricNotation,
wallDimensionReference,
visibilityRootId,
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
const def = nodeRegistry.get(node.type)
@@ -1731,6 +1985,8 @@ function buildFloorplanEntryGeometry({
node,
live,
unit,
metricNotation,
wallDimensionReference,
selected,
highlighted,
hovered,
@@ -1808,6 +2064,8 @@ function buildFloorplanEntryGeometry({
const viewState = {
selected,
unit,
metricNotation,
wallDimensionReference,
highlighted,
hovered,
moving,
@@ -1826,6 +2084,11 @@ function buildFloorplanEntryGeometry({
siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent,
levelData,
extensions: createFloorplanContextExtensions({
metricNotation,
purpose: 'edit',
wallDimensionReference,
}),
viewState: palette
? {
selected,
@@ -1925,7 +2188,7 @@ type InteractiveGeometryProps = {
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
}
const InteractiveGeometry = memo(function InteractiveGeometry({
export const InteractiveGeometry = memo(function InteractiveGeometry({
geometry,
unitsPerPixel,
palette,
@@ -1948,7 +2211,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
case 'group': {
const transform = formatGroupTransform(g.transform)
return (
<g key={keyHint} transform={transform}>
<g
data-floorplan-annotation-obstacle={
floorplanAnnotationObstacleMode(g) ??
(isFloorplanAnnotationObstacleGeometry(g) ? '' : undefined)
}
key={keyHint}
transform={transform}
>
{g.children.map((child, i) => renderInteractive(child, i))}
</g>
)
@@ -2499,11 +2769,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
return (
<g
data-floorplan-annotation-default-transform={labelTransform}
data-floorplan-annotation-label=""
data-floorplan-annotation-priority="20"
key={keyHint}
pointerEvents="none"
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
transform={labelTransform}
>
{outlined ? null : (
<rect
@@ -2598,147 +2872,13 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
}
case 'dimension': {
if (!palette) return <></>
const stroke = g.stroke ?? palette.measurementStroke
// Offset endpoints along the outward normal — this is where the
// dimension line sits, parallel to the edge.
const ox = g.offsetNormal[0] * g.offsetDistance
const oy = g.offsetNormal[1] * g.offsetDistance
const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
// Extension line endpoints — extend past the dimension line by
// `extensionOvershoot` so the tip clears the dimension stroke.
const eOvershoot = g.extensionOvershoot
const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
const dx = dEnd[0] - dStart[0]
const dy = dEnd[1] - dStart[1]
const length = Math.hypot(dx, dy)
if (length < 1e-6) return <></>
const dirX = dx / length
const dirY = dy / length
// Plan-unit constants matching the legacy `floorplan-
// measurements-layer.tsx`. `strokeWidth` is intentionally a
// raw value (not multiplied by `unitsPerPixel`) because every
// stroke here uses `vectorEffect: non-scaling-stroke` — the
// browser interprets it as screen-pixel-stable. Multiplying
// by `unitsPerPixel` would shrink the strokes by ~100× and
// make them invisible. Tick length, dash pattern, font size,
// and the label gap stay in plan units (they're geometry,
// not stroke width).
const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
const perpX = -dirY * tickHalf
const perpY = dirX * tickHalf
const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
const labelGap = 0.5 // plan units — gap in the dimension line for the label
const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
const midX = (dStart[0] + dEnd[0]) / 2
const midY = (dStart[1] + dEnd[1]) / 2
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
// Keep the label parallel to the dimension line, but decide the
// 180° flip from the on-SCREEN angle, not the local one. The parent
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
// plan), so a label kept upright in local coords still renders
// upside down for half of the wall orientations. Same fix as the
// `dimension-label` case above.
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
let screenDeg = labelDeg + sceneRotationDeg
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
if (screenDeg > 90) labelDeg -= 180
else if (screenDeg <= -90) labelDeg += 180
return (
<g key={keyHint} pointerEvents="none">
{/* Extension lines (dashed). */}
<line
stroke={stroke}
strokeDasharray="0.08 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.start[0]}
x2={eStartTip[0]}
y1={g.start[1]}
y2={eStartTip[1]}
/>
<line
stroke={stroke}
strokeDasharray="0.08 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={g.end[0]}
x2={eEndTip[0]}
y1={g.end[1]}
y2={eEndTip[1]}
/>
{/* Dimension line: two halves with the label in between. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0]}
x2={gapStart[0]}
y1={dStart[1]}
y2={gapStart[1]}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={gapEnd[0]}
x2={dEnd[0]}
y1={gapEnd[1]}
y2={dEnd[1]}
/>
{/* End ticks. */}
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dStart[0] - perpX}
x2={dStart[0] + perpX}
y1={dStart[1] - perpY}
y2={dStart[1] + perpY}
/>
<line
stroke={stroke}
strokeLinecap="round"
strokeWidth={1.35}
vectorEffect="non-scaling-stroke"
x1={dEnd[0] - perpX}
x2={dEnd[0] + perpX}
y1={dEnd[1] - perpY}
y2={dEnd[1] + perpY}
/>
{/* Rotated label centered in the gap. */}
<text
dominantBaseline="central"
fill={stroke}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={600}
textAnchor="middle"
transform={`rotate(${labelDeg} ${midX} ${midY})`}
x={midX}
y={midY}
>
{g.text}
</text>
</g>
<FloorplanDimensionRenderer
geometry={g}
key={keyHint}
sceneRotationDeg={sceneRotationDeg}
stroke={g.stroke ?? palette.measurementStroke}
/>
)
}
case 'text': {
@@ -2747,7 +2887,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
// horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°).
return (
<g key={keyHint} transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}>
<g
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
key={keyHint}
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
>
<text
dominantBaseline={g.dominantBaseline ?? 'middle'}
fill={g.fill ?? '#171717'}
@@ -2775,6 +2919,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
geometry={g}
key={keyHint}
pointerEventsOverride={isMarqueeSelectionActive ? 'none' : undefined}
sceneRotationDeg={sceneRotationDeg}
/>
)
}
@@ -2853,6 +2998,9 @@ export function buildContext(
viewState: {
selected: boolean
unit: 'metric' | 'imperial'
metricNotation?: 'meters' | 'millimeters'
purpose?: 'edit' | 'document'
wallDimensionReference?: FloorplanWallDimensionReference
highlighted: boolean
hovered: boolean
moving: boolean
@@ -2892,6 +3040,11 @@ export function buildContext(
siblings,
parent,
levelData,
extensions: createFloorplanContextExtensions({
metricNotation: viewState.metricNotation ?? 'meters',
purpose: viewState.purpose ?? 'edit',
wallDimensionReference: viewState.wallDimensionReference,
}),
viewState: viewState.palette
? {
selected: viewState.selected,
@@ -2905,6 +3058,29 @@ export function buildContext(
}
}
export function collectFloorplanLinkedLevelNodes(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
excludedIds: ReadonlySet<AnyNodeId> = new Set(),
): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
for (const [rawId, node] of Object.entries(nodes)) {
if (!node) continue
const definition = nodeRegistry.get(node.type)
const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
if (!definition?.floorplan || !linkedLevelIds) continue
const id = rawId as AnyNodeId
if (excludedIds.has(id)) continue
if (!linkedLevelIds(node).includes(levelId)) continue
const childIds = (node as { children?: AnyNodeId[] }).children
const children = Array.isArray(childIds)
? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
: []
linked.push({ id, node, children })
}
return linked
}
/**
* Stable id for a handle on a node, derived from the node id + opaque
* payload. Used to track hover / active visual state when multiple
@@ -2951,6 +3127,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'move-arrow',
'rotate-arrow',
'dimension',
'dimension-string',
'dimension-label',
'equal-spacing-badge',
])
@@ -2969,6 +3146,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
} {
if (isFloorplanAnnotationObstacleGeometry(g)) {
return { base: null, overlay: g }
}
if (OVERLAY_KINDS.has(g.kind)) {
return { base: null, overlay: g }
}
@@ -3130,6 +3310,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'node',
'live',
'unit',
'metricNotation',
'wallDimensionReference',
'selected',
'highlighted',
'hovered',
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
}
function getFloorplanStairBreakStep(stepCount: number) {
return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
}
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
return 0
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
const dashedFromIndex = Math.floor(stepCount * 0.68)
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return (
<line
key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none"
stroke={index === stepCount ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
return null
}
return (
<line
@@ -397,14 +405,16 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
strokeWidth={isSelectionActive ? '2' : '1.35'}
vectorEffect="non-scaling-stroke"
/>
{treadBars.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
{treadBars
.slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
</g>
))}
{arrow?.polyline && arrow.polyline.length >= 2 ? (
@@ -29,7 +29,9 @@ export function BakeExporter({
await nextFrames()
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) throw new Error('scene-renderer group not found')
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, {
textures: 'reference',
})
onComplete(buffer)
} catch (err) {
// The bake worker relays page console output into the job's error
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
describe('camera dragging lifecycle', () => {
test('releases wheel interactions even when camera controls never report rest', () => {
const dragging: boolean[] = []
let scheduled: (() => void) | null = null
const lifecycle = createCameraDraggingLifecycle({
setDragging: (value) => dragging.push(value),
schedule: (callback) => {
scheduled = callback
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>
},
cancel: () => {
scheduled = null
},
})
lifecycle.begin()
lifecycle.scheduleEnd()
expect(dragging).toEqual([true])
const release = scheduled as (() => void) | null
release?.()
expect(dragging).toEqual([true, false])
})
test('cancels a pending wheel release when another interaction begins', () => {
const dragging: boolean[] = []
let scheduled: (() => void) | null = null
const lifecycle = createCameraDraggingLifecycle({
setDragging: (value) => dragging.push(value),
schedule: (callback) => {
scheduled = callback
return 1 as unknown as ReturnType<typeof globalThis.setTimeout>
},
cancel: () => {
scheduled = null
},
})
lifecycle.begin()
lifecycle.scheduleEnd()
lifecycle.begin()
expect(scheduled).toBeNull()
expect(dragging).toEqual([true, true])
})
})
@@ -0,0 +1,41 @@
type TimerHandle = ReturnType<typeof globalThis.setTimeout>
export function createCameraDraggingLifecycle({
setDragging,
fallbackMs = 500,
schedule = globalThis.setTimeout,
cancel = globalThis.clearTimeout,
}: {
setDragging: (dragging: boolean) => void
fallbackMs?: number
schedule?: (callback: () => void, delay: number) => TimerHandle
cancel?: (timer: TimerHandle) => void
}) {
let releaseTimer: TimerHandle | null = null
const clearScheduledEnd = () => {
if (releaseTimer === null) return
cancel(releaseTimer)
releaseTimer = null
}
const begin = () => {
clearScheduledEnd()
setDragging(true)
}
const end = () => {
clearScheduledEnd()
setDragging(false)
}
const scheduleEnd = () => {
clearScheduledEnd()
releaseTimer = schedule(() => {
releaseTimer = null
setDragging(false)
}, fallbackMs)
}
return { begin, end, scheduleEnd }
}
@@ -35,6 +35,7 @@ import {
useEndpointReshape,
useMovingNode,
} from '../../store/use-interaction-scope'
import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle'
const currentTarget = new Vector3()
const tempBox = new Box3()
@@ -166,6 +167,10 @@ function isKeyboardPanKey(code: string): boolean {
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
}
function hasKeyboardPanInput(state: KeyboardPanState): boolean {
return state.forward || state.backward || state.left || state.right
}
type CameraViewportSize = {
width: number
height: number
@@ -420,6 +425,14 @@ export const CustomCameraControls = () => {
const gl = useThree((state) => state.gl)
const raycaster = useThree((state) => state.raycaster)
const viewportSize = useThree((state) => state.size)
const cameraDraggingLifecycle = useMemo(
() =>
createCameraDraggingLifecycle({
setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging),
}),
[],
)
useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle])
useEffect(() => {
camera.layers.enable(EDITOR_LAYER)
camera.layers.enable(GRID_LAYER)
@@ -444,10 +457,14 @@ export const CustomCameraControls = () => {
}
}, [freezeActivePoseInterpolation])
const beginLocalCameraInteraction = useCallback(() => {
cancelPoseApplication()
emitter.emit('camera-controls:interaction-start', undefined)
}, [cancelPoseApplication])
const beginLocalCameraInteraction = useCallback(
({ dragging = true }: { dragging?: boolean } = {}) => {
cancelPoseApplication()
if (dragging) cameraDraggingLifecycle.begin()
emitter.emit('camera-controls:interaction-start', undefined)
},
[cameraDraggingLifecycle, cancelPoseApplication],
)
const applyPendingPose = useCallback(() => {
if (isFirstPersonMode) {
@@ -1007,6 +1024,9 @@ export const CustomCameraControls = () => {
if (isKeyboardPanKey(event.code)) {
const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false)
if (changed) {
if (!hasKeyboardPanInput(keyboardPanKeys.current)) {
cameraDraggingLifecycle.end()
}
event.preventDefault()
event.stopPropagation()
}
@@ -1048,6 +1068,7 @@ export const CustomCameraControls = () => {
const onWheel = () => {
beginLocalCameraInteraction()
cameraDraggingLifecycle.scheduleEnd()
clearPendingFloorplanNavigationPose()
}
@@ -1067,6 +1088,7 @@ export const CustomCameraControls = () => {
panPointerId = null
panPointerButton = null
clearNavigationCursor()
cameraDraggingLifecycle.end()
updateConfig()
}
@@ -1089,9 +1111,11 @@ export const CustomCameraControls = () => {
gl.domElement.removeEventListener('wheel', onWheel, true)
clearKeyboardPanKeys()
clearNavigationCursor()
cameraDraggingLifecycle.end()
}
}, [
beginLocalCameraInteraction,
cameraDraggingLifecycle,
cameraMode,
gl,
isPreviewMode,
@@ -1102,10 +1126,18 @@ export const CustomCameraControls = () => {
// Cancel any in-progress 2D-origin navigation pose when the user starts
// dragging (right-click orbit, middle-click pan, touch). `controlstart`
// fires only for user pointer interactions — not for programmatic
// moveTo/rotateTo which emit `transitionstart` instead.
// moveTo/rotateTo which emit `transitionstart` instead. It also fires for
// pointerdowns whose button is mapped to ACTION.NONE (plain left click in
// edit mode); those must not flag the camera as dragging — no rest/sleep
// ever follows to clear the flag, which would leave canvas clicks
// (selection, placement) suppressed until the next real camera move.
const handleControlStart = useCallback(() => {
clearPendingFloorplanNavigationPose()
beginLocalCameraInteraction()
beginLocalCameraInteraction({
dragging: controls.current
? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE
: false,
})
}, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose])
// Preview mode: auto-navigate camera to selected node (viewer behavior)
@@ -1407,12 +1439,22 @@ export const CustomCameraControls = () => {
}, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode])
const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true)
}, [])
cameraDraggingLifecycle.begin()
}, [cameraDraggingLifecycle])
const onRest = useCallback(() => {
useViewer.getState().setCameraDragging(false)
}, [])
cameraDraggingLifecycle.end()
}, [cameraDraggingLifecycle])
const onControlEnd = useCallback(() => {
// A mapped-button tap with zero camera movement never wakes the
// controls, so no rest/sleep follows — clear the dragging flag on
// release. While damping is still settling (`active`), rest/sleep
// clears it instead.
if (!controls.current?.active) {
cameraDraggingLifecycle.end()
}
}, [cameraDraggingLifecycle])
// Preset capture mode frames a single subtree (often a 0.32m preset),
// so the default 2m minDistance prevents the user from getting close
@@ -1434,6 +1476,7 @@ export const CustomCameraControls = () => {
minDistance={minDistance}
minPolarAngle={0}
mouseButtons={mouseButtons}
onControlEnd={onControlEnd}
onControlStart={handleControlStart}
onUpdate={handleCameraUpdate}
onRest={onRest}
@@ -15,8 +15,11 @@ import {
getElevatorShaftDepth,
getElevatorShaftWallThickness,
getElevatorShaftWidth,
getLevelDisplayName,
getLevelElevations,
getResolvedElevatorDoorStyle,
openElevatorDoor,
pointInPolygon2D,
requestElevatorLevel,
resolveElevatorBuildingLevels,
resolveElevatorDispatchTarget,
@@ -25,7 +28,22 @@ import {
useInteractive,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
BVHEcctrl,
type BVHEcctrlApi,
CROUCH_CAPSULE,
CROUCH_EYE_OFFSET,
CROUCH_FLOAT_HEIGHT,
CROUCH_RUN_SPEED,
CROUCH_WALK_SPEED,
EYE_LERP_SPEED,
type MovementInput,
STAND_CAPSULE,
STAND_CLEARANCE,
STAND_FLOAT_HEIGHT,
useViewer,
WALKTHROUGH_FOV,
} from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -38,6 +56,7 @@ import {
Mesh,
MeshBasicMaterial,
type Object3D,
type PerspectiveCamera,
Ray,
Raycaster,
Vector2,
@@ -45,19 +64,22 @@ import {
} from 'three'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
import '../../three-types'
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
import {
closeDoorOpenState,
DOOR_SWING_OPEN_ANGLE,
getDisplayedDoorValue,
isOperationDoorType,
toggleDoorOpenState,
} from '../../lib/door-interaction'
import {
closeWindowOpenState,
getDisplayedWindowValue,
isOperableWindowType,
toggleWindowOpenState,
} from '../../lib/window-interaction'
import useEditor from '../../store/use-editor'
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
import { WalkthroughHud } from '../walkthrough-hud'
import {
buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn,
@@ -76,8 +98,8 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14
const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08
const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12
const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72
const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5
const VOID_FALL_RESPAWN_DEPTH = 12
const HUD_LABEL_SAMPLE_FRAMES = 10
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
@@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
canvas.focus({ preventScroll: true })
}
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraOffset = new Vector3()
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const standClearanceRaycaster = new Raycaster()
const standClearanceUp = new Vector3(0, 1, 0)
const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster()
const doorLeafBox = new Box3()
@@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const windowInteractionRaycaster = new Raycaster()
const hudBuildingLocalEyePosition = new Vector3()
const hudWorldEyePosition = new Vector3()
const hudLevelBounds = new Box3()
type ElevatorColliderKind =
| 'cab-back'
@@ -202,6 +229,128 @@ type ElevatorButtonTarget = {
levelId?: AnyNodeId
}
function getLevelChildren(
level: Extract<AnyNode, { type: 'level' }>,
nodes: Record<string, AnyNode>,
) {
const childIds = new Set<string>(level.children)
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
}
function pointIsInLevelFootprint(
point: [number, number],
worldPoint: Vector3,
level: Extract<AnyNode, { type: 'level' }>,
nodes: Record<string, AnyNode>,
) {
const children = getLevelChildren(level, nodes)
const slabs = children.filter(
(node): node is Extract<AnyNode, { type: 'slab' }> =>
node.type === 'slab' && node.polygon.length >= 3,
)
const zones = children.filter(
(node): node is Extract<AnyNode, { type: 'zone' }> =>
node.type === 'zone' && node.polygon.length >= 3,
)
if (slabs.length > 0) {
return slabs.some(
(slab) =>
pointInPolygon2D(point, slab.polygon) &&
!slab.holes.some((hole) => pointInPolygon2D(point, hole)),
)
}
if (zones.length > 0) {
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
}
const levelObject = sceneRegistry.nodes.get(level.id)
if (!levelObject) return false
hudLevelBounds.setFromObject(levelObject)
return (
!hudLevelBounds.isEmpty() &&
worldPoint.x >= hudLevelBounds.min.x &&
worldPoint.x <= hudLevelBounds.max.x &&
worldPoint.z >= hudLevelBounds.min.z &&
worldPoint.z <= hudLevelBounds.max.z
)
}
function resolveFirstPersonHudLabels(worldPoint: Vector3) {
const nodes = useScene.getState().nodes
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
for (const building of Object.values(nodes)) {
if (building.type !== 'building') continue
const buildingObject = sceneRegistry.nodes.get(building.id)
if (!buildingObject) continue
buildingObject.updateWorldMatrix(true, true)
hudBuildingLocalEyePosition.copy(worldPoint)
buildingObject.worldToLocal(hudBuildingLocalEyePosition)
const levels = Object.values(nodes)
.filter((node) => node.type === 'level')
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
.sort(
(left, right) =>
(levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
)
let activeLevel: (typeof levels)[number] | null = null
for (const level of levels) {
const elevation = levelElevations.get(level.id)
if (!elevation) continue
if (
hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
) {
activeLevel = level
}
}
if (!activeLevel) continue
const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
const zone = getLevelChildren(activeLevel, nodes).find(
(node) =>
node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
)
return {
floorLabel: getLevelDisplayName(activeLevel),
zoneLabel: zone?.type === 'zone' ? zone.name : null,
}
}
return { floorLabel: null, zoneLabel: null }
}
function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
if (!target) return null
if (target.type === 'elevator') {
return {
label: target.action === 'open-door' ? 'door button' : 'elevator button',
verb: 'press',
}
}
const node = useScene.getState().nodes[target.id]
if (target.type === 'window') {
if (node?.type !== 'window') return null
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
}
if (node?.type !== 'door') return null
const isOpen = isOperationDoorType(node.doorType)
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
}
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
let current: Object3D | null = object
@@ -281,37 +430,17 @@ function isInsideElevatorCab(
)
}
function getFirstPersonLevelHeight(levelId: string, nodes: Record<string, AnyNode>) {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as AnyNodeId]
if (!child) continue
if (child.type === 'ceiling') {
maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)
continue
}
if (child.type === 'wall') {
const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0)
maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT))
}
}
return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT
}
function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record<string, AnyNode>) {
const allLevels = resolveElevatorBuildingLevels(elevator, nodes)
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
const baseYByLevelId = new Map<string, number>()
let cumulativeY = 0
for (const level of allLevels) {
baseYByLevelId.set(level.id, cumulativeY)
cumulativeY += getFirstPersonLevelHeight(level.id, nodes)
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)
@@ -573,6 +702,11 @@ export const FirstPersonControls = () => {
const yawRef = useRef(0)
const pitchRef = useRef(0)
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
const crouchKeyRef = useRef(false)
const suspendRef = useRef(false)
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
const [crouched, setCrouched] = useState(false)
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
const ridingElevatorRef = useRef<{
elevatorId: AnyNodeId
@@ -589,6 +723,35 @@ export const FirstPersonControls = () => {
yaw: number
} | null>(null)
useEffect(() => {
const previousCameraMode = useViewer.getState().cameraMode
if (previousCameraMode === 'orthographic') {
useViewer.getState().setCameraMode('perspective')
}
return () => {
if (previousCameraMode === 'orthographic') {
useViewer.getState().setCameraMode('orthographic')
}
}
}, [])
useEffect(() => {
const perspectiveCamera = camera as PerspectiveCamera
if (!perspectiveCamera.isPerspectiveCamera) return
const previousFov = perspectiveCamera.fov
perspectiveCamera.fov = WALKTHROUGH_FOV
perspectiveCamera.updateProjectionMatrix()
return () => {
perspectiveCamera.fov = previousFov
perspectiveCamera.updateProjectionMatrix()
}
}, [camera])
useEffect(() => {
useFirstPersonHud.getState().reset()
return () => useFirstPersonHud.getState().reset()
}, [])
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
worldRef.current?.dispose()
worldRef.current = nextWorld
@@ -999,9 +1162,15 @@ export const FirstPersonControls = () => {
const isLocked = document.pointerLockElement === canvas
if (isLocked) {
hadPointerLockRef.current = true
suspendRef.current = false
useViewer.getState().setWalkthroughSuspended(false)
return
}
// Deliberately released (screenshot pause) — stay in first person;
// clicking the canvas re-locks.
if (suspendRef.current) return
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
useEditor.getState().setFirstPersonMode(false)
}
@@ -1018,6 +1187,7 @@ export const FirstPersonControls = () => {
document.removeEventListener('click', handleClick)
document.removeEventListener('mousedown', handleMouseDown, true)
document.removeEventListener('pointerlockchange', handlePointerLockChange)
useViewer.getState().setWalkthroughSuspended(false)
if (document.pointerLockElement === canvas) {
document.exitPointerLock()
}
@@ -1049,7 +1219,11 @@ export const FirstPersonControls = () => {
return
}
if (event.code === 'Escape') {
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
// screenshot) must not toggle it under the user.
if (!suspendRef.current) crouchKeyRef.current = true
} else if (event.code === 'Escape') {
event.preventDefault()
event.stopPropagation()
if (document.pointerLockElement === canvas) {
@@ -1064,18 +1238,41 @@ export const FirstPersonControls = () => {
event.preventDefault()
event.stopPropagation()
closeInteractableTarget()
} else if (event.code === 'KeyP') {
// P toggles a cursor pause (advertised in the HUD): frees the pointer
// without leaving first person — e.g. for an OS screenshot, which
// needs a movable cursor — and click or P resumes.
event.preventDefault()
event.stopPropagation()
if (document.pointerLockElement === canvas) {
suspendRef.current = true
useViewer.getState().setWalkthroughSuspended(true)
document.exitPointerLock()
} else if (suspendRef.current) {
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
if (result && typeof result.catch === 'function') result.catch(() => {})
}
}
}
const handleKeyUp = (event: KeyboardEvent) => {
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
crouchKeyRef.current = false
}
applyMovementKey(event, false)
}
const handleBlur = () => {
if (!suspendRef.current) crouchKeyRef.current = false
}
document.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('keyup', handleKeyUp, true)
window.addEventListener('blur', handleBlur)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('keyup', handleKeyUp, true)
window.removeEventListener('blur', handleBlur)
}
}, [closeInteractableTarget, gl, toggleInteractableTarget])
@@ -1301,11 +1498,33 @@ export const FirstPersonControls = () => {
[camera, setElevatorRideLocked],
)
useFrame(() => {
const hasStandingClearance = useCallback((position: Vector3) => {
standClearanceRaycaster.set(position, standClearanceUp)
standClearanceRaycaster.far = STAND_CLEARANCE
const meshes: Mesh[] = []
if (worldRef.current) meshes.push(worldRef.current.mesh)
for (const mesh of elevatorColliderMeshesRef.current) {
if (mesh.visible) meshes.push(mesh)
}
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
}, [])
useFrame((_, delta) => {
if (!controllerRef.current?.group) return
const group = controllerRef.current.group
// Crouch follows the held key; standing back up waits for headroom.
// Frozen while the cursor pause is active.
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
if (crouchKeyRef.current) setCrouched(true)
else if (hasStandingClearance(group.position)) setCrouched(false)
}
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
eyeOffsetRef.current +=
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
cameraOffset.set(0, eyeOffsetRef.current, 0)
// The site ground collider is effectively unbounded, but scenes without a
// site node only have finite fallback floors — if the controller still ends
// up below every collider it can never land, so put it back at the spawn.
@@ -1346,6 +1565,17 @@ export const FirstPersonControls = () => {
interactableTargetRef.current = nextInteractableTarget
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
}
useFirstPersonHud.getState().setHud({
interact: resolveHudInteract(nextInteractableTarget),
})
hudLabelFrameRef.current += 1
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
hudLabelFrameRef.current = 0
camera.getWorldPosition(hudWorldEyePosition)
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
}
}, 2.5)
useEffect(() => {
@@ -1372,7 +1602,7 @@ export const FirstPersonControls = () => {
<BVHEcctrl
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderCapsuleArgs={crouched ? CROUCH_CAPSULE : STAND_CAPSULE}
colliderMeshes={firstPersonColliderMeshes}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
@@ -1383,16 +1613,16 @@ export const FirstPersonControls = () => {
fallGravityFactor={4}
floatCheckType="BOTH"
floatDampingC={36}
floatHeight={0.5}
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
floatPullBackHeight={0.35}
floatSensorRadius={0.15}
floatSpringK={1200}
gravity={9.81}
jumpVel={5}
key="first-person-controller"
maxRunSpeed={5}
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
maxSlope={1.2}
maxWalkSpeed={2}
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
paused={isElevatorRideLocked}
position={controllerStart.position}
ref={setControllerApi}
@@ -1403,27 +1633,14 @@ export const FirstPersonControls = () => {
)
}
/**
* Overlay UI for first-person mode: crosshair, controls hint, exit button.
* Rendered as a regular DOM overlay (not inside the Canvas).
*/
export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
const [isLocked, setIsLocked] = useState(false)
const hasPlacedSpawn = useScene((state) =>
Object.values(state.nodes).some((node) => node.type === 'spawn'),
)
useEffect(() => {
const handlePointerLockChange = () => {
setIsLocked(document.pointerLockElement != null)
}
handlePointerLockChange()
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => {
document.removeEventListener('pointerlockchange', handlePointerLockChange)
}
}, [])
const floorLabel = useFirstPersonHud((state) => state.floorLabel)
const zoneLabel = useFirstPersonHud((state) => state.zoneLabel)
const interact = useFirstPersonHud((state) => state.interact)
const suspended = useViewer((state) => state.walkthroughSuspended)
const handleExit = useCallback(() => {
if (document.pointerLockElement) {
@@ -1433,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
}, [onExit])
return (
<>
{isLocked && (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
<div className="relative h-7 w-7">
<div className="absolute top-1/2 left-1/2 h-px w-7 -translate-x-1/2 -translate-y-1/2 bg-white/60" />
<div className="absolute top-1/2 left-1/2 h-7 w-px -translate-x-1/2 -translate-y-1/2 bg-white/60" />
</div>
</div>
)}
<div className="absolute top-4 right-4 z-50">
<button
className="pointer-events-auto flex items-center gap-2 rounded-xl border border-border/40 bg-background/90 px-4 py-2 font-medium text-foreground text-sm shadow-lg backdrop-blur-xl transition-colors hover:bg-background"
onClick={handleExit}
type="button"
>
<kbd className="rounded border border-border/50 bg-accent/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
ESC
</kbd>
Exit Street View
</button>
</div>
<WalkthroughHud
floorLabel={floorLabel}
interact={interact}
onExit={handleExit}
suspended={suspended}
zoneLabel={zoneLabel}
>
{!hasPlacedSpawn && (
<div className="absolute top-4 left-1/2 z-50 -translate-x-1/2">
<div className="rounded-2xl border border-sky-300/35 bg-slate-950/88 px-4 py-2 text-center text-slate-100 text-sm shadow-lg backdrop-blur-xl">
Place a Spawn Point from the Build tab to control where walkthrough starts.
</div>
<div className="corner-smooth rounded-full border border-border/40 bg-background/80 px-3 py-1 text-center text-muted-foreground text-xs shadow-elevation-3 backdrop-blur-xl">
Place a spawn point from the Build tab to control where walkthrough starts.
</div>
)}
{isLocked && (
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
</span>
</div>
</div>
)}
</>
)
}
function ControlHint({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex flex-col items-center gap-1.5 text-center">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em]">
{label}
</span>
<div className="flex flex-wrap items-center justify-center gap-1">
{keys.map((key) => (
<kbd
className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1 font-mono text-[10px] text-foreground/80 leading-none"
key={key}
>
{key}
</kbd>
))}
</div>
</div>
)
}
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
{keyLabel}
</kbd>
</div>
</WalkthroughHud>
)
}
@@ -6,7 +6,6 @@ import {
type CeilingNode,
ColumnNode,
createSceneApi,
DEFAULT_WALL_HEIGHT,
DoorNode,
ElevatorNode,
emitter,
@@ -15,6 +14,7 @@ import {
getActiveRoofHeight,
getEffectiveNode,
getWallCurveLength,
getWallEffectiveHeightForNodes,
getWallThickness,
ItemNode,
isCurvedWall,
@@ -271,7 +271,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
} {
if (node.type === 'wall') {
return {
height: node.height ?? DEFAULT_WALL_HEIGHT,
height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes),
length: getWallCurveLength(node),
thickness: getWallThickness(node),
}
@@ -413,7 +413,10 @@ export function FloatingActionMenu() {
const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
| { height?: number }
| undefined
const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
const fallbackHeight =
node.type === 'wall'
? getWallEffectiveHeightForNodes(node, useScene.getState().nodes)
: FENCE_DEFAULT_HEIGHT
const liveHeight = override?.height ?? node.height ?? fallbackHeight
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test'
import {
canApplyFloorplanNavigationSync,
canZoomFloorplanDuringNavigation,
finalizeFloorplanNavigation,
resolveFloorplanPresentationViewBox,
} from './floorplan-navigation-presentation'
describe('floorplan navigation presentation', () => {
test('keeps the imperative viewBox authoritative during navigation', () => {
const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 }
const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 }
expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, true)).toBe(
imperativeViewBox,
)
expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, false)).toBe(
reactViewBox,
)
})
test('does not mix wheel zoom with a compositor rotation preview', () => {
expect(canZoomFloorplanDuringNavigation(true)).toBe(false)
expect(canZoomFloorplanDuringNavigation(false)).toBe(true)
})
test('does not apply synchronized camera poses over local navigation', () => {
expect(canApplyFloorplanNavigationSync(true)).toBe(false)
expect(canApplyFloorplanNavigationSync(false)).toBe(true)
})
test('commits every active navigation channel before teardown', () => {
const calls: string[] = []
const rotationState = { angle: 42 }
finalizeFloorplanNavigation({
zoomPending: true,
panActive: true,
rotationState,
commitZoom: () => calls.push('zoom'),
commitPan: () => calls.push('pan'),
commitRotation: (state) => calls.push(`rotation:${state.angle}`),
})
expect(calls).toEqual(['zoom', 'pan', 'rotation:42'])
})
})

Some files were not shown because too many files have changed in this diff Show More