diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index bb476542..1ec86d04 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -1,7 +1,12 @@
'use client'
import { nodeRegistry } from '@pascal-app/core'
-import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
+import {
+ getFloorplanNodeExtension,
+ MaterialPaintPanel,
+ triggerSFX,
+ useEditor,
+} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
import Image from 'next/image'
import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -13,24 +18,6 @@ import {
} from '@/components/toolbar-tooltip'
import { cn } from '@/lib/utils'
-/**
- * Raw structure-tool kinds the Build tab can activate. These map 1:1 to the
- * editor's `StructureTool` ids.
- */
-type BuildToolKind =
- | 'wall'
- | 'fence'
- | 'slab'
- | 'ceiling'
- | 'roof'
- | 'stair'
- | 'elevator'
- | 'door'
- | 'window'
- | 'column'
- | 'shelf'
- | 'spawn'
-
/**
* MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP"
* group tile — its own sub-grid, like Roof's "Features".
@@ -53,7 +40,8 @@ type BuildType = {
/** Raster asset tile (legacy Build sidebar artwork). */
iconSrc: string
/** Present for structure-tool types (absent for paint mode and the MEP group). */
- kind?: BuildToolKind
+ kind?: string
+ paletteOrder?: number
/** Non-placement special mode. */
mode?: 'material-paint'
}
@@ -67,7 +55,7 @@ type MepItem = {
}
// Same icons + ordering as the community Build sidebar, minus presets.
-const BUILD_TYPES: BuildType[] = [
+const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' },
{ id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' },
{ id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' },
@@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
]
+function collectBuildTypes(): BuildType[] {
+ const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : [])))
+ const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({
+ ...type,
+ paletteOrder:
+ nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10,
+ }))
+ for (const [kind, definition] of nodeRegistry.entries()) {
+ const presentation = definition.presentation
+ const extension = getFloorplanNodeExtension(definition)
+ if (
+ baseKinds.has(kind) ||
+ !extension?.tool ||
+ !presentation ||
+ presentation.hidden ||
+ presentation.paletteSection !== 'structure'
+ ) {
+ continue
+ }
+ tools.push({
+ id: kind,
+ kind,
+ label: presentation.label,
+ iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp',
+ paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER,
+ })
+ }
+ tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0))
+ return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)]
+}
+
// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP
// tools had in the community Build sidebar.
const MEP_ITEMS: MepItem[] = [
@@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
*/
-function activateBuildTool(kind: BuildToolKind | MepToolKind): void {
+function activateBuildTool(kind: string): void {
const ed = useEditor.getState()
+ const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView
+ if (preferredView) ed.setViewMode(preferredView)
ed.setPhase('structure')
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
@@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void {
ed.setStructureLayer('elements')
ed.setCatalogCategory(null)
ed.setMode('build')
- ed.setTool(kind as Parameters
[0])
+ ed.setTool(kind)
}
/**
@@ -165,6 +186,7 @@ export function BuildTab() {
const mode = useEditor((s) => s.mode)
const follow = useLiquidLineToolOptions((s) => s.follow)
const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow)
+ const buildTypes = useMemo(collectBuildTypes, [])
// The fitting / follow tools are armed from a segment's panel, not a grid
// tile — keep the segment tile lit so the panel (and the way back) stays
@@ -245,9 +267,9 @@ export function BuildTab() {
didInitRef.current = true
const ed = useEditor.getState()
if (ed.mode === 'build' && ed.tool) return
- const firstType = BUILD_TYPES.find((t) => t.kind)
+ const firstType = buildTypes.find((t) => t.kind)
if (firstType) handleTypeClick(firstType)
- }, [handleTypeClick])
+ }, [buildTypes, handleTypeClick])
return (
@@ -256,7 +278,7 @@ export function BuildTab() {
className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }}
>
- {BUILD_TYPES.map((type) => {
+ {buildTypes.map((type) => {
const active = isTypeActive(type)
return (
diff --git a/apps/editor/components/floorplan-construction-preflight.tsx b/apps/editor/components/floorplan-construction-preflight.tsx
new file mode 100644
index 00000000..19d30b85
--- /dev/null
+++ b/apps/editor/components/floorplan-construction-preflight.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+import { useScene } from '@pascal-app/core'
+import { useFloorplanPreflight } from '@pascal-app/editor'
+import {
+ buildClearanceAdvisories,
+ buildConstructionModuleAdvisories,
+ buildDimensionCompletenessAudit,
+} from '@pascal-app/nodes'
+import { useEffect, useMemo, useState } from 'react'
+
+export function FloorplanConstructionPreflight() {
+ const nodes = useDebouncedSceneNodes()
+ const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled)
+ const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled)
+ const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues)
+
+ const issues = useMemo(() => {
+ const completeness = buildDimensionCompletenessAudit(nodes, {
+ includeAutomaticDimensions: true,
+ }).map((issue) => ({
+ id: issue.id,
+ kind: 'dimension-completeness' as const,
+ severity: issue.severity,
+ message: issue.message,
+ }))
+ const clearance = clearanceChecksEnabled
+ ? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
+ id: issue.id,
+ kind: 'clearance-advisory' as const,
+ severity: issue.severity,
+ message: issue.message,
+ }))
+ : []
+ const modules = moduleChecksEnabled
+ ? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({
+ id: issue.id,
+ kind: 'module-advisory' as const,
+ severity: issue.severity,
+ message: issue.message,
+ }))
+ : []
+ return [...completeness, ...clearance, ...modules]
+ }, [clearanceChecksEnabled, moduleChecksEnabled, nodes])
+
+ useEffect(() => {
+ setAuditIssues(issues)
+ return () => setAuditIssues([])
+ }, [issues, setAuditIssues])
+
+ return null
+}
+
+function useDebouncedSceneNodes() {
+ const [nodes, setNodes] = useState(() => useScene.getState().nodes)
+
+ useEffect(() => {
+ let pending: ReturnType | undefined
+ const unsubscribe = useScene.subscribe((state) => {
+ if (pending) clearTimeout(pending)
+ pending = setTimeout(() => setNodes(state.nodes), 100)
+ })
+ return () => {
+ if (pending) clearTimeout(pending)
+ unsubscribe()
+ }
+ }, [])
+
+ return nodes
+}
diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx
index d4443a32..77809e10 100644
--- a/apps/editor/components/viewer-toolbar.tsx
+++ b/apps/editor/components/viewer-toolbar.tsx
@@ -2,6 +2,7 @@
import { Icon as IconifyIcon } from '@iconify/react'
import {
+ DRAWING_TYPE_OPTIONS,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -10,7 +11,9 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
+ useDrawingView,
useEditor,
+ useFloorplanAnnotationVisibility,
useSidebarStore,
type ViewMode,
} from '@pascal-app/editor'
@@ -32,12 +35,16 @@ import {
EyeOff,
Footprints,
Grid2X2,
+ Layers3,
Magnet,
PenLine,
Ruler,
+ ScanLine,
SlidersHorizontal,
Sparkles,
+ SquareUserRound,
SwatchBook,
+ Tag,
} from 'lucide-react'
import Image from 'next/image'
import { type ReactNode, useCallback } from 'react'
@@ -133,6 +140,22 @@ const SHADING_OPTIONS = [
{ id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles },
] as const
+const FLOORPLAN_ANNOTATION_OPTIONS = [
+ { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler },
+ { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler },
+ { id: 'measurements', name: 'Measurements', icon: ScanLine },
+ { id: 'openingMarks', name: 'Door/window marks', icon: Tag },
+ { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 },
+ { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound },
+ { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints },
+] as const
+
+const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [
+ { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' },
+ { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' },
+ { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' },
+] as const
+
function ViewModeControl() {
const viewMode = useEditor((state) => state.viewMode)
const setViewMode = useEditor((state) => state.setViewMode)
@@ -165,6 +188,49 @@ function ViewModeControl() {
)
}
+function DrawingTypeControl() {
+ const viewMode = useEditor((state) => state.viewMode)
+ const drawingType = useDrawingView((state) => state.drawingType)
+ const setDrawingType = useDrawingView((state) => state.setDrawingType)
+ if (viewMode === '3d') return null
+
+ const active =
+ DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0]
+
+ return (
+
+
+
+
+
+
+
+
+ {DRAWING_TYPE_OPTIONS.map((option) => (
+ setDrawingType(option.id)}>
+
+ {option.label}
+ {drawingType === option.id ? : null}
+
+ ))}
+
+
+
+ )
+}
+
function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)
@@ -278,12 +344,15 @@ const EDGE_OPTIONS = [
const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl'
function DisplayMenu() {
+ const viewMode = useEditor((state) => state.viewMode)
const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid)
const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit)
+ const metricNotation = useViewer((state) => state.metricNotation)
+ const setMetricNotation = useViewer((state) => state.setMetricNotation)
const cameraMode = useViewer((state) => state.cameraMode)
const setCameraMode = useViewer((state) => state.setCameraMode)
const shading = useViewer((state) => state.shading)
@@ -296,6 +365,14 @@ function DisplayMenu() {
const setShadows = useViewer((state) => state.setShadows)
const magneticSnap = useEditor((state) => state.magneticSnap)
const setMagneticSnap = useEditor((state) => state.setMagneticSnap)
+ const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility)
+ const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory)
+ const wallDimensionReference = useFloorplanAnnotationVisibility(
+ (state) => state.wallDimensionReference,
+ )
+ const setWallDimensionReference = useFloorplanAnnotationVisibility(
+ (state) => state.setWallDimensionReference,
+ )
const activeShading =
SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0]
@@ -337,17 +414,82 @@ function DisplayMenu() {
)}
- keepOpen(e, () => setShowMeasurements(!showMeasurements))}
- >
-
- Measurements
- {showMeasurements ? (
-
- ) : (
-
- )}
-
+ {viewMode !== '2d' ? (
+ keepOpen(e, () => setShowMeasurements(!showMeasurements))}
+ >
+
+ {viewMode === 'split' ? '3D measurements' : 'Measurements'}
+ {showMeasurements ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+ {viewMode !== '3d' ? (
+ <>
+
+
+
+ Floor plan annotations
+
+
+ {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => {
+ const OptionIcon = option.icon
+ const visible = annotationVisibility[option.id]
+ return (
+
+ keepOpen(e, () => setAnnotationCategory(option.id, !visible))
+ }
+ >
+
+ {option.name}
+ {visible ? (
+
+ ) : (
+
+ )}
+
+ )
+ })}
+
+
+
+
+
+ Wall dimensions
+
+ {
+ FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find(
+ (option) => option.id === wallDimensionReference,
+ )?.name
+ }
+
+
+
+ {FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => (
+
+ keepOpen(event, () => setWallDimensionReference(option.id))
+ }
+ >
+
+ {option.name}
+ {option.detail}
+
+ {wallDimensionReference === option.id ? (
+
+ ) : null}
+
+ ))}
+
+
+ >
+ ) : null}
keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
Magnetic snap
@@ -377,17 +519,48 @@ function DisplayMenu() {
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
- keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))}
- >
-
- {unit === 'metric' ? 'm' : 'ft'}
-
- Units
-
- {unit === 'metric' ? 'Metric' : 'Imperial'}
-
-
+
+
+
+ {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'}
+
+ Units
+
+ {unit === 'imperial'
+ ? 'Feet & inches'
+ : metricNotation === 'millimeters'
+ ? 'Millimeters'
+ : 'Meters'}
+
+
+
+ setMetricNotation('meters')}>
+
+ m
+
+ Meters
+ {unit === 'metric' && metricNotation === 'meters' ? (
+
+ ) : null}
+
+ setMetricNotation('millimeters')}>
+
+ mm
+
+ Millimeters
+ {unit === 'metric' && metricNotation === 'millimeters' ? (
+
+ ) : null}
+
+ setUnit('imperial')}>
+
+ ft
+
+ Feet & inches
+ {unit === 'imperial' ? : null}
+
+
+
@@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() {
<>
+
>
)
}
diff --git a/apps/editor/public/icons/structural-grid.webp b/apps/editor/public/icons/structural-grid.webp
new file mode 100644
index 00000000..bc5ec505
Binary files /dev/null and b/apps/editor/public/icons/structural-grid.webp differ
diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts
index 9edff1c7..c4b7818f 100644
--- a/apps/ifc-converter/next-env.d.ts
+++ b/apps/ifc-converter/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/types/routes.d.ts";
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/bun.lock b/bun.lock
index 2a57c1e6..5cbd6c7a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -98,7 +98,7 @@
},
"packages/core": {
"name": "@pascal-app/core",
- "version": "0.9.1",
+ "version": "0.9.2",
"dependencies": {
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
@@ -124,7 +124,7 @@
},
"packages/editor": {
"name": "@pascal-app/editor",
- "version": "0.9.1",
+ "version": "0.9.2",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -145,35 +145,37 @@
"@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",
"zustand": "^5.0.11",
},
"devDependencies": {
- "@pascal-app/core": "^0.9.1",
- "@pascal-app/viewer": "^0.9.1",
+ "@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",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^0.9.1",
- "@pascal-app/viewer": "^0.9.1",
+ "@pascal-app/core": "^0.9.2",
+ "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
@@ -201,7 +203,7 @@
},
"packages/ifc-converter": {
"name": "@pascal-app/ifc-converter",
- "version": "0.1.1",
+ "version": "0.1.2",
"dependencies": {
"@pascal-app/core": "*",
"nanoid": "^5.1.6",
@@ -215,7 +217,7 @@
},
"packages/mcp": {
"name": "@pascal-app/mcp",
- "version": "0.3.1",
+ "version": "0.3.2",
"bin": {
"pascal-mcp": "./dist/bin/pascal-mcp.js",
},
@@ -225,22 +227,22 @@
"zod": "^4.3.5",
},
"devDependencies": {
- "@pascal-app/core": "^0.9.1",
+ "@pascal-app/core": "^0.9.2",
"@pascal/typescript-config": "*",
"@types/node": "^22.19.20",
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^0.9.1",
+ "@pascal-app/core": "^0.9.2",
},
},
"packages/nodes": {
"name": "@pascal-app/nodes",
- "version": "0.1.0",
+ "version": "0.1.1",
"devDependencies": {
- "@pascal-app/core": "^0.9.0",
- "@pascal-app/editor": "^0.9.0",
- "@pascal-app/viewer": "^0.9.0",
+ "@pascal-app/core": "^0.9.2",
+ "@pascal-app/editor": "^0.9.2",
+ "@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/node": "^22.19.12",
@@ -249,9 +251,9 @@
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^0.9.0",
- "@pascal-app/editor": "^0.9.0",
- "@pascal-app/viewer": "^0.9.0",
+ "@pascal-app/core": "^0.9.2",
+ "@pascal-app/editor": "^0.9.2",
+ "@pascal-app/viewer": "^0.9.2",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"lucide-react": "^1",
@@ -283,7 +285,7 @@
},
"packages/viewer": {
"name": "@pascal-app/viewer",
- "version": "0.9.1",
+ "version": "0.9.2",
"dependencies": {
"three-bvh-csg": "^0.0.18",
"three-mesh-bvh": "^0.9.8",
@@ -297,7 +299,7 @@
"typescript": "6.0.3",
},
"peerDependencies": {
- "@pascal-app/core": "^0.9.1",
+ "@pascal-app/core": "^0.9.2",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
@@ -538,6 +540,10 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="],
+ "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
+
+ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
+
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
@@ -852,6 +858,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
+ "@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="],
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
@@ -868,9 +876,7 @@
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
- "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
-
- "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
+ "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
@@ -882,8 +888,6 @@
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
- "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
-
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -978,8 +982,6 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
- "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
-
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="],
@@ -988,12 +990,20 @@
"bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="],
+ "blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="],
+
+ "blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="],
+
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+ "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="],
+
+ "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="],
+
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
@@ -1014,8 +1024,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
- "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
-
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
@@ -1030,6 +1038,8 @@
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
+ "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
+
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
@@ -1056,18 +1066,12 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
- "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
-
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
- "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
-
- "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
-
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -1100,9 +1104,9 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
- "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
+ "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="],
- "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="],
+ "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
@@ -1202,8 +1206,6 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
- "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
-
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -1232,7 +1234,7 @@
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
- "font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="],
+ "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
@@ -1302,8 +1304,6 @@
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
- "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
-
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1330,8 +1330,6 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
- "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
-
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@@ -1404,6 +1402,8 @@
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
+ "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="],
+
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
@@ -1422,8 +1422,6 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
- "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
-
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@@ -1460,6 +1458,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+ "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
+
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -1580,7 +1580,7 @@
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
- "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
+ "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
@@ -1598,7 +1598,7 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
- "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="],
+ "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -1606,6 +1606,8 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+ "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="],
+
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
@@ -1632,8 +1634,6 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
- "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
-
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
@@ -1660,8 +1660,6 @@
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
- "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
-
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1674,9 +1672,9 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
- "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+ "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
- "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
+ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -1726,10 +1724,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
- "specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
-
- "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
-
"stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="],
"stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="],
@@ -1768,12 +1762,6 @@
"suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="],
- "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="],
-
- "svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="],
-
- "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="],
-
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
@@ -1782,8 +1770,6 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
- "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
-
"three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
@@ -1792,6 +1778,8 @@
"three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="],
+ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
+
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
@@ -1844,6 +1832,10 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+ "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="],
+
+ "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
+
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
@@ -1860,8 +1852,6 @@
"utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="],
- "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
-
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -1962,6 +1952,8 @@
"agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
+ "browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
+
"conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="],
"deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1978,6 +1970,8 @@
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+ "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
+
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
diff --git a/design-qa.md b/design-qa.md
new file mode 100644
index 00000000..8ed33c4f
--- /dev/null
+++ b/design-qa.md
@@ -0,0 +1,64 @@
+**Comparison Target**
+
+- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`.
+- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`.
+- Viewport: 1280 × 720.
+- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator.
+- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions.
+
+**Full-view Comparison Evidence**
+
+- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text.
+- The larger left perimeter door has no room-side width dimension in the source state.
+- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading.
+
+**Focused-region Comparison Evidence**
+
+- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall.
+- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`.
+- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door.
+- A same-state rendered focused comparison is blocked by the local loading state.
+
+**Findings**
+
+- [P0] Browser-rendered implementation evidence unavailable.
+ Location: local editor preview.
+ Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected.
+ Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted.
+ Fix: restore a working local scene preview and recapture the reported room in 2D.
+
+**Required Fidelity Surfaces**
+
+- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection.
+- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection.
+- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection.
+- Image quality and asset fidelity: no image assets are involved.
+- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included.
+
+**Comparison History**
+
+- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls.
+- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls.
+- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked.
+
+**Implementation Evidence**
+
+- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed.
+- Nodes package build: passed.
+- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`.
+- Biome check: passed.
+- Git diff whitespace check: passed.
+- Browser console warnings/errors: none reported.
+
+**Implementation Checklist**
+
+- Restore the local editor preview.
+- Reopen the reported room in 2D.
+- Confirm each internal string has a visible parallel baseline, witness lines, and ticks.
+- Confirm the large left-side door displays its room-side width dimension.
+
+**Follow-up Polish**
+
+- Reassess internal line contrast only after the corrected geometry can be seen in the target scene.
+
+final result: blocked
diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts
index 78bc2619..25aed8e7 100644
--- a/packages/core/src/events/bus.ts
+++ b/packages/core/src/events/bus.ts
@@ -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
export type SpawnEvent = NodeEvent
export type CeilingEvent = NodeEvent
export type ColumnEvent = NodeEvent
+export type ConstructionDimensionEvent = NodeEvent
export type RoofEvent = NodeEvent
export type RoofSegmentEvent = NodeEvent
export type StairEvent = NodeEvent
export type StairSegmentEvent = NodeEvent
+export type StructuralGridEvent = NodeEvent
export type WindowEvent = NodeEvent
export type DoorEvent = NodeEvent
export type ElevatorEvent = NodeEvent
@@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent
export type SkylightEvent = NodeEvent
export type DormerEvent = NodeEvent
export type DownspoutEvent = NodeEvent
+export type DrawingSheetEvent = NodeEvent
export type DuctSegmentEvent = NodeEvent
export type DuctFittingEvent = NodeEvent
export type DuctTerminalEvent = NodeEvent
@@ -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> &
diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts
new file mode 100644
index 00000000..9ad31a19
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts
@@ -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 {
+ 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 {
+ return {
+ start: [0.5, 1.5],
+ end: [3.5, 1.5],
+ thickness: 0.08,
+ parentId: LEVEL_ID,
+ ...overrides,
+ }
+}
+
+function nodesFor(...nodes: AnyNode[]): Record {
+ return Object.fromEntries(nodes.map((node) => [node.id, node]))
+}
+
+function sceneWith(...slabs: SlabNode[]): Record {
+ 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,
+ })
+ })
+})
diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
index f0dfe76f..ebdb24c3 100644
--- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
+++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts
@@ -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)
diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
index b0b8315d..ca66b3cc 100644
--- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
+++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts
@@ -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
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] {
diff --git a/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts
new file mode 100644
index 00000000..1846afa6
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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)
+
+ 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)
+
+ 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,
+ })
+ })
+})
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
index 81c322e1..fa4b27bc 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
@@ -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> {
- 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>()
+
+ 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,
+): 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)
+}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
new file mode 100644
index 00000000..280db599
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts
@@ -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 {
+ 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 {
+ return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record
+}
+
+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) {
+ useScene.setState({
+ collections: {},
+ dirtyNodes: new Set(),
+ 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() })
+ })
+
+ 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() })
+ const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record
+ 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(),
+ nodes: nodesFor(deckLevel, deck, attachedStair, otherStair),
+ readOnly: false,
+ rootNodeIds: ['level_0'] as AnyNodeId[],
+ } as never)
+ clearSceneHistory()
+ stopSync = initSpatialGridSync()
+ useScene.setState({ dirtyNodes: new Set() })
+ })
+
+ 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([])
+ })
+})
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
index 8f37156e..886d5970 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -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,
+ 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,
+ 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,
+ 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,
)
) {
diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts
new file mode 100644
index 00000000..588ca7ee
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts
@@ -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,
+ 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()
+ 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,
+ 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()
+ 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,
+ 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()
+ 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 }
+}
diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts
new file mode 100644
index 00000000..88ed9453
--- /dev/null
+++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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)
+ const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial)
+
+ 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)
+ 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)
+ const level = makeLevel(['slab_low', 'slab_high', item.id])
+
+ useScene.setState({
+ collections: {},
+ dirtyNodes: new Set(),
+ 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')
+ })
+})
diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts
index b1da3b33..3a1b79fe 100644
--- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts
+++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts
@@ -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 },
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index c59b1766..a477a88f 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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'
diff --git a/packages/core/src/lib/measurement-geometry.test.ts b/packages/core/src/lib/measurement-geometry.test.ts
index 842f184d..48a8264b 100644
--- a/packages/core/src/lib/measurement-geometry.test.ts
+++ b/packages/core/src/lib/measurement-geometry.test.ts
@@ -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')
+ })
})
diff --git a/packages/core/src/lib/measurement-geometry.ts b/packages/core/src/lib/measurement-geometry.ts
index f2184ad1..9dd7b7f9 100644
--- a/packages/core/src/lib/measurement-geometry.ts
+++ b/packages/core/src/lib/measurement-geometry.ts
@@ -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,
): 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,
+): 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,
+): 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()
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]
diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts
index 45490c1e..ef89df43 100644
--- a/packages/core/src/lib/slab-polygon.test.ts
+++ b/packages/core/src/lib/slab-polygon.test.ts
@@ -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])])
diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts
index 5b121adb..e8fded0d 100644
--- a/packages/core/src/lib/slab-polygon.ts
+++ b/packages/core/src/lib/slab-polygon.ts
@@ -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
diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts
index 708f7153..c7eb3949 100644
--- a/packages/core/src/lib/space-detection.test.ts
+++ b/packages/core/src/lib/space-detection.test.ts
@@ -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 {
+ 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
+}
+
+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) {
+ const listeners = new Set<(state: unknown) => void>()
+ const state: Record & { nodes: Record } = {
+ nodes: initialNodes,
+ }
+ const notify = () => {
+ for (const listener of [...listeners]) listener(state)
+ }
+ state.updateNodes = (updates: Array<{ id: string; data: Record }>) => {
+ const next: Record = { ...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 = { ...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 = { ...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) {
+ state.nodes = next
+ notify()
+ },
+ }
+}
+
+function createEditorStoreStub() {
+ const state = {
+ spaces: {} as Record,
+ setSpaces(next: Record) {
+ 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
+
+ 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
diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts
index c125e275..41302a74 100644
--- a/packages/core/src/lib/space-detection.ts
+++ b/packages/core/src/lib/space-detection.ts
@@ -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
}
-type DetectedCeilingRoom = DetectedRoom & {
- ceilingHeight: number
-}
-
export type AutoSlabSyncPlan = {
create: SlabNodeType[]
update: Array<{ id: SlabNodeType['id']; data: Partial }>
@@ -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) {
@@ -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) {
const wallsByLevel = new Map()
const zonesByLevel = new Map()
+ const slabElevationsByLevel = new Map()
+ const coveringUndersidesByLevel = new Map()
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) {
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)
const snapshots = new Map()
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()
const matchedDetectedIdx = new Set()
- const updatesById = new Map()
+ const updatesById = new Map()
const autoBySignature = new Map>()
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 = {}
- 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,
diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts
index c8e529ce..4bdeb23a 100644
--- a/packages/core/src/lib/zone-quantities.test.ts
+++ b/packages/core/src/lib/zone-quantities.test.ts
@@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record {
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}`,
diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts
index f120ff02..b1830696 100644
--- a/packages/core/src/lib/zone-quantities.ts
+++ b/packages/core/src/lib/zone-quantities.ts
@@ -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,
+ sceneNodes: Readonly>,
): 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),
{ 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.',
diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts
index e45a4887..7bf2b786 100644
--- a/packages/core/src/material-library.ts
+++ b/packages/core/src/material-library.ts
@@ -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()
+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:'
diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts
index 437ee091..08d459be 100644
--- a/packages/core/src/registry/index.ts
+++ b/packages/core/src/registry/index.ts
@@ -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,
diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts
index ae7b8605..4f14671f 100644
--- a/packages/core/src/registry/subtree.test.ts
+++ b/packages/core/src/registry/subtree.test.ts
@@ -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], {
diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts
index 894669c7..f3e3f10d 100644
--- a/packages/core/src/registry/subtree.ts
+++ b/packages/core/src/registry/subtree.ts
@@ -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) {
diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index de9e4089..22150bcb 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -51,6 +51,8 @@ export type GeometryContext = {
* `scene:` refs.
*/
materials?: Record
+ /** Opaque host/plugin context. Core never interprets extension values. */
+ extensions?: Readonly>
/**
* 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>
/**
* 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>
}
/**
* 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>
}
/**
* 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>
+ }
// ─── FloorplanAffordance ─────────────────────────────────────────────
//
@@ -853,6 +913,8 @@ export type NodeDefinition> = {
schemaVersion: number
schema: S
category: NodeCategory
+ /** Opaque host/plugin contributions. Core stores but never interprets them. */
+ extensions?: Readonly>
surfaceRole?: SurfaceRole
/**
* Show a floor direction-triangle while placing/moving — the kind has a
@@ -889,7 +951,6 @@ export type NodeDefinition> = {
portConnectivityFollow?: boolean
defaults: () => Omit, 'id' | 'type'>
- migrate?: Record unknown>
capabilities: Capabilities
relations?: Relations
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index 7727c986..fd0e8f7f 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -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'
diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts
index e4a4e470..c1acb785 100644
--- a/packages/core/src/schema/nodes/building.ts
+++ b/packages/core/src/schema/nodes/building.ts
@@ -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
`,
)
diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts
index a84e3248..a0949a49 100644
--- a/packages/core/src/schema/nodes/cabinet.ts
+++ b/packages/core/src/schema/nodes/cabinet.ts
@@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer
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),
diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts
index b94aaaa2..cb673d2c 100644
--- a/packages/core/src/schema/nodes/ceiling.ts
+++ b/packages/core/src/schema/nodes/ceiling.ts
@@ -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
`,
)
diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts
index de411604..1dc704cf 100644
--- a/packages/core/src/schema/nodes/column.ts
+++ b/packages/core/src/schema/nodes/column.ts
@@ -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),
diff --git a/packages/core/src/schema/nodes/construction-dimension.test.ts b/packages/core/src/schema/nodes/construction-dimension.test.ts
new file mode 100644
index 00000000..33cc7e56
--- /dev/null
+++ b/packages/core/src/schema/nodes/construction-dimension.test.ts
@@ -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([])
+ })
+})
diff --git a/packages/core/src/schema/nodes/construction-dimension.ts b/packages/core/src/schema/nodes/construction-dimension.ts
new file mode 100644
index 00000000..c831b116
--- /dev/null
+++ b/packages/core/src/schema/nodes/construction-dimension.ts
@@ -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
+export type ConstructionDimensionChainMode = z.infer
+export type ConstructionDimensionMode = z.infer
+export type ConstructionDrawingType = z.infer
+export type ConstructionDimensionDrawingPresentation = z.infer<
+ typeof ConstructionDimensionDrawingPresentation
+>
+export type ConstructionDimensionDrawingOverride = z.infer<
+ typeof ConstructionDimensionDrawingOverride
+>
+export type ConstructionDimensionDatumPolicy = z.infer
+export type ConstructionDimensionTerminator = z.infer
+export type ConstructionDimensionTextPosition = z.infer
+export type ConstructionDimensionImperialPrecision = z.infer<
+ typeof ConstructionDimensionImperialPrecision
+>
+export type ConstructionDimensionMetricNotation = z.infer<
+ typeof ConstructionDimensionMetricNotation
+>
+export type ConstructionDimensionNode = z.infer
+
+export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options
+
+export function resolveConstructionDimensionDrawingPresentation(
+ node: Pick,
+ 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,
+ 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,
+ 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,
+ 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,
+ )
+}
diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts
index 26b4170e..ab0aaab9 100644
--- a/packages/core/src/schema/nodes/door.ts
+++ b/packages/core/src/schema/nodes/door.ts
@@ -19,6 +19,13 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer
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
+export type OpeningConstructionType = z.infer
+export type OpeningDimensionReference = z.infer
export type DoorType = z.infer
export type DoorTrackStyle = z.infer
@@ -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'),
diff --git a/packages/core/src/schema/nodes/drawing-sheet.test.ts b/packages/core/src/schema/nodes/drawing-sheet.test.ts
new file mode 100644
index 00000000..216295e1
--- /dev/null
+++ b/packages/core/src/schema/nodes/drawing-sheet.test.ts
@@ -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)
+ })
+})
diff --git a/packages/core/src/schema/nodes/drawing-sheet.ts b/packages/core/src/schema/nodes/drawing-sheet.ts
new file mode 100644
index 00000000..372addf0
--- /dev/null
+++ b/packages/core/src/schema/nodes/drawing-sheet.ts
@@ -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
+export type DrawingSheetOrientation = z.infer
+export type DrawingSheetScale = z.infer
+export type DrawingSheetAnnotationProfile = z.infer
+export type DrawingSheetRect = z.infer
+export type DrawingSheetPlacedView = z.infer
+export type DrawingSheetGeneralNote = z.infer
+export type DrawingSheetGeneralNoteSet = z.infer
+export type DrawingSheetKeyedNote = z.infer
+export type DrawingSheetKeyedNoteDefinition = z.infer
+export type DrawingSheetKeyedNoteInstance = z.infer
+export type DrawingSheetDocumentMarker = z.infer
+export type DrawingSheetDocumentMarkerKind = z.infer
+export type DrawingSheetSchedulePlacement = z.infer
+export type DrawingSheetTitleBlock = z.infer
+export type DrawingSheetNode = z.infer
+
+/**
+ * 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,
+): 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'),
+ })),
+ }
+}
diff --git a/packages/core/src/schema/nodes/duct-terminal.ts b/packages/core/src/schema/nodes/duct-terminal.ts
index 5eefff31..6c5f058a 100644
--- a/packages/core/src/schema/nodes/duct-terminal.ts
+++ b/packages/core/src/schema/nodes/duct-terminal.ts
@@ -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.
diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts
index e8e76874..26ebff77 100644
--- a/packages/core/src/schema/nodes/fence.ts
+++ b/packages/core/src/schema/nodes/fence.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts
index a0e00a13..6bde1e2c 100644
--- a/packages/core/src/schema/nodes/hvac-equipment.ts
+++ b/packages/core/src/schema/nodes/hvac-equipment.ts
@@ -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).
diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts
index f8732482..00c5a998 100644
--- a/packages/core/src/schema/nodes/item.ts
+++ b/packages/core/src/schema/nodes/item.ts
@@ -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()).optional(),
diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts
index f950f122..f30d91bc 100644
--- a/packages/core/src/schema/nodes/level.test.ts
+++ b/packages/core/src/schema/nodes/level.test.ts
@@ -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)
+ })
})
diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts
index 0360598f..60d15aa9 100644
--- a/packages/core/src/schema/nodes/level.ts
+++ b/packages/core/src/schema/nodes/level.ts
@@ -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
`,
)
diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts
index 3e9b74b2..89faaedd 100644
--- a/packages/core/src/schema/nodes/shelf.ts
+++ b/packages/core/src/schema/nodes/shelf.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts
index fe3c47c1..ec8e99b7 100644
--- a/packages/core/src/schema/nodes/slab.ts
+++ b/packages/core/src/schema/nodes/slab.ts
@@ -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
`,
)
diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts
index 521d3f81..b3a620eb 100644
--- a/packages/core/src/schema/nodes/spawn.ts
+++ b/packages/core/src/schema/nodes/spawn.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts
index abc1fe54..cf9d0664 100644
--- a/packages/core/src/schema/nodes/stair.ts
+++ b/packages/core/src/schema/nodes/stair.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/structural-grid.test.ts b/packages/core/src/schema/nodes/structural-grid.test.ts
new file mode 100644
index 00000000..1b6afcfc
--- /dev/null
+++ b/packages/core/src/schema/nodes/structural-grid.test.ts
@@ -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],
+ })
+ })
+})
diff --git a/packages/core/src/schema/nodes/structural-grid.ts b/packages/core/src/schema/nodes/structural-grid.ts
new file mode 100644
index 00000000..09f071a6
--- /dev/null
+++ b/packages/core/src/schema/nodes/structural-grid.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts
index abc2f5e8..164a5c37 100644
--- a/packages/core/src/schema/nodes/wall.test.ts
+++ b/packages/core/src/schema/nodes/wall.test.ts
@@ -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)
+ } as Pick)
expect(patch.faceBands).toEqual({
enabled: true,
@@ -129,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
- } as Pick,
+ } as Pick,
3,
)
@@ -153,7 +166,7 @@ describe('wall face bands', () => {
middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper',
},
- } as Pick)
+ } as Pick)
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,
+ } as Pick,
3,
)
@@ -213,7 +226,7 @@ describe('wall face bands', () => {
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior',
},
- } as Pick,
+ } as Pick,
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,
+ })
+ })
+})
diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts
index 4bfaa063..82a81429 100644
--- a/packages/core/src/schema/nodes/wall.ts
+++ b/packages/core/src/schema/nodes/wall.ts
@@ -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
+
+export const WallDimensionDatum = z.enum([
+ 'centerline',
+ 'structural-face',
+ 'finish-face',
+ 'veneer-face',
+])
+export type WallDimensionDatum = z.infer
+
+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
+
+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): WallAssemblyLayer[] {
+ return wall.assemblyLayers ?? []
+}
+
+export function getWallAssemblyThickness(
+ wall: Pick,
+): 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): {
+ 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,
+ 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,
+): 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,
+): 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,
+ 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 = {
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
}
-export function getWallFaceBandConfig(wall: Pick) {
- const wallHeight = wall.height ?? 2.5
+export function getWallFaceBandConfig(
+ wall: Pick,
+ 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,
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'
diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts
index 08c5ce4e..53b7fec5 100644
--- a/packages/core/src/schema/nodes/window.ts
+++ b/packages/core/src/schema/nodes/window.ts
@@ -17,6 +17,16 @@ export const WindowType = z.enum([
])
export type WindowType = z.infer
+export const WindowConstructionType = z.enum(['framed', 'masonry'])
+export const WindowDimensionReference = z.enum([
+ 'nominal',
+ 'rough-opening',
+ 'masonry-opening',
+ 'finish-opening',
+])
+export type WindowConstructionType = z.infer
+export type WindowDimensionReference = z.infer
+
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'),
diff --git a/packages/core/src/schema/nodes/zone.test.ts b/packages/core/src/schema/nodes/zone.test.ts
new file mode 100644
index 00000000..95a80512
--- /dev/null
+++ b/packages/core/src/schema/nodes/zone.test.ts
@@ -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')
+ })
+})
diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts
index 55a17957..227f5a4d 100644
--- a/packages/core/src/schema/nodes/zone.ts
+++ b/packages/core/src/schema/nodes/zone.ts
@@ -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)
`,
diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts
index da37c6f2..2c980bfc 100644
--- a/packages/core/src/schema/types.ts
+++ b/packages/core/src/schema/types.ts
@@ -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,
diff --git a/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts
new file mode 100644
index 00000000..75841f1a
--- /dev/null
+++ b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts
@@ -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,
+ },
+}
diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts
index 85517d5c..245793f6 100644
--- a/packages/core/src/services/index.ts
+++ b/packages/core/src/services/index.ts
@@ -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,
diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts
new file mode 100644
index 00000000..81698a26
--- /dev/null
+++ b/packages/core/src/services/level-height.test.ts
@@ -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 {
+ 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
+}
+
+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 {
+ 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
+}
+
+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
+
+ 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)).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
+
+ // 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
+ expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id)
+ })
+})
diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts
index 016711ac..871303c4 100644
--- a/packages/core/src/services/level-height.ts
+++ b/packages/core/src/services/level-height.ts
@@ -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,
+ nodes: Record,
+): 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,
- 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
}
diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts
new file mode 100644
index 00000000..2cce58ef
--- /dev/null
+++ b/packages/core/src/services/storey.test.ts
@@ -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 =>
+ Object.fromEntries(list.map((node) => [node.id, node])) as Record
+
+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>
+ 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
+ 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,
+ )
+ })
+})
diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts
new file mode 100644
index 00000000..4fd6563f
--- /dev/null
+++ b/packages/core/src/services/storey.ts
@@ -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): 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): Map {
+ 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()
+ const cumulativeYByBuilding = new Map()
+ 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 | 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,
+): 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 | 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,
+): 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,
+): 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,
+ 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 & Partial>,
+ levelId: string,
+ nodes: Record,
+): 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,
+ 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
+}
diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts
index 3b0d09b5..e56623ec 100644
--- a/packages/core/src/store/actions/node-actions.ts
+++ b/packages/core/src/store/actions/node-actions.ts
@@ -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 {
+ const merged: Record = { ...currentNode, ...patch }
+ for (const key of Object.keys(patch)) {
+ if ((patch as Record)[key] === undefined) delete merged[key]
+ }
+ return merged as AnyNode
+}
+
function parseUpdatedNode(currentNode: AnyNode, data: Partial): 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
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) } as AnyNode
+ return mergeNodeUpdate(currentNode, sanitized.value as Partial)
}
function shouldRefreshDefaultRidgeVents(data: Partial) {
@@ -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()
+ 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
diff --git a/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/packages/core/src/store/actions/node-mutation-sanitize.test.ts
index 1863b538..3e54d6d4 100644
--- a/packages/core/src/store/actions/node-mutation-sanitize.test.ts
+++ b/packages/core/src/store/actions/node-mutation-sanitize.test.ts
@@ -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 {
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)
+
+ const wall = useScene.getState().nodes[WALL_ID] as Record
+ 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)
+ expect('height' in (useScene.getState().nodes[WALL_ID] as Record)).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)
+
+ const wall = useScene.getState().nodes[WALL_ID] as Record
+ expect('height' in wall).toBe(false)
+ expect(wall.name).toBe('Plane-bound wall')
+ })
+})
diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts
index b2aef7dd..6c235583 100644
--- a/packages/core/src/store/use-live-transforms.ts
+++ b/packages/core/src/store/use-live-transforms.ts
@@ -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 = {
diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts
index 5dc7d332..88b9a82f 100644
--- a/packages/core/src/store/use-scene-commits.test.ts
+++ b/packages/core/src/store/use-scene-commits.test.ts
@@ -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)
diff --git a/packages/core/src/store/use-scene-construction-dimension-migration.test.ts b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts
new file mode 100644
index 00000000..6becb6d1
--- /dev/null
+++ b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts
@@ -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,
+ ['site_test'] as never,
+ )
+
+ const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode &
+ Record
+ expect(dimension.reference).toBeUndefined()
+ expect(dimension.referenceStyle).toBeUndefined()
+ expect(dimension.drawingOverrides).toEqual([
+ { drawingType: 'roof-plan', presentation: 'shown' },
+ ])
+ })
+})
diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts
new file mode 100644
index 00000000..a95e7839
--- /dev/null
+++ b/packages/core/src/store/use-scene-vertical-migration.test.ts
@@ -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
+
+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): Record {
+ useScene.getState().setScene(nodes as unknown as Record, ['site_test'] as never)
+ return useScene.getState().nodes as Record
+}
+
+type LevelResult = Extract
+type WallResult = Extract
+type StairResult = Extract
+type SlabResult = Extract
+type CeilingResult = Extract
+
+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)
+
+ expect(second).toEqual(first)
+ })
+})
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index c4638887..a27991e4 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -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) {
+ const hasTotalRise = 'totalRise' in node
const sanitized = {
...node,
position: getVector3(node.position, [0, 0, 0]),
@@ -101,7 +106,7 @@ function normalizeStairNode(node: Record) {
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) {
}
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) {
@@ -559,6 +570,36 @@ function migrateRoofSurfaceMaterials(node: Record) {
return next
}
+function migrateConstructionDimension(node: Record) {
+ 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): {
nodes: Record
mintedMaterials: Record
@@ -667,6 +708,10 @@ function migrateNodes(nodes: Record): {
}
}
+ 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): {
}
}
+ // 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>()
+ 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