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/index.ts b/packages/core/src/index.ts
index 1b8b3a47..b76a9c69 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,
@@ -88,6 +91,7 @@ export {
closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback,
+ measurementAnchorReferenceNodeIds,
measurementAngle,
measurementArea,
measurementAreaVector,
@@ -98,6 +102,7 @@ export {
measurementPerimeter,
measurementPrismVolume,
measurementReferenceNodeIds,
+ remapMeasurementAnchors,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export {
@@ -294,6 +299,7 @@ export { resolveStairTotalRise } from './systems/stair/stair-rise'
export {
getClampedWallCurveOffset,
getMaxWallCurveOffset,
+ getWallArcData,
getWallChordFrame,
getWallCurveFrameAt,
getWallCurveLength,
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/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts
index a2ad983c..b1830696 100644
--- a/packages/core/src/lib/zone-quantities.ts
+++ b/packages/core/src/lib/zone-quantities.ts
@@ -471,7 +471,7 @@ function unavailable(reason: string): ZoneQuantityValue {
export function deriveZoneQuantityReport(
zone: ZoneNode,
- sceneNodes: Record,
+ sceneNodes: Readonly>,
): ZoneQuantityReport {
const levelId = zone.parentId
const levelNodes = levelId
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 e58db315..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'
@@ -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/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/level.ts b/packages/core/src/schema/nodes/level.ts
index a566f593..114c9514 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 { CeilingNode } from './ceiling'
import { ColumnNode } from './column'
+import { ConstructionDimensionNode } from './construction-dimension'
import { DuctFittingNode } from './duct-fitting'
import { DuctSegmentNode } from './duct-segment'
import { DuctTerminalNode } from './duct-terminal'
@@ -22,6 +23,7 @@ import { ShelfNode } from './shelf'
import { SlabNode } from './slab'
import { SpawnNode } from './spawn'
import { StairNode } from './stair'
+import { StructuralGridNode } from './structural-grid'
import { WallNode } from './wall'
import { ZoneNode } from './zone'
@@ -34,6 +36,8 @@ export const LevelNode = BaseNode.extend({
WallNode.shape.id,
FenceNode.shape.id,
ColumnNode.shape.id,
+ ConstructionDimensionNode.shape.id,
+ StructuralGridNode.shape.id,
ItemNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
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 5364e083..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'
@@ -99,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,
@@ -135,7 +142,7 @@ describe('wall face bands', () => {
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
- } as Pick,
+ } as Pick,
3,
)
@@ -159,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,
@@ -187,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,
)
@@ -219,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,
)
@@ -260,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 a01a98ad..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,6 +191,7 @@ 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.
@@ -167,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
@@ -190,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.
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/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.ts b/packages/core/src/store/use-scene.ts
index be23e8e5..6036dff8 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -570,6 +570,27 @@ 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.
@@ -687,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) {
diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts
index 743f14ca..5107922c 100644
--- a/packages/core/src/systems/wall/wall-curve.ts
+++ b/packages/core/src/systems/wall/wall-curve.ts
@@ -106,7 +106,7 @@ export function getWallChordFrame(wall: WallCurveLike) {
}
}
-function getWallArcData(wall: WallCurveLike) {
+export function getWallArcData(wall: WallCurveLike) {
const chord = getWallChordFrame(wall)
const sagitta = getClampedWallCurveOffset(wall)
diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts
index 637b1840..d73bd8d4 100644
--- a/packages/core/src/systems/wall/wall-mitering.test.ts
+++ b/packages/core/src/systems/wall/wall-mitering.test.ts
@@ -10,6 +10,7 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
visible: true,
parentId: 'level_test',
children: [],
+ assemblyLayers: [],
start,
end,
thickness: 0.1,
diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts
index 93fd3c69..02aea9c5 100644
--- a/packages/core/src/utils/clone-scene-graph.test.ts
+++ b/packages/core/src/utils/clone-scene-graph.test.ts
@@ -77,6 +77,119 @@ describe('forkSceneGraph', () => {
})
})
+describe('construction-dimension clone references', () => {
+ function sceneWithControlledDimensions(): SceneGraph {
+ const site = makeNode('site_1', 'site', { children: ['level_1'] })
+ const level = makeNode('level_1', 'level', {
+ parentId: 'site_1',
+ children: ['construction-dimension_foundation', 'construction-dimension_floor'],
+ })
+ const controller = makeNode('construction-dimension_foundation', 'construction-dimension', {
+ name: 'Foundation controller',
+ parentId: 'level_1',
+ anchors: [
+ [0, 0, 0],
+ [4, 0, 0],
+ ],
+ controllingDimensionId: null,
+ })
+ const dependent = makeNode('construction-dimension_floor', 'construction-dimension', {
+ name: 'Floor dependent',
+ parentId: 'level_1',
+ anchors: [
+ [0, 0, 0],
+ [4, 0, 0],
+ ],
+ controllingDimensionId: controller.id,
+ })
+ return {
+ nodes: {
+ [site.id]: site,
+ [level.id]: level,
+ [controller.id]: controller,
+ [dependent.id]: dependent,
+ },
+ rootNodeIds: [site.id],
+ }
+ }
+
+ test('remaps controller IDs in whole-scene clones', () => {
+ const cloned = cloneSceneGraph(sceneWithControlledDimensions())
+ const dimensions = Object.values(cloned.nodes).filter(
+ (node) => node.type === 'construction-dimension',
+ )
+ const controller = dimensions.find((node) => node.name === 'Foundation controller')
+ const dependent = dimensions.find((node) => node.name === 'Floor dependent')
+
+ expect(controller?.type).toBe('construction-dimension')
+ expect(dependent?.type).toBe('construction-dimension')
+ if (
+ controller?.type === 'construction-dimension' &&
+ dependent?.type === 'construction-dimension'
+ ) {
+ expect(dependent.controllingDimensionId).toBe(controller.id)
+ }
+ })
+
+ test('remaps controller IDs in level-subtree clones', () => {
+ const scene = sceneWithControlledDimensions()
+ const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId)
+ const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension')
+ const controller = dimensions.find((node) => node.name === 'Foundation controller')
+ const dependent = dimensions.find((node) => node.name === 'Floor dependent')
+
+ expect(controller?.type).toBe('construction-dimension')
+ expect(dependent?.type).toBe('construction-dimension')
+ if (
+ controller?.type === 'construction-dimension' &&
+ dependent?.type === 'construction-dimension'
+ ) {
+ expect(dependent.controllingDimensionId).toBe(controller.id)
+ }
+ })
+})
+
+describe('drawing-sheet clone references', () => {
+ test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
+ const level = makeNode('level_main', 'level')
+ const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
+ placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
+ generalNoteSetIds: [],
+ generalNoteSets: [],
+ generalNotes: [],
+ keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
+ keyedNoteInstances: [
+ {
+ id: 'keyed-note-instance_a',
+ definitionId: 'keyed-note_a',
+ placedViewId: 'drawing-view_main',
+ position: [1, 1],
+ },
+ ],
+ keyedNoteLegend: [],
+ documentMarkers: [],
+ schedules: [],
+ })
+ const cloned = cloneSceneGraph({
+ nodes: { [level.id]: level, [sheet.id]: sheet },
+ rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
+ })
+ const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
+ const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
+
+ expect(clonedLevel).toBeDefined()
+ expect(clonedSheet?.type).toBe('drawing-sheet')
+ if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
+ expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
+ expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
+ expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
+ clonedSheet.keyedNoteDefinitions[0]?.id,
+ )
+ expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
+ }
+ })
+})
+
describe('supportSlabId remap', () => {
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts
index 8235c012..5c6ec8bc 100644
--- a/packages/core/src/utils/clone-scene-graph.ts
+++ b/packages/core/src/utils/clone-scene-graph.ts
@@ -1,8 +1,12 @@
import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation'
-import { remapMeasurementReferences } from '../lib/measurement-geometry'
+import {
+ remapConstructionDimensionReferences,
+ remapMeasurementReferences,
+} from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
+import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
export type SceneGraph = {
nodes: Record
@@ -45,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId
- const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
+ let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
@@ -107,6 +111,12 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
+ if (clonedNode.type === 'construction-dimension') {
+ clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
+ }
+ if (clonedNode.type === 'drawing-sheet') {
+ clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
+ }
clonedNodes[newId] = clonedNode
}
@@ -221,7 +231,7 @@ export function cloneLevelSubtree(
const newId = idMap.get(oldId)! as AnyNodeId
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
- const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
+ let cloned = JSON.parse(JSON.stringify(node)) as AnyNode
;(cloned as Record).id = newId
// Remap parentId — but only for descendants, not the level node itself
@@ -274,6 +284,12 @@ export function cloneLevelSubtree(
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
+ if (cloned.type === 'construction-dimension') {
+ cloned = remapConstructionDimensionReferences(cloned, idMap)
+ }
+ if (cloned.type === 'drawing-sheet') {
+ cloned = remapDrawingSheetReferences(cloned, idMap)
+ }
clonedNodes.push(cloned)
}
diff --git a/packages/editor/package.json b/packages/editor/package.json
index bec9357d..73d5899f 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -40,16 +40,16 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@visual-json/react": "^0.4.0",
+ "blob-stream": "^0.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
- "jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
- "svg2pdf.js": "^2.7.0",
+ "pdfkit": "^0.19.1",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "~0.9.8",
"zod": "^4.3.6",
@@ -59,8 +59,10 @@
"@pascal-app/core": "^0.9.2",
"@pascal-app/viewer": "^0.9.2",
"@pascal/typescript-config": "*",
+ "@types/blob-stream": "^0.1.33",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
+ "@types/pdfkit": "^0.17.6",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
diff --git a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
index b98d2394..8478747b 100644
--- a/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-alignment-guide-layer.tsx
@@ -28,6 +28,7 @@ import { useFloorplanRender } from './floorplan-render-context'
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
const guides = useAlignmentGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
+ const metricNotation = useViewer((s) => s.metricNotation)
const ctx = useFloorplanRender()
if (guides.length === 0) return null
@@ -61,7 +62,7 @@ export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuid
// offset along X.
const pillX = axis === 'x' ? midX + pillOffset : midX
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
- const distLabel = formatMeasurement(distMeters, unit)
+ const distLabel = formatMeasurement(distMeters, unit, metricNotation)
const charWidth = pillFontSize * 0.55
const pillWidth = distLabel.length * charWidth + pillPadX * 2
const pillHeight = pillFontSize + pillPadY * 2
diff --git a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx
index 7e690b82..70840020 100644
--- a/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-measurement-tool-layer.tsx
@@ -786,6 +786,7 @@ export function FloorplanMeasurementToolLayer() {
const draftLevelId = useMeasurementDraft((state) => state.levelId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const unit = useViewer((state) => state.unit)
+ const metricNotation = useViewer((state) => state.metricNotation)
useEffect(() => {
if (active) {
@@ -1224,7 +1225,7 @@ export function FloorplanMeasurementToolLayer() {
angle: Math.atan2(end[2] - start[2], end[0] - start[0]),
point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
screenUpright: false,
- text: formatLinearMeasurement(measurementDistance(start, end), unit),
+ text: formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation),
}
} else if (kind === 'angle' && livePoints.length >= 3) {
const anglePoints = livePoints.slice(0, 3) as [
@@ -1248,7 +1249,7 @@ export function FloorplanMeasurementToolLayer() {
text:
kind === 'area'
? `A ${formatAreaLabel(measurementArea(livePoints), unit)}`
- : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit)}`,
+ : `P ${formatLinearMeasurement(measurementPerimeter(livePoints), unit, metricNotation)}`,
}
}
} else if (kind === 'volume' && center && baseNormal) {
@@ -1280,7 +1281,11 @@ export function FloorplanMeasurementToolLayer() {
(segmentStart[1] + segmentEnd[1]) / 2,
(segmentStart[2] + segmentEnd[2]) / 2,
],
- text: formatLinearMeasurement(measurementDistance(segmentStart, segmentEnd), unit),
+ text: formatLinearMeasurement(
+ measurementDistance(segmentStart, segmentEnd),
+ unit,
+ metricNotation,
+ ),
}
}
}
@@ -1298,7 +1303,18 @@ export function FloorplanMeasurementToolLayer() {
polygonPoints,
segmentLabel,
}
- }, [axisGuide, baseNormal, extrusionHeight, hover, kind, points, stage, unit, vertexDrag])
+ }, [
+ axisGuide,
+ baseNormal,
+ extrusionHeight,
+ hover,
+ kind,
+ metricNotation,
+ points,
+ stage,
+ unit,
+ vertexDrag,
+ ])
if (smartActive) return
if (!active || (draftLevelId && draftLevelId !== activeLevelId)) return null
@@ -1595,7 +1611,7 @@ export function FloorplanMeasurementToolLayer() {
text={`${hover.semantic.label}${
hover.semantic.length === null
? ''
- : ` · ${formatLinearMeasurement(hover.semantic.length, unit)}`
+ : ` · ${formatLinearMeasurement(hover.semantic.length, unit, metricNotation)}`
}`}
textColor={labelText}
unitsPerPixel={unitsPerPixel}
diff --git a/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx
new file mode 100644
index 00000000..54b240cc
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/floorplan-registered-tool-layer.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { type ComponentType, lazy, Suspense, useCallback, useMemo } from 'react'
+import {
+ type FloorplanToolContext,
+ getFloorplanNodeExtension,
+} from '../../lib/floorplan/floorplan-extension'
+import useEditor from '../../store/use-editor'
+
+const lazyToolCache = new WeakMap<() => Promise, ComponentType>()
+
+function registeredFloorplanTool(tool: string | null): ComponentType | null {
+ if (!tool) return null
+ const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
+ if (!loader) return null
+ const cached = lazyToolCache.get(loader)
+ if (cached) return cached
+ const component = lazy(loader)
+ lazyToolCache.set(loader, component)
+ return component
+}
+
+export function FloorplanRegisteredToolLayer() {
+ const mode = useEditor((state) => state.mode)
+ const tool = useEditor((state) => state.tool)
+ const gridSnapStep = useEditor((state) => state.gridSnapStep)
+ const toolDefaults = useEditor((state) =>
+ state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
+ )
+ const activeLevelId = useViewer((state) => state.selection.levelId)
+ const unit = useViewer((state) => state.unit)
+ const metricNotation = useViewer((state) => state.metricNotation)
+ const sceneApi = useMemo(() => createSceneApi(useScene), [])
+ const selectNode = useCallback(
+ (id: Parameters[0]) =>
+ useViewer.getState().setSelection({ selectedIds: [id] }),
+ [],
+ )
+ const finishTool = useCallback(() => {
+ useEditor.getState().setTool(null)
+ useEditor.getState().setMode('select')
+ }, [])
+ if (mode !== 'build') return null
+ const Tool = registeredFloorplanTool(tool)
+ return Tool ? (
+
+
+
+ ) : null
+}
diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
index bef46dcd..315834d1 100644
--- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
+++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx
@@ -20,16 +20,21 @@ import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
+import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
+import { curveReshapeScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
-import { useMovingNode } from '../../store/use-interaction-scope'
+import useInteractionScope, {
+ useIsCurveReshape,
+ useMovingNode,
+} from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref'
@@ -106,6 +111,8 @@ function collectQuickActionNodes(
* `` / dispatcher picks the right path.
* Walls are excluded — their move is reached via the side-arrow
* handles emitted from `def.floorplan`, not via a menu button.
+ * - Curve (wall only): enters curve reshape mode. The selected wall's
+ * midpoint curve handle remains visible so it can be dragged in plan.
* - Add hole (slab + ceiling only): inserts a small default-square
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
* `handleAddHole` in `floating-action-menu.tsx`.
@@ -114,7 +121,7 @@ function collectQuickActionNodes(
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
* `relations.cascadeDelete` if declared on the def.
*
- * Hidden while in a move state (so we don't show buttons over a ghost).
+ * Hidden while moving or curving so the menu does not compete with the active affordance.
*/
export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion()
@@ -124,6 +131,7 @@ export function FloorplanRegistryActionMenu() {
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
) as AnyNodeId | undefined
const movingNode = useMovingNode()
+ const isCurveReshape = useIsCurveReshape()
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin)
// Gate on floorplan hover so this 2D menu never coexists with the 3D
@@ -139,10 +147,28 @@ export function FloorplanRegistryActionMenu() {
// Only show for registered kinds (skip legacy kinds — they have their
// own FloorplanActionMenuLayer entries).
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
+ const canCurve = useScene((s) => {
+ if (!selectedId) return false
+ const selectedNode = s.nodes[selectedId]
+ if (!selectedNode) return false
+ const definition = nodeRegistry.get(selectedNode.type)
+ const canCurveNode = getFloorplanNodeExtension(definition)?.actionMenu?.canCurve
+ return (
+ !!definition?.floorplanAffordances?.curve &&
+ !!canCurveNode?.({
+ node: selectedNode as never,
+ nodes: s.nodes,
+ })
+ )
+ })
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible =
- isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
+ isRegistryKind &&
+ def?.presentation?.actionMenu !== false &&
+ !movingNode &&
+ !isCurveReshape &&
+ isFloorplanHovered
const isWall = selectedKind === 'wall'
const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -284,6 +310,12 @@ export function FloorplanRegistryActionMenu() {
)
}
+ const handleCurve = () => {
+ if (!canCurve) return
+ sfxEmitter.emit('sfx:item-pick')
+ useInteractionScope.getState().begin(curveReshapeScope(node.id))
+ }
+
const handleDuplicate = () => {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
@@ -351,6 +383,7 @@ export function FloorplanRegistryActionMenu() {
>
{
+ test('treats fixed annotation categories as layout obstacles', () => {
+ expect(
+ floorplanAnnotationObstacleMode({
+ kind: 'text',
+ x: 0,
+ y: 0,
+ text: 'BEDROOM',
+ fontSize: 0.18,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
+ }),
+ ).toBe('bounds')
+ expect(
+ floorplanAnnotationObstacleMode({
+ kind: 'line',
+ x1: 0,
+ y1: 0,
+ x2: 1,
+ y2: 0,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
+ }),
+ ).toBe('bounds')
+ expect(
+ floorplanAnnotationObstacleMode({
+ kind: 'polyline',
+ points: [
+ [0, 0],
+ [1, 0],
+ ],
+ metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
+ }),
+ ).toBe('outline')
+ })
+})
+
+describe('collectAnnotationLayoutPreflightIssues', () => {
+ test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => {
+ const issues = collectAnnotationLayoutPreflightIssues(
+ [
+ {
+ id: 'short',
+ x: 0,
+ y: 0,
+ width: 40,
+ height: 10,
+ priority: 10,
+ text: '1"',
+ labelPlacement: 'outside-end',
+ },
+ {
+ id: 'blocked',
+ x: 100,
+ y: 0,
+ width: 40,
+ height: 10,
+ priority: 10,
+ text: 'Blocked',
+ },
+ {
+ id: 'overlap-a',
+ x: 200,
+ y: 0,
+ width: 40,
+ height: 10,
+ priority: 10,
+ text: 'A',
+ },
+ {
+ id: 'overlap-b',
+ x: 205,
+ y: 0,
+ width: 40,
+ height: 10,
+ priority: 10,
+ text: 'B',
+ },
+ ],
+ [
+ { id: 'short', dx: 0, dy: 0, resolved: true },
+ { id: 'blocked', dx: 0, dy: 0, resolved: true },
+ { id: 'overlap-a', dx: 0, dy: 0, resolved: false },
+ { id: 'overlap-b', dx: 0, dy: 0, resolved: true },
+ ],
+ [{ x: 96, y: -2, width: 48, height: 14 }],
+ )
+
+ expect(issues.map((issue) => issue.kind)).toEqual([
+ 'short-unreadable-segment',
+ 'plan-geometry-conflict',
+ 'unresolved-collision',
+ ])
+ expect(issues.every((issue) => issue.severity === 'warning')).toBe(true)
+ })
+})
+
+describe('resolveAnnotationLabelRectangles', () => {
+ test('keeps the higher-priority label and moves the conflicting label', () => {
+ const shifts = resolveAnnotationLabelRectangles([
+ { id: 'overall', x: 0, y: 0, width: 80, height: 12, priority: 100 },
+ { id: 'opening', x: 20, y: 0, width: 50, height: 12, priority: 50 },
+ ])
+
+ expect(shifts.find((entry) => entry.id === 'overall')).toMatchObject({ dx: 0, dy: 0 })
+ expect(shifts.find((entry) => entry.id === 'opening')).not.toMatchObject({ dx: 0, dy: 0 })
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
+ })
+
+ test('keeps pinned labels at their drawing-view override and routes other labels around them', () => {
+ const shifts = resolveAnnotationLabelRectangles([
+ {
+ id: 'pinned',
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 12,
+ priority: 1,
+ pinnedShift: { dx: 30, dy: 0 },
+ },
+ { id: 'automatic', x: 30, y: 0, width: 80, height: 12, priority: 100 },
+ ])
+
+ expect(shifts.find((entry) => entry.id === 'pinned')).toEqual({
+ id: 'pinned',
+ dx: 30,
+ dy: 0,
+ resolved: true,
+ })
+ expect(shifts.find((entry) => entry.id === 'automatic')).not.toMatchObject({
+ dx: 0,
+ dy: 0,
+ })
+ })
+
+ test('does not move labels that are already clear', () => {
+ expect(
+ resolveAnnotationLabelRectangles([
+ { id: 'left', x: 0, y: 0, width: 40, height: 12, priority: 10 },
+ { id: 'right', x: 100, y: 0, width: 40, height: 12, priority: 10 },
+ ]),
+ ).toEqual([
+ { id: 'left', dx: 0, dy: 0, resolved: true },
+ { id: 'right', dx: 0, dy: 0, resolved: true },
+ ])
+ })
+
+ test('preserves drawing order for labels on the same dimension string', () => {
+ const shifts = resolveAnnotationLabelRectangles([
+ { id: 'first', x: 0, y: 0, width: 20, height: 12, priority: 10 },
+ { id: 'second', x: 0, y: 0, width: 80, height: 12, priority: 10 },
+ ])
+
+ expect(shifts.find((entry) => entry.id === 'first')).toMatchObject({ dx: 0, dy: 0 })
+ expect(shifts.find((entry) => entry.id === 'second')).not.toMatchObject({ dx: 0, dy: 0 })
+ })
+
+ test('slides a colliding label along its dimension string before crossing tiers', () => {
+ const shifts = resolveAnnotationLabelRectangles([
+ { id: 'datum', x: 0, y: 0, width: 40, height: 12, priority: 20 },
+ {
+ id: 'adjacent',
+ x: 0,
+ y: 0,
+ width: 40,
+ height: 12,
+ priority: 10,
+ tangentX: 1,
+ tangentY: 0,
+ },
+ ])
+
+ expect(shifts.find((entry) => entry.id === 'adjacent')).toMatchObject({ dy: 0, resolved: true })
+ expect(shifts.find((entry) => entry.id === 'adjacent')?.dx).not.toBe(0)
+ })
+
+ test('tries a short dimension alternative before generic relocation', () => {
+ const shifts = resolveAnnotationLabelRectangles(
+ [
+ {
+ id: 'short-dimension',
+ x: 0,
+ y: 0,
+ width: 40,
+ height: 12,
+ priority: 10,
+ preferredShifts: [{ dx: 100, dy: 0 }],
+ },
+ ],
+ [{ x: 0, y: 0, width: 40, height: 12 }],
+ )
+
+ expect(shifts).toEqual([{ id: 'short-dimension', dx: 100, dy: 0, resolved: true }])
+ })
+
+ test('falls back to a third position when both short-dimension sides are blocked', () => {
+ const shifts = resolveAnnotationLabelRectangles(
+ [
+ {
+ id: 'short-dimension',
+ x: 0,
+ y: 0,
+ width: 40,
+ height: 12,
+ priority: 10,
+ preferredShifts: [{ dx: 100, dy: 0 }],
+ },
+ ],
+ [
+ { x: 0, y: 0, width: 40, height: 12 },
+ { x: 100, y: 0, width: 40, height: 12 },
+ ],
+ )
+
+ expect(shifts[0]).toMatchObject({ id: 'short-dimension', resolved: true })
+ expect(shifts[0]).not.toMatchObject({ dx: 0, dy: 0 })
+ expect(shifts[0]).not.toMatchObject({ dx: 100, dy: 0 })
+ })
+
+ test('approximates diagonal outlines without blocking their full bounding box', () => {
+ expect(
+ polylineObstacleRectangles([
+ { x: 0, y: 0 },
+ { x: 4, y: 4 },
+ { x: 8, y: 8 },
+ ]),
+ ).toEqual([
+ { x: -1, y: -1, width: 6, height: 6 },
+ { x: 3, y: 3, width: 6, height: 6 },
+ ])
+ })
+
+ test('moves a dimension value clear of a fixed door-mark pill', () => {
+ const shifts = resolveAnnotationLabelRectangles(
+ [{ id: 'door-width', x: 94, y: 88, width: 42, height: 16, priority: 100 }],
+ [{ x: 100, y: 82, width: 48, height: 32 }],
+ )
+
+ expect(shifts).toEqual([expect.objectContaining({ id: 'door-width', resolved: true })])
+ const shift = shifts[0]
+ expect(shift).not.toMatchObject({ dx: 0, dy: 0 })
+ expect(
+ 94 + (shift?.dx ?? 0) + 42 + 6 <= 100 ||
+ 148 + 6 <= 94 + (shift?.dx ?? 0) ||
+ 88 + (shift?.dy ?? 0) + 16 + 6 <= 82 ||
+ 114 + 6 <= 88 + (shift?.dy ?? 0),
+ ).toBe(true)
+ })
+
+ test('finds separate nearby positions for a dense label cluster', () => {
+ const shifts = resolveAnnotationLabelRectangles(
+ Array.from({ length: 4 }, (_, index) => ({
+ id: `label-${index}`,
+ x: 0,
+ y: 0,
+ width: 40,
+ height: 12,
+ priority: 10 - index,
+ })),
+ )
+
+ expect(new Set(shifts.map(({ dx, dy }) => `${dx},${dy}`))).toHaveLength(4)
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
+ })
+
+ test('keeps a large dense label cluster readable', () => {
+ const rectangles = Array.from({ length: 12 }, (_, index) => ({
+ id: `label-${index}`,
+ x: 100 + (index % 3) * 4,
+ y: 100 + (index % 2) * 3,
+ width: 48 + (index % 4) * 8,
+ height: 14,
+ priority: 20 - index,
+ }))
+ const shifts = resolveAnnotationLabelRectangles(rectangles)
+ const placed = rectangles.map((rectangle) => {
+ const shift = shifts.find(({ id }) => id === rectangle.id)
+ return {
+ ...rectangle,
+ x: rectangle.x + (shift?.dx ?? 0),
+ y: rectangle.y + (shift?.dy ?? 0),
+ }
+ })
+
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
+ for (let index = 0; index < placed.length; index += 1) {
+ for (let otherIndex = index + 1; otherIndex < placed.length; otherIndex += 1) {
+ const left = placed[index]
+ const right = placed[otherIndex]
+ if (!left || !right) continue
+ const overlaps = !(
+ left.x + left.width + 6 <= right.x ||
+ right.x + right.width + 6 <= left.x ||
+ left.y + left.height + 6 <= right.y ||
+ right.y + right.height + 6 <= left.y
+ )
+ expect(overlaps).toBe(false)
+ }
+ }
+ })
+
+ test('resolves a construction-plan label set without blocking the view transition', () => {
+ const labels = Array.from({ length: 25 }, (_, index) => ({
+ id: `label-${index}`,
+ x: index % 5,
+ y: index % 7,
+ width: 80,
+ height: 20,
+ priority: 25 - index,
+ }))
+ const obstacles = Array.from({ length: 100 }, (_, index) => ({
+ x: (index % 10) * 2,
+ y: (index % 13) * 2,
+ width: 100,
+ height: 40,
+ }))
+
+ const startedAt = performance.now()
+ const shifts = resolveAnnotationLabelRectangles(labels, obstacles)
+ const elapsedMs = performance.now() - startedAt
+
+ expect(shifts).toHaveLength(labels.length)
+ expect(shifts.every((entry) => entry.resolved)).toBe(true)
+ expect(elapsedMs).toBeLessThan(500)
+ })
+})
+
+describe('observeSvgAnnotationLayoutChanges', () => {
+ test('requests a fresh collision pass when floor-plan geometry changes after mount', () => {
+ const OriginalMutationObserver = globalThis.MutationObserver
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame
+ const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
+ let notify: MutationCallback | undefined
+ let animationFrame: FrameRequestCallback | undefined
+ let disconnected = false
+ let observedOptions: MutationObserverInit | undefined
+
+ class FakeMutationObserver {
+ constructor(callback: MutationCallback) {
+ notify = callback
+ }
+
+ observe(_target: Node, options?: MutationObserverInit): void {
+ observedOptions = options
+ }
+
+ disconnect(): void {
+ disconnected = true
+ }
+
+ takeRecords(): MutationRecord[] {
+ return []
+ }
+ }
+
+ globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ animationFrame = callback
+ return 1
+ }) as typeof requestAnimationFrame
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
+ try {
+ let layoutPasses = 0
+ const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => {
+ layoutPasses += 1
+ })
+
+ notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver)
+
+ expect(layoutPasses).toBe(0)
+ animationFrame?.(0)
+ expect(layoutPasses).toBe(1)
+ expect(observedOptions).toMatchObject({
+ attributes: true,
+ childList: true,
+ subtree: true,
+ attributeFilter: expect.any(Array),
+ })
+
+ notify?.(
+ [
+ {
+ attributeName: 'style',
+ target: { closest: () => ({}) },
+ type: 'attributes',
+ } as unknown as MutationRecord,
+ ],
+ {} as MutationObserver,
+ )
+ expect(layoutPasses).toBe(1)
+
+ stop()
+ expect(disconnected).toBe(true)
+ } finally {
+ globalThis.MutationObserver = OriginalMutationObserver
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame
+ globalThis.cancelAnimationFrame = originalCancelAnimationFrame
+ }
+ })
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts
new file mode 100644
index 00000000..934a806e
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts
@@ -0,0 +1,688 @@
+import type { FloorplanGeometry } from '@pascal-app/core'
+import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
+
+export type AnnotationLabelRectangle = {
+ id: string
+ x: number
+ y: number
+ width: number
+ height: number
+ priority: number
+ text?: string
+ labelPlacement?: 'inside' | 'outside-end'
+ pinnedShift?: { dx: number; dy: number }
+ tangentX?: number
+ tangentY?: number
+ preferredShifts?: readonly { dx: number; dy: number }[]
+}
+
+export type AnnotationObstacleRectangle = Pick<
+ AnnotationLabelRectangle,
+ 'x' | 'y' | 'width' | 'height'
+>
+
+export type AnnotationLabelShift = {
+ id: string
+ dx: number
+ dy: number
+ resolved: boolean
+}
+
+const LABEL_GAP_PX = 6
+const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5
+const OUTLINE_SAMPLE_SPACING_PX = 6
+const OUTLINE_OBSTACLE_PADDING_PX = 1
+const MAX_LABEL_SHIFT_CANDIDATES = 512
+const PREFERRED_SHIFT_COST_STEP = 1_000_000
+const COLLISION_GRID_CELL_SIZE_PX = 64
+
+class AnnotationObstacleIndex {
+ private readonly cells = new Map>()
+
+ add(rectangle: AnnotationObstacleRectangle): void {
+ this.forEachCell(rectangle, 0, (key) => {
+ let cell = this.cells.get(key)
+ if (!cell) {
+ cell = new Set()
+ this.cells.set(key, cell)
+ }
+ cell.add(rectangle)
+ })
+ }
+
+ findOverlaps(rectangle: AnnotationObstacleRectangle): AnnotationObstacleRectangle[] {
+ const candidates = new Set()
+ this.forEachCell(rectangle, LABEL_GAP_PX, (key) => {
+ for (const candidate of this.cells.get(key) ?? []) candidates.add(candidate)
+ })
+ return [...candidates].filter((candidate) => rectanglesOverlap(rectangle, candidate))
+ }
+
+ private forEachCell(
+ rectangle: AnnotationObstacleRectangle,
+ padding: number,
+ visit: (key: string) => void,
+ ): void {
+ const minCellX = Math.floor((rectangle.x - padding) / COLLISION_GRID_CELL_SIZE_PX)
+ const maxCellX = Math.floor(
+ (rectangle.x + rectangle.width + padding) / COLLISION_GRID_CELL_SIZE_PX,
+ )
+ const minCellY = Math.floor((rectangle.y - padding) / COLLISION_GRID_CELL_SIZE_PX)
+ const maxCellY = Math.floor(
+ (rectangle.y + rectangle.height + padding) / COLLISION_GRID_CELL_SIZE_PX,
+ )
+ for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
+ for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) visit(`${cellX}:${cellY}`)
+ }
+ }
+}
+
+export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
+export type AnnotationLayoutOverrides = Readonly>
+export type AnnotationPreflightIssueKind =
+ | 'unresolved-collision'
+ | 'short-unreadable-segment'
+ | 'plan-geometry-conflict'
+export type AnnotationPreflightIssue = {
+ id: string
+ kind: AnnotationPreflightIssueKind
+ severity: 'warning'
+ message: string
+}
+
+export function resolveAnnotationLabelRectangles(
+ rectangles: readonly AnnotationLabelRectangle[],
+ obstacles: readonly AnnotationObstacleRectangle[] = [],
+): AnnotationLabelShift[] {
+ const occupied = new AnnotationObstacleIndex()
+ for (const obstacle of obstacles) occupied.add(obstacle)
+ const shifts = new Map()
+ const ordered = rectangles
+ .map((rectangle, order) => ({ order, rectangle }))
+ .sort(
+ (left, right) =>
+ Number(Boolean(right.rectangle.pinnedShift)) -
+ Number(Boolean(left.rectangle.pinnedShift)) ||
+ right.rectangle.priority - left.rectangle.priority ||
+ left.order - right.order,
+ )
+
+ for (const { rectangle } of ordered) {
+ const selected = rectangle.pinnedShift ?? resolveLabelShift(rectangle, occupied)
+ const shift = selected ?? { dx: 0, dy: 0 }
+ const resolved = rectangle.pinnedShift !== undefined || selected !== undefined
+ occupied.add({
+ x: rectangle.x + shift.dx,
+ y: rectangle.y + shift.dy,
+ width: rectangle.width,
+ height: rectangle.height,
+ })
+ shifts.set(rectangle.id, { id: rectangle.id, ...shift, resolved })
+ }
+
+ return rectangles.map(
+ (rectangle) => shifts.get(rectangle.id) ?? { id: rectangle.id, dx: 0, dy: 0, resolved: false },
+ )
+}
+
+export function resolveSvgAnnotationCollisions(
+ svg: SVGSVGElement,
+ options: { layoutOverrides?: AnnotationLayoutOverrides } = {},
+): AnnotationPreflightIssue[] {
+ const labels = Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]'))
+ if (labels.length === 0) return []
+
+ for (const label of labels) {
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
+ if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
+ label.removeAttribute('data-floorplan-layout-unresolved')
+ delete label.dataset.floorplanAnnotationLayoutDx
+ delete label.dataset.floorplanAnnotationLayoutDy
+ }
+ resetDimensionConnectors(svg)
+
+ const pinnedLocalById = new Map()
+ const rectangles: AnnotationLabelRectangle[] = labels.map((label, index) => {
+ const bounds = label.getBoundingClientRect()
+ const matrix = label.getScreenCTM()
+ const id = svgAnnotationLabelId(label, index)
+ label.dataset.floorplanAnnotationId = id
+ const override = options.layoutOverrides?.[id]
+ const pinnedLocal =
+ override?.pinned === true && Number.isFinite(override.dx) && Number.isFinite(override.dy)
+ ? { dx: override.dx, dy: override.dy }
+ : undefined
+ if (pinnedLocal) pinnedLocalById.set(id, { x: pinnedLocal.dx, y: pinnedLocal.dy })
+ const pinnedShift =
+ pinnedLocal && matrix
+ ? {
+ dx: matrix.a * pinnedLocal.dx + matrix.c * pinnedLocal.dy,
+ dy: matrix.b * pinnedLocal.dx + matrix.d * pinnedLocal.dy,
+ }
+ : undefined
+ const tangentLength = matrix ? Math.hypot(matrix.a, matrix.b) : 0
+ const outsideStartLocalX = Number(
+ label.dataset.floorplanDimensionOutsideStartLocalX ?? Number.NaN,
+ )
+ const outsideStartLocalY = Number(
+ label.dataset.floorplanDimensionOutsideStartLocalY ?? Number.NaN,
+ )
+ const preferredShifts =
+ matrix && Number.isFinite(outsideStartLocalX) && Number.isFinite(outsideStartLocalY)
+ ? [
+ {
+ dx: matrix.a * outsideStartLocalX + matrix.c * outsideStartLocalY,
+ dy: matrix.b * outsideStartLocalX + matrix.d * outsideStartLocalY,
+ },
+ ]
+ : undefined
+ return {
+ id,
+ x: bounds.x,
+ y: bounds.y,
+ width: bounds.width,
+ height: bounds.height,
+ priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
+ text: label.textContent?.trim() ?? '',
+ labelPlacement:
+ label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside',
+ pinnedShift,
+ tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
+ tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined,
+ preferredShifts,
+ }
+ })
+ const obstacles = Array.from(
+ svg.querySelectorAll('[data-floorplan-annotation-obstacle]'),
+ ).flatMap(svgAnnotationObstacleRectangles)
+ const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
+ const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles)
+
+ labels.forEach((label, index) => {
+ const rectangle = rectangles[index]
+ const shift = rectangle && shifts.find((candidate) => candidate.id === rectangle.id)
+ if (!shift || (shift.dx === 0 && shift.dy === 0)) {
+ label.dataset.floorplanAnnotationLayoutDx = '0'
+ label.dataset.floorplanAnnotationLayoutDy = '0'
+ if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
+ return
+ }
+ const matrix = label.getScreenCTM()
+ if (!matrix) return
+ const local = pinnedLocalById.get(shift.id) ?? screenVectorToLocal(matrix, shift.dx, shift.dy)
+ label.dataset.floorplanAnnotationLayoutDx = String(local.x)
+ label.dataset.floorplanAnnotationLayoutDy = String(local.y)
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
+ label.setAttribute('transform', `${defaultTransform} translate(${local.x} ${local.y})`.trim())
+ const preferredShift = rectangle.preferredShifts?.[0]
+ const usedOutsideStart =
+ preferredShift !== undefined &&
+ Math.hypot(shift.dx - preferredShift.dx, shift.dy - preferredShift.dy) < 0.5
+ if (usedOutsideStart) applyOutsideStartDimensionLine(label)
+ else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
+ showDimensionLeader(label, matrix, shift.dx, shift.dy)
+ }
+ if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
+ })
+ return preflightIssues
+}
+
+export function observeSvgAnnotationLayoutChanges(
+ svg: SVGSVGElement,
+ onChange: () => void,
+): () => void {
+ let scheduledFrame: number | null = null
+ const schedule = () => {
+ if (scheduledFrame !== null) return
+ const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0))
+ scheduledFrame = requestFrame(() => {
+ scheduledFrame = null
+ onChange()
+ })
+ }
+ const observer = new MutationObserver((mutations) => {
+ if (mutations.some(isAnnotationLayoutMutation)) schedule()
+ })
+ observer.observe(svg, {
+ attributes: true,
+ attributeFilter: [
+ 'cx',
+ 'cy',
+ 'd',
+ 'dominant-baseline',
+ 'font-family',
+ 'font-size',
+ 'font-weight',
+ 'height',
+ 'points',
+ 'r',
+ 'rx',
+ 'ry',
+ 'stroke-width',
+ 'text-anchor',
+ 'transform',
+ 'visibility',
+ 'width',
+ 'x',
+ 'x1',
+ 'x2',
+ 'y',
+ 'y1',
+ 'y2',
+ ],
+ characterData: true,
+ childList: true,
+ subtree: true,
+ })
+ return () => {
+ observer.disconnect()
+ if (scheduledFrame === null) return
+ if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame)
+ else clearTimeout(scheduledFrame)
+ }
+}
+
+function isAnnotationLayoutMutation(mutation: MutationRecord): boolean {
+ if (mutation.type !== 'attributes') return true
+ const attribute = mutation.attributeName ?? ''
+ const target = mutation.target as Element
+ const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null
+
+ if (
+ attribute === 'data-floorplan-annotation-id' ||
+ attribute === 'data-floorplan-annotation-layout-dx' ||
+ attribute === 'data-floorplan-annotation-layout-dy' ||
+ attribute === 'data-floorplan-layout-unresolved'
+ ) {
+ return false
+ }
+ if (
+ closest?.('[data-floorplan-annotation-label]') &&
+ (attribute === 'style' || attribute === 'transform')
+ ) {
+ return false
+ }
+ if (
+ closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') &&
+ (attribute === 'x1' ||
+ attribute === 'x2' ||
+ attribute === 'y1' ||
+ attribute === 'y2' ||
+ attribute === 'visibility')
+ ) {
+ return false
+ }
+ return true
+}
+
+export function collectAnnotationLayoutPreflightIssues(
+ rectangles: readonly AnnotationLabelRectangle[],
+ shifts: readonly AnnotationLabelShift[],
+ obstacles: readonly AnnotationObstacleRectangle[] = [],
+): AnnotationPreflightIssue[] {
+ const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
+ const finalRectangles = rectangles.map((rectangle) => {
+ const shift = shiftsById.get(rectangle.id) ?? {
+ id: rectangle.id,
+ dx: 0,
+ dy: 0,
+ resolved: false,
+ }
+ return {
+ source: rectangle,
+ shift,
+ bounds: {
+ x: rectangle.x + shift.dx,
+ y: rectangle.y + shift.dy,
+ width: rectangle.width,
+ height: rectangle.height,
+ },
+ }
+ })
+ const issues: AnnotationPreflightIssue[] = []
+ const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
+ if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
+ issues.push({ id, kind, severity: 'warning', message })
+ }
+
+ for (const entry of finalRectangles) {
+ const label = preflightLabel(entry.source)
+ if (entry.source.labelPlacement === 'outside-end') {
+ addIssue(
+ entry.source.id,
+ 'short-unreadable-segment',
+ `${label} is too short for inline text and uses an outside label or leader.`,
+ )
+ }
+ if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
+ addIssue(
+ entry.source.id,
+ 'plan-geometry-conflict',
+ `${label} still conflicts with fixed plan geometry after automatic layout.`,
+ )
+ }
+ if (!entry.shift.resolved) {
+ const collidesWithLabel = finalRectangles.some(
+ (candidate) =>
+ candidate.source.id !== entry.source.id &&
+ rectanglesOverlap(entry.bounds, candidate.bounds),
+ )
+ if (collidesWithLabel) {
+ addIssue(
+ entry.source.id,
+ 'unresolved-collision',
+ `${label} still overlaps another annotation after automatic layout.`,
+ )
+ }
+ }
+ }
+ return issues
+}
+
+function preflightLabel(rectangle: AnnotationLabelRectangle): string {
+ const text = rectangle.text?.trim()
+ return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
+}
+
+export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
+ const explicit = label.dataset.floorplanAnnotationId?.trim()
+ if (explicit) return explicit
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
+ const text = label.textContent?.trim() ?? ''
+ return `annotation:${index}:${text}:${defaultTransform}`
+}
+
+function resetDimensionConnectors(svg: SVGSVGElement): void {
+ for (const line of svg.querySelectorAll('[data-floorplan-dimension-line]')) {
+ line.setAttribute(
+ 'x1',
+ line.dataset.floorplanDimensionDefaultX1 ?? line.getAttribute('x1') ?? '0',
+ )
+ line.setAttribute(
+ 'y1',
+ line.dataset.floorplanDimensionDefaultY1 ?? line.getAttribute('y1') ?? '0',
+ )
+ line.setAttribute(
+ 'x2',
+ line.dataset.floorplanDimensionDefaultX2 ?? line.getAttribute('x2') ?? '0',
+ )
+ line.setAttribute(
+ 'y2',
+ line.dataset.floorplanDimensionDefaultY2 ?? line.getAttribute('y2') ?? '0',
+ )
+ }
+ for (const leader of svg.querySelectorAll('[data-floorplan-dimension-leader]')) {
+ leader.setAttribute('visibility', 'hidden')
+ }
+}
+
+function applyOutsideStartDimensionLine(label: SVGGElement): void {
+ const dimension = label.closest('[data-floorplan-dimension]')
+ const line = dimension?.querySelector('[data-floorplan-dimension-line]')
+ if (!line) return
+ const { dataset } = line
+ if (
+ dataset.floorplanDimensionOutsideStartX1 === undefined ||
+ dataset.floorplanDimensionOutsideStartY1 === undefined ||
+ dataset.floorplanDimensionOutsideStartX2 === undefined ||
+ dataset.floorplanDimensionOutsideStartY2 === undefined
+ ) {
+ return
+ }
+ line.setAttribute('x1', dataset.floorplanDimensionOutsideStartX1)
+ line.setAttribute('y1', dataset.floorplanDimensionOutsideStartY1)
+ line.setAttribute('x2', dataset.floorplanDimensionOutsideStartX2)
+ line.setAttribute('y2', dataset.floorplanDimensionOutsideStartY2)
+}
+
+function showDimensionLeader(
+ label: SVGGElement,
+ labelMatrix: DOMMatrix,
+ dx: number,
+ dy: number,
+): void {
+ const dimension = label.closest('[data-floorplan-dimension]') as SVGGElement | null
+ const leader = dimension?.querySelector('[data-floorplan-dimension-leader]')
+ const dimensionLine = dimension?.querySelector('[data-floorplan-dimension-line]')
+ const dimensionMatrix = dimension?.getScreenCTM()
+ if (!leader || !dimensionLine || !dimensionMatrix) return
+
+ const start = dimensionEndpoint(label, 'start')
+ const end = dimensionEndpoint(label, 'end')
+ if (!start || !end) return
+ dimensionLine.setAttribute('x1', String(start.x))
+ dimensionLine.setAttribute('y1', String(start.y))
+ dimensionLine.setAttribute('x2', String(end.x))
+ dimensionLine.setAttribute('y2', String(end.y))
+ const labelScreen = { x: labelMatrix.e + dx, y: labelMatrix.f + dy }
+ const startScreen = localPointToScreen(dimensionMatrix, start.x, start.y)
+ const endScreen = localPointToScreen(dimensionMatrix, end.x, end.y)
+ const anchor =
+ Math.hypot(labelScreen.x - startScreen.x, labelScreen.y - startScreen.y) <
+ Math.hypot(labelScreen.x - endScreen.x, labelScreen.y - endScreen.y)
+ ? start
+ : end
+ const labelPoint = screenPointToLocal(dimensionMatrix, labelScreen.x, labelScreen.y)
+
+ leader.setAttribute('x1', String(anchor.x))
+ leader.setAttribute('y1', String(anchor.y))
+ leader.setAttribute('x2', String(labelPoint.x))
+ leader.setAttribute('y2', String(labelPoint.y))
+ leader.setAttribute('visibility', 'visible')
+}
+
+function dimensionEndpoint(
+ label: SVGGElement,
+ endpoint: 'start' | 'end',
+): { x: number; y: number } | null {
+ const x = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}X`])
+ const y = Number(label.dataset[`floorplanDimension${endpoint === 'start' ? 'Start' : 'End'}Y`])
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null
+}
+
+function localPointToScreen(matrix: DOMMatrix, x: number, y: number) {
+ return {
+ x: matrix.a * x + matrix.c * y + matrix.e,
+ y: matrix.b * x + matrix.d * y + matrix.f,
+ }
+}
+
+function screenPointToLocal(matrix: DOMMatrix, x: number, y: number) {
+ const local = screenVectorToLocal(matrix, x - matrix.e, y - matrix.f)
+ return { x: local.x, y: local.y }
+}
+
+function svgAnnotationObstacleRectangles(
+ obstacle: SVGGraphicsElement,
+): AnnotationObstacleRectangle[] {
+ const bounds = obstacle.getBoundingClientRect()
+ const fallback = [{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }]
+ if (obstacle.getAttribute('data-floorplan-annotation-obstacle') !== 'outline') return fallback
+
+ const geometry = obstacle as SVGGeometryElement
+ const matrix = geometry.getScreenCTM()
+ if (!matrix || typeof geometry.getTotalLength !== 'function') return fallback
+
+ try {
+ const length = geometry.getTotalLength()
+ const screenScale = Math.max(Math.hypot(matrix.a, matrix.b), Math.hypot(matrix.c, matrix.d))
+ const sampleCount = Math.max(1, Math.ceil((length * screenScale) / OUTLINE_SAMPLE_SPACING_PX))
+ const points = Array.from({ length: sampleCount + 1 }, (_, index) => {
+ const point = geometry.getPointAtLength((length * index) / sampleCount)
+ return {
+ x: matrix.a * point.x + matrix.c * point.y + matrix.e,
+ y: matrix.b * point.x + matrix.d * point.y + matrix.f,
+ }
+ })
+ return polylineObstacleRectangles(points)
+ } catch {
+ return fallback
+ }
+}
+
+export function polylineObstacleRectangles(
+ points: readonly { x: number; y: number }[],
+): AnnotationObstacleRectangle[] {
+ if (points.length === 1) {
+ const point = points[0]!
+ return [
+ {
+ x: point.x - OUTLINE_OBSTACLE_PADDING_PX,
+ y: point.y - OUTLINE_OBSTACLE_PADDING_PX,
+ width: OUTLINE_OBSTACLE_PADDING_PX * 2,
+ height: OUTLINE_OBSTACLE_PADDING_PX * 2,
+ },
+ ]
+ }
+
+ const rectangles: AnnotationObstacleRectangle[] = []
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]!
+ const end = points[index]!
+ rectangles.push({
+ x: Math.min(start.x, end.x) - OUTLINE_OBSTACLE_PADDING_PX,
+ y: Math.min(start.y, end.y) - OUTLINE_OBSTACLE_PADDING_PX,
+ width: Math.abs(end.x - start.x) + OUTLINE_OBSTACLE_PADDING_PX * 2,
+ height: Math.abs(end.y - start.y) + OUTLINE_OBSTACLE_PADDING_PX * 2,
+ })
+ }
+ return rectangles
+}
+
+function resolveLabelShift(
+ rectangle: AnnotationLabelRectangle,
+ occupied: AnnotationObstacleIndex,
+): { dx: number; dy: number } | undefined {
+ const candidates = new Map()
+ const visited = new Set()
+ const addCandidate = (dx: number, dy: number, preference = 0) => {
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return
+ const key = `${dx}:${dy}`
+ if (visited.has(key)) return
+ const existing = candidates.get(key)
+ if (!existing || preference < existing.preference) {
+ candidates.set(key, {
+ dx,
+ dy,
+ preference,
+ cost: candidateCost({ dx, dy, preference }, rectangle),
+ })
+ }
+ }
+ addCandidate(0, 0, -2)
+ for (const preferred of rectangle.preferredShifts ?? []) {
+ addCandidate(preferred.dx, preferred.dy, -1)
+ }
+
+ while (candidates.size > 0 && visited.size < MAX_LABEL_SHIFT_CANDIDATES) {
+ let candidate: { dx: number; dy: number; preference: number; cost: number } | undefined
+ for (const queued of candidates.values()) {
+ if (!candidate || queued.cost < candidate.cost) candidate = queued
+ }
+ if (!candidate) return undefined
+ const key = `${candidate.dx}:${candidate.dy}`
+ candidates.delete(key)
+ visited.add(key)
+
+ const shifted = {
+ ...rectangle,
+ x: rectangle.x + candidate.dx,
+ y: rectangle.y + candidate.dy,
+ }
+ const blockers = occupied.findOverlaps(shifted)
+ if (blockers.length === 0) return { dx: candidate.dx, dy: candidate.dy }
+
+ const blockerBounds = blockers.reduce(
+ (bounds, blocker) => ({
+ minX: Math.min(bounds.minX, blocker.x),
+ minY: Math.min(bounds.minY, blocker.y),
+ maxX: Math.max(bounds.maxX, blocker.x + blocker.width),
+ maxY: Math.max(bounds.maxY, blocker.y + blocker.height),
+ }),
+ {
+ minX: Number.POSITIVE_INFINITY,
+ minY: Number.POSITIVE_INFINITY,
+ maxX: Number.NEGATIVE_INFINITY,
+ maxY: Number.NEGATIVE_INFINITY,
+ },
+ )
+ const left = blockerBounds.minX - LABEL_PLACEMENT_GAP_PX - rectangle.width - rectangle.x
+ const right = blockerBounds.maxX + LABEL_PLACEMENT_GAP_PX - rectangle.x
+ const above = blockerBounds.minY - LABEL_PLACEMENT_GAP_PX - rectangle.height - rectangle.y
+ const below = blockerBounds.maxY + LABEL_PLACEMENT_GAP_PX - rectangle.y
+
+ addCandidate(candidate.dx, above)
+ addCandidate(candidate.dx, below)
+ addCandidate(left, candidate.dy)
+ addCandidate(right, candidate.dy)
+ addCandidate(left, above)
+ addCandidate(right, above)
+ addCandidate(left, below)
+ addCandidate(right, below)
+ }
+ return undefined
+}
+
+function candidateCost(
+ candidate: { dx: number; dy: number; preference?: number },
+ rectangle: AnnotationLabelRectangle,
+): number {
+ const distance = Math.hypot(candidate.dx, candidate.dy)
+ const preference = (candidate.preference ?? 0) * PREFERRED_SHIFT_COST_STEP
+ if (rectangle.tangentX === undefined || rectangle.tangentY === undefined) {
+ return preference + distance
+ }
+
+ const perpendicularMovement = Math.abs(
+ candidate.dx * -rectangle.tangentY + candidate.dy * rectangle.tangentX,
+ )
+ return preference + distance + perpendicularMovement * 4
+}
+
+export function isFloorplanAnnotationObstacleGeometry(geometry: FloorplanGeometry): boolean {
+ if (floorplanAnnotationObstacleMode(geometry)) return true
+ if (geometry.kind !== 'group') return false
+ const hasPlate = geometry.children.some(
+ (child) => child.kind === 'rect' || child.kind === 'circle',
+ )
+ const hasUprightText = geometry.children.some((child) => child.kind === 'text' && child.upright)
+ return hasPlate && hasUprightText
+}
+
+export function floorplanAnnotationObstacleMode(
+ geometry: FloorplanGeometry,
+): 'bounds' | 'outline' | '' | undefined {
+ const metadata = readFloorplanGeometryMetadata(geometry)
+ if (metadata.annotationObstacle) return metadata.annotationObstacle
+ switch (metadata.annotationRole) {
+ case 'room-label':
+ return geometry.kind === 'text' ? 'bounds' : undefined
+ case 'column-center':
+ return geometry.kind === 'line' || geometry.kind === 'text' ? 'bounds' : undefined
+ case 'stair-annotation':
+ return geometry.kind === 'polyline' || geometry.kind === 'line' ? 'outline' : 'bounds'
+ default:
+ return undefined
+ }
+}
+
+function rectanglesOverlap(
+ left: Pick,
+ right: Pick,
+): boolean {
+ return !(
+ left.x + left.width + LABEL_GAP_PX <= right.x ||
+ right.x + right.width + LABEL_GAP_PX <= left.x ||
+ left.y + left.height + LABEL_GAP_PX <= right.y ||
+ right.y + right.height + LABEL_GAP_PX <= left.y
+ )
+}
+
+function screenVectorToLocal(matrix: DOMMatrix, dx: number, dy: number) {
+ const determinant = matrix.a * matrix.d - matrix.b * matrix.c
+ if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
+ return {
+ x: (matrix.d * dx - matrix.c * dy) / determinant,
+ y: (-matrix.b * dx + matrix.a * dy) / determinant,
+ }
+}
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx
new file mode 100644
index 00000000..c48ef972
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx
@@ -0,0 +1,236 @@
+import { describe, expect, test } from 'bun:test'
+import type { FloorplanGeometry } from '@pascal-app/core'
+import { renderToStaticMarkup } from 'react-dom/server'
+import {
+ computeArchitecturalDimensionLayout,
+ FloorplanDimensionRenderer,
+ FloorplanDimensionStringRenderer,
+ floorplanDimensionAnnotationPriority,
+} from './floorplan-dimension-renderer'
+
+const dimension = {
+ kind: 'dimension',
+ start: [0, 0],
+ end: [4, 0],
+ offsetNormal: [0, 1],
+ offsetDistance: 0.45,
+ extensionOvershoot: 0.12,
+ text: `13'-1 1/2"`,
+} satisfies Extract
+
+describe('architectural floor-plan dimensions', () => {
+ test('leaves a paper-space gap before solid extension lines', () => {
+ const layout = computeArchitecturalDimensionLayout(dimension, 0)
+
+ expect(layout).not.toBeNull()
+ expect(layout?.extensionStart).toEqual([0, 0.075])
+ expect(layout?.extensionEnd).toEqual([4, 0.075])
+ expect(layout?.extensionStartTip[0]).toBe(0)
+ expect(layout?.extensionStartTip[1]).toBeCloseTo(0.57)
+ expect(layout?.extensionEndTip[0]).toBe(4)
+ expect(layout?.extensionEndTip[1]).toBeCloseTo(0.57)
+ expect(layout?.dimensionStart).toEqual([0, 0.45])
+ expect(layout?.dimensionEnd).toEqual([4, 0.45])
+ expect(layout?.dimensionLineEnd).toEqual([4, 0.45])
+ expect(layout?.labelPlacement).toBe('inside')
+ })
+
+ test('builds consistent 45-degree architectural slash terminators', () => {
+ const layout = computeArchitecturalDimensionLayout(dimension, 0)
+
+ expect(layout?.tickHalfVector[0]).toBeCloseTo(0.06364, 5)
+ expect(layout?.tickHalfVector[1]).toBeCloseTo(-0.06364, 5)
+ })
+
+ test('honors dimension standard overrides for gaps, terminators, and text placement', () => {
+ const customDimension = {
+ ...dimension,
+ extensionStartGap: 0.2,
+ terminator: 'dot',
+ textPosition: 'centered',
+ } satisfies Extract
+ const layout = computeArchitecturalDimensionLayout(customDimension, 0)
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(layout?.extensionStart).toEqual([0, 0.2])
+ expect(markup).toContain(' {
+ const shortDimension = {
+ ...dimension,
+ end: [0.23, 0] as [number, number],
+ text: '0.23m',
+ }
+ const layout = computeArchitecturalDimensionLayout(shortDimension, 0)
+
+ expect(layout?.labelPlacement).toBe('outside-end')
+ expect(layout?.labelPoint[0]).toBeGreaterThan(0.23)
+ expect(layout?.outsideStartLabelPoint?.[0]).toBeLessThan(0)
+ expect(layout?.outsideStartDimensionLineStart?.[0]).toBeLessThan(
+ layout?.outsideStartLabelPoint?.[0] ?? 0,
+ )
+ expect(layout?.dimensionLineEnd[0]).toBeGreaterThan(layout?.labelPoint[0] ?? 0)
+ expect(layout?.dimensionEnd).toEqual([0.23, 0.45])
+
+ const documentLayout = computeArchitecturalDimensionLayout(shortDimension, 0, 0.01)
+ expect(documentLayout?.labelPlacement).toBe('outside-end')
+ expect(documentLayout?.dimensionLineEnd[0]).toBeGreaterThan(documentLayout?.labelPoint[0] ?? 0)
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ expect(markup).toContain('data-floorplan-dimension-outside-start-local-x=')
+ expect(markup).not.toContain('data-floorplan-dimension-leader=""')
+
+ const documentMarkup = renderToStaticMarkup(
+ ,
+ )
+ expect(documentMarkup).toContain('data-floorplan-dimension-leader=""')
+ expect(documentMarkup).toContain('visibility="hidden"')
+ })
+
+ test('aligns stepped feature origins to an explicit exterior baseline', () => {
+ const layout = computeArchitecturalDimensionLayout(
+ {
+ ...dimension,
+ start: [0, 0],
+ end: [4, 1],
+ dimensionStart: [0, 2],
+ dimensionEnd: [4, 2],
+ offsetDistance: 2,
+ },
+ 0,
+ )
+
+ expect(layout?.dimensionStart).toEqual([0, 2])
+ expect(layout?.dimensionEnd).toEqual([4, 2])
+ expect(layout?.extensionStart).toEqual([0, 0.075])
+ expect(layout?.extensionEnd).toEqual([4, 1.075])
+ expect(layout?.extensionStartTip).toEqual([0, 2.12])
+ expect(layout?.extensionEndTip).toEqual([4, 2.12])
+ })
+
+ test('renders one uninterrupted line with the label above it', () => {
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup.match(/ {
+ expect(floorplanDimensionAnnotationPriority(1.67)).toBeGreaterThan(
+ floorplanDimensionAnnotationPriority(0.55),
+ )
+ })
+
+ test('keeps labels readable after the scene rotates', () => {
+ expect(computeArchitecturalDimensionLayout(dimension, 180)?.labelAngleDeg).toBe(-180)
+ })
+
+ test('resolves document annotation sizes from paper points', () => {
+ const layout = computeArchitecturalDimensionLayout(dimension, 0, 0.01)
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(layout?.extensionStart[1]).toBeCloseTo(0.03)
+ expect(layout?.extensionStartTip[1]).toBeCloseTo(0.49)
+ expect(markup).toContain('font-size="0.08"')
+ expect(markup).toContain('y="-0.05"')
+ })
+
+ test('uses PDF-safe label plates and hairline measurement strokes for export', () => {
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-dimension-label-plate=""')
+ expect(markup).toContain('stroke-width="0.5"')
+ expect(markup).not.toContain('paint-order="stroke"')
+ expect(markup).not.toContain('stroke="#ffffff"')
+ })
+
+ test('renders a dimension string with shared witness extension lines and ticks', () => {
+ const stringGeometry = {
+ kind: 'dimension-string',
+ segments: [
+ {
+ start: [0, 0],
+ end: [2, 0],
+ dimensionStart: [0, 1],
+ dimensionEnd: [2, 1],
+ text: '2m',
+ },
+ {
+ start: [2, 0],
+ end: [5, 0],
+ dimensionStart: [2, 1],
+ dimensionEnd: [5, 1],
+ text: '3m',
+ },
+ ],
+ offsetNormal: [0, 1],
+ offsetDistance: 1,
+ extensionOvershoot: 0.12,
+ textPosition: 'above',
+ } satisfies Extract
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-dimension-string=""')
+ expect(markup.match(/ {
+ const automaticString = {
+ kind: 'dimension-string',
+ segments: [{ start: [0, 0], end: [2, 0], text: '2m' }],
+ offsetNormal: [0, 1],
+ offsetDistance: 0.55,
+ extensionOvershoot: 0.12,
+ textPosition: 'above',
+ } satisfies Extract
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-dimension-default-y1="0.55"')
+ expect(markup).toContain('data-floorplan-dimension-default-y2="0.55"')
+ })
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx
new file mode 100644
index 00000000..e917a273
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx
@@ -0,0 +1,608 @@
+import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
+import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
+
+const EXTENSION_START_GAP = 0.075
+const TICK_HALF_LENGTH = 0.09
+const LABEL_FONT_SIZE = 0.15
+const LABEL_BASELINE_OFFSET = 0.12
+const LABEL_CHARACTER_WIDTH_RATIO = 0.62
+const LABEL_END_GAP = 0.075
+const DOCUMENT_EXTENSION_START_GAP_PT = 3
+const DOCUMENT_EXTENSION_OVERSHOOT_PT = 4
+const DOCUMENT_TICK_HALF_LENGTH_PT = 4.5
+const DOCUMENT_LABEL_FONT_SIZE_PT = 8
+const DOCUMENT_LABEL_BASELINE_OFFSET_PT = 5
+const DOCUMENT_LABEL_END_GAP_PT = 3
+const LINE_STROKE_WIDTH_PX = 0.9
+const TICK_STROKE_WIDTH_PX = 1.35
+const PDF_LINE_STROKE_WIDTH_PT = 0.5
+const PDF_TICK_STROKE_WIDTH_PT = 0.75
+const SQRT_ONE_HALF = Math.SQRT1_2
+
+type FloorplanDimensionRenderMode = 'screen' | 'pdf'
+
+type DimensionGeometry = Extract
+type DimensionStringGeometry = Extract
+type DimensionTerminator = NonNullable
+
+export type ArchitecturalDimensionLayout = {
+ dimensionStart: FloorplanPoint
+ dimensionEnd: FloorplanPoint
+ dimensionLineStart: FloorplanPoint
+ dimensionLineEnd: FloorplanPoint
+ extensionStart: FloorplanPoint
+ extensionEnd: FloorplanPoint
+ extensionStartTip: FloorplanPoint
+ extensionEndTip: FloorplanPoint
+ tickHalfVector: FloorplanPoint
+ labelPoint: FloorplanPoint
+ labelAngleDeg: number
+ labelPlacement: 'inside' | 'outside-end'
+ outsideStartLabelPoint?: FloorplanPoint
+ outsideStartDimensionLineStart?: FloorplanPoint
+}
+
+export function floorplanDimensionAnnotationPriority(offsetDistance: number): number {
+ return 100 + Math.round(Math.abs(offsetDistance) * 100)
+}
+
+export function computeArchitecturalDimensionLayout(
+ geometry: DimensionGeometry,
+ sceneRotationDeg: number,
+ annotationUnitsPerPoint?: number,
+): ArchitecturalDimensionLayout | null {
+ const extensionStartGap = annotationUnitsPerPoint
+ ? DOCUMENT_EXTENSION_START_GAP_PT * annotationUnitsPerPoint
+ : (geometry.extensionStartGap ?? EXTENSION_START_GAP)
+ const extensionOvershoot = annotationUnitsPerPoint
+ ? DOCUMENT_EXTENSION_OVERSHOOT_PT * annotationUnitsPerPoint
+ : geometry.extensionOvershoot
+ const tickHalfLength = annotationUnitsPerPoint
+ ? DOCUMENT_TICK_HALF_LENGTH_PT * annotationUnitsPerPoint
+ : TICK_HALF_LENGTH
+ const labelFontSize = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
+ : LABEL_FONT_SIZE
+ const labelEndGap = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_END_GAP_PT * annotationUnitsPerPoint
+ : LABEL_END_GAP
+ const offsetX = geometry.offsetNormal[0] * geometry.offsetDistance
+ const offsetY = geometry.offsetNormal[1] * geometry.offsetDistance
+ const dimensionStart: FloorplanPoint = geometry.dimensionStart ?? [
+ geometry.start[0] + offsetX,
+ geometry.start[1] + offsetY,
+ ]
+ const dimensionEnd: FloorplanPoint = geometry.dimensionEnd ?? [
+ geometry.end[0] + offsetX,
+ geometry.end[1] + offsetY,
+ ]
+ const dx = dimensionEnd[0] - dimensionStart[0]
+ const dy = dimensionEnd[1] - dimensionStart[1]
+ const length = Math.hypot(dx, dy)
+ if (length < 1e-6) return null
+
+ const dirX = dx / length
+ const dirY = dy / length
+ const startOffsetDistance = dot(subtract(dimensionStart, geometry.start), geometry.offsetNormal)
+ const endOffsetDistance = dot(subtract(dimensionEnd, geometry.end), geometry.offsetNormal)
+ const startExtensionGap = Math.min(extensionStartGap, Math.max(0, startOffsetDistance - 0.01))
+ const endExtensionGap = Math.min(extensionStartGap, Math.max(0, endOffsetDistance - 0.01))
+
+ // A 45-degree architectural slash. Every terminator within one string uses
+ // this same vector instead of rotating independently around its endpoint.
+ const tickHalfVector: FloorplanPoint = [
+ (dirX + dirY) * tickHalfLength * SQRT_ONE_HALF,
+ (dirY - dirX) * tickHalfLength * SQRT_ONE_HALF,
+ ]
+ const labelWidth = Math.max(
+ labelFontSize,
+ geometry.text.length * labelFontSize * LABEL_CHARACTER_WIDTH_RATIO,
+ )
+ const labelPlacement =
+ length >= labelWidth + labelEndGap * 2 ? ('inside' as const) : ('outside-end' as const)
+ const direction: FloorplanPoint = [dirX, dirY]
+ const labelPoint =
+ labelPlacement === 'inside'
+ ? ([
+ (dimensionStart[0] + dimensionEnd[0]) / 2,
+ (dimensionStart[1] + dimensionEnd[1]) / 2,
+ ] as FloorplanPoint)
+ : addScaled(dimensionEnd, direction, labelEndGap + labelWidth / 2)
+ const dimensionLineEnd =
+ labelPlacement === 'inside'
+ ? dimensionEnd
+ : addScaled(dimensionEnd, direction, labelEndGap * 2 + labelWidth)
+ const outsideStartLabelPoint =
+ labelPlacement === 'outside-end'
+ ? addScaled(dimensionStart, direction, -(labelEndGap + labelWidth / 2))
+ : undefined
+ const outsideStartDimensionLineStart =
+ labelPlacement === 'outside-end'
+ ? addScaled(dimensionStart, direction, -(labelEndGap * 2 + labelWidth))
+ : undefined
+
+ return {
+ dimensionStart,
+ dimensionEnd,
+ dimensionLineStart: dimensionStart,
+ dimensionLineEnd,
+ extensionStart: addScaled(geometry.start, geometry.offsetNormal, startExtensionGap),
+ extensionEnd: addScaled(geometry.end, geometry.offsetNormal, endExtensionGap),
+ extensionStartTip: addScaled(dimensionStart, geometry.offsetNormal, extensionOvershoot),
+ extensionEndTip: addScaled(dimensionEnd, geometry.offsetNormal, extensionOvershoot),
+ tickHalfVector,
+ labelPoint,
+ labelAngleDeg: resolveFloorplanLabelAngle(Math.atan2(dy, dx), sceneRotationDeg),
+ labelPlacement,
+ outsideStartLabelPoint,
+ outsideStartDimensionLineStart,
+ }
+}
+
+function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint {
+ return [left[0] - right[0], left[1] - right[1]]
+}
+
+function dot(left: FloorplanPoint, right: FloorplanPoint): number {
+ return left[0] * right[0] + left[1] * right[1]
+}
+
+function addScaled(
+ point: FloorplanPoint,
+ direction: FloorplanPoint,
+ distance: number,
+): FloorplanPoint {
+ return [point[0] + direction[0] * distance, point[1] + direction[1] * distance]
+}
+
+export function FloorplanDimensionRenderer({
+ geometry,
+ sceneRotationDeg = 0,
+ stroke = geometry.stroke ?? '#334155',
+ annotationUnitsPerPoint,
+ renderMode = 'screen',
+}: {
+ geometry: DimensionGeometry
+ sceneRotationDeg?: number
+ stroke?: string
+ annotationUnitsPerPoint?: number
+ renderMode?: FloorplanDimensionRenderMode
+}): React.ReactElement | null {
+ const layout = computeArchitecturalDimensionLayout(
+ geometry,
+ sceneRotationDeg,
+ annotationUnitsPerPoint,
+ )
+ if (!layout) return null
+
+ const labelFontSize = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
+ : LABEL_FONT_SIZE
+ const labelBaselineOffset = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
+ : LABEL_BASELINE_OFFSET
+ const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
+
+ const lineProps = {
+ stroke,
+ strokeLinecap: 'butt' as const,
+ strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
+ vectorEffect: 'non-scaling-stroke' as const,
+ }
+ const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
+ const terminator = geometry.terminator ?? 'architectural-tick'
+ const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
+ const outsideStartLocalShift = layout.outsideStartLabelPoint
+ ? rotateVector(
+ subtract(layout.outsideStartLabelPoint, layout.labelPoint),
+ (-layout.labelAngleDeg * Math.PI) / 180,
+ )
+ : undefined
+
+ return (
+
+
+
+
+ {renderTerminator(
+ terminator,
+ layout.dimensionStart,
+ layout.dimensionEnd,
+ layout,
+ lineProps,
+ tickStrokeWidth,
+ )}
+ {renderTerminator(
+ terminator,
+ layout.dimensionEnd,
+ layout.dimensionStart,
+ layout,
+ lineProps,
+ tickStrokeWidth,
+ )}
+ {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
+
+ ) : null}
+
+
+
+
+ )
+}
+
+export function FloorplanDimensionStringRenderer({
+ geometry,
+ sceneRotationDeg = 0,
+ stroke = geometry.stroke ?? '#334155',
+ annotationUnitsPerPoint,
+ renderMode = 'screen',
+}: {
+ geometry: DimensionStringGeometry
+ sceneRotationDeg?: number
+ stroke?: string
+ annotationUnitsPerPoint?: number
+ renderMode?: FloorplanDimensionRenderMode
+}): React.ReactElement | null {
+ const segmentLayouts = geometry.segments.flatMap((segment, index) => {
+ const segmentGeometry: DimensionGeometry = {
+ kind: 'dimension',
+ start: segment.start,
+ end: segment.end,
+ dimensionStart: segment.dimensionStart,
+ dimensionEnd: segment.dimensionEnd,
+ offsetNormal: geometry.offsetNormal,
+ offsetDistance: geometry.offsetDistance,
+ extensionOvershoot: geometry.extensionOvershoot,
+ extensionStartGap: geometry.extensionStartGap,
+ terminator: geometry.terminator,
+ textPosition: geometry.textPosition,
+ text: segment.text,
+ stroke: geometry.stroke,
+ }
+ const layout = computeArchitecturalDimensionLayout(
+ segmentGeometry,
+ sceneRotationDeg,
+ annotationUnitsPerPoint,
+ )
+ return layout ? [{ index, layout, segment: segmentGeometry }] : []
+ })
+ if (segmentLayouts.length === 0) return null
+
+ const labelFontSize = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_FONT_SIZE_PT * annotationUnitsPerPoint
+ : LABEL_FONT_SIZE
+ const labelBaselineOffset = annotationUnitsPerPoint
+ ? DOCUMENT_LABEL_BASELINE_OFFSET_PT * annotationUnitsPerPoint
+ : LABEL_BASELINE_OFFSET
+ const labelY = geometry.textPosition === 'centered' ? labelFontSize * 0.35 : -labelBaselineOffset
+ const lineProps = {
+ stroke,
+ strokeLinecap: 'butt' as const,
+ strokeWidth: renderMode === 'pdf' ? PDF_LINE_STROKE_WIDTH_PT : LINE_STROKE_WIDTH_PX,
+ vectorEffect: 'non-scaling-stroke' as const,
+ }
+ const tickStrokeWidth = renderMode === 'pdf' ? PDF_TICK_STROKE_WIDTH_PT : TICK_STROKE_WIDTH_PX
+
+ const extensionLines = new Map()
+ const ticks = new Map<
+ string,
+ { point: FloorplanPoint; toward: FloorplanPoint; tickHalfVector: FloorplanPoint }
+ >()
+ for (const { layout } of segmentLayouts) {
+ extensionLines.set(pointKey(layout.dimensionStart), {
+ start: layout.extensionStart,
+ tip: layout.extensionStartTip,
+ })
+ extensionLines.set(pointKey(layout.dimensionEnd), {
+ start: layout.extensionEnd,
+ tip: layout.extensionEndTip,
+ })
+ ticks.set(pointKey(layout.dimensionStart), {
+ point: layout.dimensionStart,
+ toward: layout.dimensionEnd,
+ tickHalfVector: layout.tickHalfVector,
+ })
+ ticks.set(pointKey(layout.dimensionEnd), {
+ point: layout.dimensionEnd,
+ toward: layout.dimensionStart,
+ tickHalfVector: layout.tickHalfVector,
+ })
+ }
+ const terminator = geometry.terminator ?? 'architectural-tick'
+
+ return (
+
+ {[...extensionLines.values()].map((line, index) => (
+
+ ))}
+ {segmentLayouts.map(({ index, layout }) => (
+
+ ))}
+ {[...ticks.values()].map(({ point, toward, tickHalfVector }, index) =>
+ renderTerminator(
+ terminator,
+ point,
+ toward,
+ { tickHalfVector },
+ lineProps,
+ tickStrokeWidth,
+ `tick-${index}`,
+ ),
+ )}
+ {segmentLayouts.map(({ index, layout, segment }) => {
+ const labelTransform = `translate(${layout.labelPoint[0]} ${layout.labelPoint[1]}) rotate(${layout.labelAngleDeg})`
+ const outsideStartLocalShift = layout.outsideStartLabelPoint
+ ? rotateVector(
+ subtract(layout.outsideStartLabelPoint, layout.labelPoint),
+ (-layout.labelAngleDeg * Math.PI) / 180,
+ )
+ : undefined
+ return (
+
+ {layout.labelPlacement === 'outside-end' && annotationUnitsPerPoint !== undefined ? (
+
+ ) : null}
+
+
+
+
+ )
+ })}
+
+ )
+}
+
+function rotateVector(vector: FloorplanPoint, radians: number): FloorplanPoint {
+ const cosine = Math.cos(radians)
+ const sine = Math.sin(radians)
+ return [vector[0] * cosine - vector[1] * sine, vector[0] * sine + vector[1] * cosine]
+}
+
+function DimensionLabel({
+ text,
+ y,
+ fontSize,
+ stroke,
+ renderMode,
+}: {
+ text: string
+ y: number
+ fontSize: number
+ stroke: string
+ renderMode: FloorplanDimensionRenderMode
+}) {
+ const width = Math.max(fontSize, text.length * fontSize * LABEL_CHARACTER_WIDTH_RATIO)
+ const plateWidth = width + fontSize * 0.5
+ const plateHeight = fontSize * 1.2
+ const plateY = y - fontSize * 0.82
+
+ return (
+ <>
+ {renderMode === 'pdf' ? (
+
+ ) : null}
+
+ {text}
+
+ >
+ )
+}
+
+function renderTerminator(
+ terminator: DimensionTerminator,
+ point: FloorplanPoint,
+ toward: FloorplanPoint,
+ layout: Pick,
+ lineProps: {
+ stroke: string
+ strokeLinecap: 'butt'
+ strokeWidth: number
+ vectorEffect: 'non-scaling-stroke'
+ },
+ tickStrokeWidth: number,
+ key?: string,
+): React.ReactElement | null {
+ const direction = normalized(point, toward)
+ if (!direction) return null
+ const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1])
+ if (terminator === 'dot') {
+ return (
+
+ )
+ }
+ if (terminator === 'filled-arrow' || terminator === 'open-arrow') {
+ const base = addScaled(point, direction, tickHalfLength * 1.7)
+ const normal: FloorplanPoint = [-direction[1], direction[0]]
+ const wing = tickHalfLength * 0.65
+ const left: FloorplanPoint = [base[0] + normal[0] * wing, base[1] + normal[1] * wing]
+ const right: FloorplanPoint = [base[0] - normal[0] * wing, base[1] - normal[1] * wing]
+ if (terminator === 'filled-arrow') {
+ return (
+
+ )
+ }
+ return (
+
+
+
+
+ )
+ }
+ const [tickX, tickY] = layout.tickHalfVector
+ return (
+
+ )
+}
+
+function normalized(start: FloorplanPoint, end: FloorplanPoint): FloorplanPoint | null {
+ const dx = end[0] - start[0]
+ const dy = end[1] - start[1]
+ const magnitude = Math.hypot(dx, dy)
+ return magnitude <= 1e-6 ? null : [dx / magnitude, dy / magnitude]
+}
+
+function pointKey(point: FloorplanPoint): string {
+ return `${point[0].toFixed(6)},${point[1].toFixed(6)}`
+}
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx
new file mode 100644
index 00000000..10672fd5
--- /dev/null
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx
@@ -0,0 +1,369 @@
+import { describe, expect, test } from 'bun:test'
+import type { FloorplanGeometry } from '@pascal-app/core'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
+import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
+
+describe('FloorplanGeometryRenderer static labels', () => {
+ test('renders a measurement value together with its geometry', () => {
+ const geometry = {
+ kind: 'group',
+ children: [
+ { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0, stroke: '#334155' },
+ {
+ kind: 'dimension-label',
+ appearance: 'outlined',
+ cx: 1,
+ cy: 0,
+ text: '2.00m',
+ angle: 0,
+ offsetPx: 14,
+ },
+ ],
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain(' {
+ const geometry = {
+ kind: 'dimension-label',
+ appearance: 'outlined',
+ cx: 1,
+ cy: 2,
+ text: 'A 6.0m²',
+ angle: Math.PI / 3,
+ screenUpright: true,
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('A 6.0m²')
+ expect(markup).toContain('rotate(-90)')
+ })
+
+ test('uses paper-point sizing when document scale is provided', () => {
+ const geometry = {
+ kind: 'dimension-label',
+ appearance: 'outlined',
+ cx: 1,
+ cy: 2,
+ text: '2.00m',
+ angle: 0,
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('font-size="0.08"')
+ })
+
+ test('uses live screen sizing without enabling document annotation styles', () => {
+ const geometry = {
+ kind: 'group',
+ children: [
+ {
+ kind: 'text',
+ x: 0,
+ y: 0,
+ text: 'LIVE TEXT',
+ fontSize: 0.16,
+ upright: true,
+ },
+ {
+ kind: 'dimension-label',
+ appearance: 'outlined',
+ cx: 1,
+ cy: 2,
+ text: '2.00m',
+ angle: 0,
+ offsetPx: 14,
+ },
+ ],
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('font-size="0.16"')
+ expect(markup).toContain('font-size="0.24"')
+ expect(markup).toContain('translate(0 -0.28)')
+ })
+
+ test('renders outlined measurement labels as PDF-safe dark text on a white plate', () => {
+ const geometry = {
+ kind: 'dimension-label',
+ appearance: 'outlined',
+ cx: 1,
+ cy: 2,
+ text: '2.00m',
+ angle: 0,
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-dimension-label-plate=""')
+ expect(markup).toContain('fill="#111827"')
+ expect(markup).not.toContain('paint-order="stroke"')
+ expect(markup).not.toContain('fill="#ffffff" font-family=')
+ })
+
+ test('caps PDF annotation linework without changing live stroke widths', () => {
+ const geometry = {
+ kind: 'line',
+ x1: 0,
+ y1: 0,
+ x2: 2,
+ y2: 0,
+ stroke: '#334155',
+ strokeWidth: 2,
+ vectorEffect: 'non-scaling-stroke',
+ } satisfies FloorplanGeometry
+
+ const liveMarkup = renderToStaticMarkup(
+ ,
+ )
+ const pdfMarkup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(liveMarkup).toContain('stroke-width="2"')
+ expect(pdfMarkup).toContain('stroke-width="0.5"')
+ })
+
+ test('removes unsupported paint-order outlines from generic PDF text', () => {
+ const geometry = {
+ kind: 'text',
+ x: 1,
+ y: 2,
+ text: '101',
+ fontSize: 0.15,
+ fill: '#ffffff',
+ stroke: '#334155',
+ strokeWidth: 0.04,
+ paintOrder: 'stroke',
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('fill="#334155"')
+ expect(markup).not.toContain('paint-order')
+ expect(markup).not.toContain('stroke=')
+ })
+
+ test('resolves generic annotation text from paper points only in document mode', () => {
+ const geometry = {
+ kind: 'text',
+ x: 2,
+ y: 3,
+ text: 'VERIFY DIMENSIONS',
+ fontSize: 0.16,
+ upright: true,
+ } satisfies FloorplanGeometry
+
+ const liveMarkup = renderToStaticMarkup(
+ ,
+ )
+ const documentMarkup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(liveMarkup).toContain('font-size="0.16"')
+ expect(documentMarkup).toContain('font-size="0.08"')
+ })
+
+ test('uses a room-label paper profile while preserving room label hierarchy', () => {
+ const geometry = {
+ kind: 'group',
+ children: [
+ {
+ kind: 'text',
+ x: 0,
+ y: 0,
+ text: 'KITCHEN',
+ fontSize: 0.2,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
+ upright: true,
+ },
+ {
+ kind: 'text',
+ x: 0,
+ y: 0.18,
+ text: '101',
+ fontSize: 0.16,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
+ upright: true,
+ },
+ {
+ kind: 'text',
+ x: 0,
+ y: 0.36,
+ text: 'CH: 2700',
+ fontSize: 0.11,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
+ upright: true,
+ },
+ ],
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('font-size="0.08"')
+ expect(markup).toContain('font-size="0.07"')
+ expect(markup).toContain('font-size="0.055"')
+ expect(markup).toMatch(/translate\(0 0\.08625/)
+ expect(markup).toMatch(/translate\(0 0\.18625/)
+ expect(markup).toMatch(/translate\(0 0\.27375/)
+ })
+
+ test('uses paper stroke profiles for leaders and opening marks in document mode', () => {
+ const geometry = {
+ kind: 'group',
+ metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
+ children: [
+ {
+ kind: 'polyline',
+ points: [
+ [0, 0],
+ [1, 0],
+ [1.4, 0],
+ ],
+ stroke: '#334155',
+ strokeWidth: 0.9,
+ vectorEffect: 'non-scaling-stroke',
+ },
+ {
+ kind: 'rect',
+ x: 2,
+ y: 2,
+ width: 0.42,
+ height: 0.32,
+ fill: '#ffffff',
+ stroke: '#334155',
+ strokeWidth: 0.02,
+ },
+ ],
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('stroke-width="0.9"')
+ expect(markup).toContain('vector-effect="non-scaling-stroke"')
+ expect(markup).toContain('height="0.14"')
+ expect(markup).toContain('rx="0.07"')
+ })
+
+ test('registers fixed mark pills as annotation obstacles', () => {
+ const geometry = {
+ kind: 'group',
+ children: [
+ { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
+ { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
+ { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
+ ],
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-annotation-obstacle=""')
+ })
+
+ test('registers semantic plan primitives as annotation obstacles', () => {
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
+ })
+
+ test('registers fixed annotation categories as obstacles', () => {
+ const roomLabel = {
+ kind: 'text',
+ x: 0,
+ y: 0,
+ text: 'KITCHEN',
+ fontSize: 0.18,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }),
+ } satisfies FloorplanGeometry
+ const stairArrow = {
+ kind: 'polyline',
+ points: [
+ [0, 0],
+ [0.5, 0.5],
+ ],
+ metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }),
+ } satisfies FloorplanGeometry
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toContain('data-floorplan-annotation-obstacle="bounds"')
+ expect(markup).toContain('data-floorplan-annotation-obstacle="outline"')
+ })
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx
index 316c78ee..70c95b64 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx
@@ -2,6 +2,29 @@
import { type FloorplanGeometry, loadAssetUrl } from '@pascal-app/core'
import { memo, useEffect, useState } from 'react'
+import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
+import {
+ floorplanAnnotationObstacleMode,
+ isFloorplanAnnotationObstacleGeometry,
+} from './floorplan-annotation-layout'
+import {
+ FloorplanDimensionRenderer,
+ FloorplanDimensionStringRenderer,
+} from './floorplan-dimension-renderer'
+import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
+
+const STATIC_LABEL_UNITS_PER_PIXEL = 0.01
+const DOCUMENT_DEFAULT_TEXT_SIZE_PT = 8
+const DOCUMENT_ROOM_NAME_TEXT_SIZE_PT = 8
+const DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT = 7
+const DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT = 5.5
+const DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT = 7
+const DOCUMENT_DEFAULT_STROKE_WIDTH_PT = 0.5
+const DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT = 0.75
+const DOCUMENT_MARK_HEIGHT_PT = 14
+const PDF_ANNOTATION_STROKE_WIDTH_PT = 0.5
+
+type FloorplanRenderMode = 'screen' | 'pdf'
/**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
@@ -24,16 +47,34 @@ import { memo, useEffect, useState } from 'react'
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
pointerEventsOverride,
+ sceneRotationDeg = 0,
+ annotationUnitsPerPoint,
+ screenUnitsPerPixel,
+ renderMode = 'screen',
}: {
geometry: FloorplanGeometry
pointerEventsOverride?: string
+ sceneRotationDeg?: number
+ annotationUnitsPerPoint?: number
+ screenUnitsPerPixel?: number
+ renderMode?: FloorplanRenderMode
}) {
- return renderNode(geometry, 0, pointerEventsOverride)
+ return renderNode(
+ geometry,
+ 0,
+ pointerEventsOverride,
+ sceneRotationDeg,
+ annotationUnitsPerPoint,
+ screenUnitsPerPixel,
+ renderMode,
+ )
})
function styleAttrs(
g: FloorplanGeometry & { kind: Exclude },
pointerEventsOverride?: string,
+ annotationUnitsPerPoint?: number,
+ renderMode: FloorplanRenderMode = 'screen',
) {
// Shared SVG attribute mapping for any styled primitive. Keeps the per-
// primitive switch arms terse and ensures new style fields land
@@ -54,37 +95,241 @@ function styleAttrs(
pointerEvents?: string
cursor?: string
}
+ const annotationMetadata = readFloorplanGeometryMetadata(g)
+ const documentStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
+ const vectorEffect = documentStyle.vectorEffect ?? s.vectorEffect
+ const resolvedStrokeWidth = documentStyle.strokeWidth ?? s.strokeWidth
+ const strokeWidth =
+ renderMode === 'pdf' &&
+ vectorEffect === 'non-scaling-stroke' &&
+ resolvedStrokeWidth !== undefined
+ ? Math.min(PDF_ANNOTATION_STROKE_WIDTH_PT, resolvedStrokeWidth)
+ : resolvedStrokeWidth
return {
- fill: s.fill ?? 'none',
+ 'data-floorplan-annotation-obstacle': floorplanAnnotationObstacleMode(g),
+ 'data-floorplan-annotation-role': annotationMetadata.annotationRole,
+ fill: documentStyle.fill ?? s.fill ?? 'none',
fillOpacity: s.fillOpacity,
- stroke: s.stroke,
- strokeWidth: s.strokeWidth,
+ stroke: documentStyle.stroke ?? s.stroke,
+ strokeWidth,
strokeDasharray: s.strokeDasharray,
strokeLinecap: s.strokeLinecap,
strokeLinejoin: s.strokeLinejoin,
strokeOpacity: s.strokeOpacity,
opacity: s.opacity,
- vectorEffect: s.vectorEffect,
+ vectorEffect,
pointerEvents: pointerEventsOverride ?? s.pointerEvents,
style: s.cursor ? { cursor: s.cursor } : undefined,
}
}
+export function resolveDocumentFloorplanAnnotationStyle(
+ geometry: FloorplanGeometry & { kind: Exclude },
+ annotationUnitsPerPoint?: number,
+): {
+ fill?: string
+ stroke?: string
+ strokeWidth?: number
+ vectorEffect?: 'non-scaling-stroke'
+} {
+ if (annotationUnitsPerPoint === undefined) return {}
+ const styled = geometry as FloorplanGeometry & {
+ stroke?: string
+ strokeWidth?: number
+ vectorEffect?: 'non-scaling-stroke'
+ }
+ if (!styled.stroke && geometry.kind !== 'text') return {}
+
+ if (geometry.kind === 'text') {
+ const sourceFontSize = Math.max(geometry.fontSize, 1e-6)
+ const sourceStrokeWidth = geometry.strokeWidth ?? 0
+ const outlineRatio = sourceStrokeWidth > 0 ? sourceStrokeWidth / sourceFontSize : 0
+ const fontSize = documentTextFontSize(geometry, annotationUnitsPerPoint)
+ return {
+ strokeWidth:
+ geometry.stroke && outlineRatio > 0
+ ? Math.max(
+ DOCUMENT_TEXT_OUTLINE_MIN_WIDTH_PT * annotationUnitsPerPoint,
+ fontSize * outlineRatio,
+ )
+ : undefined,
+ }
+ }
+
+ return {
+ strokeWidth: documentStrokeWidth(styled, annotationUnitsPerPoint),
+ vectorEffect: 'non-scaling-stroke',
+ }
+}
+
+function documentTextFontSize(
+ geometry: Extract,
+ annotationUnitsPerPoint: number,
+): number {
+ return documentTextSizePt(geometry) * annotationUnitsPerPoint
+}
+
+function documentTextSizePt(geometry: Extract): number {
+ switch (readFloorplanGeometryMetadata(geometry).annotationRole) {
+ case 'room-label':
+ if (geometry.fontSize >= 0.18) return DOCUMENT_ROOM_NAME_TEXT_SIZE_PT
+ if (geometry.fontSize >= 0.145) return DOCUMENT_ROOM_NUMBER_TEXT_SIZE_PT
+ return DOCUMENT_ROOM_DETAIL_TEXT_SIZE_PT
+ case 'column-center':
+ case 'stair-annotation':
+ return DOCUMENT_COLUMN_MARK_TEXT_SIZE_PT
+ default:
+ return DOCUMENT_DEFAULT_TEXT_SIZE_PT
+ }
+}
+
+function documentStrokeWidth(
+ geometry: { strokeWidth?: number },
+ annotationUnitsPerPoint: number,
+): number {
+ return Math.max(DOCUMENT_DEFAULT_STROKE_WIDTH_PT, Math.min(1.2, geometry.strokeWidth ?? 0.5))
+}
+
+export function documentRectGeometryAttrs(
+ geometry: Extract,
+ annotationUnitsPerPoint?: number,
+) {
+ if (annotationUnitsPerPoint === undefined || !isAnnotationMarkRect(geometry)) {
+ return {
+ x: geometry.x,
+ y: geometry.y,
+ width: geometry.width,
+ height: geometry.height,
+ rx: geometry.rx,
+ ry: geometry.ry,
+ }
+ }
+
+ const centerX = geometry.x + geometry.width / 2
+ const centerY = geometry.y + geometry.height / 2
+ const height = DOCUMENT_MARK_HEIGHT_PT * annotationUnitsPerPoint
+ const width = Math.max(height * 1.6, (geometry.width / Math.max(geometry.height, 1e-6)) * height)
+ return {
+ x: centerX - width / 2,
+ y: centerY - height / 2,
+ width,
+ height,
+ rx: height / 2,
+ ry: height / 2,
+ }
+}
+
+function isAnnotationMarkRect(geometry: Extract): boolean {
+ return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.height <= 0.5
+}
+
+export function documentCircleGeometryAttrs(
+ geometry: Extract,
+ annotationUnitsPerPoint?: number,
+) {
+ if (annotationUnitsPerPoint === undefined || !isAnnotationMarkCircle(geometry)) {
+ return { r: geometry.r }
+ }
+ return { r: Math.max(geometry.r, (DOCUMENT_MARK_HEIGHT_PT / 2) * annotationUnitsPerPoint) }
+}
+
+function isAnnotationMarkCircle(geometry: Extract): boolean {
+ return geometry.fill === '#ffffff' && !!geometry.stroke && geometry.r <= 0.25
+}
+
+export function resolveDocumentAnnotationGroupChildren(
+ children: FloorplanGeometry[],
+ annotationUnitsPerPoint?: number,
+): FloorplanGeometry[] {
+ if (annotationUnitsPerPoint === undefined) return children
+
+ const next = [...children]
+ let start = 0
+ while (start < next.length) {
+ const first = next[start]
+ if (!isDocumentTextLine(first)) {
+ start++
+ continue
+ }
+
+ let end = start + 1
+ while (end < next.length && isSameDocumentTextRun(first, next[end])) end++
+ if (end - start > 1) {
+ const run = next.slice(start, end) as Extract[]
+ const adjusted = positionDocumentTextRun(run, annotationUnitsPerPoint)
+ for (let index = 0; index < adjusted.length; index++) {
+ const line = adjusted[index]
+ if (line) next[start + index] = line
+ }
+ }
+ start = end
+ }
+
+ return next
+}
+
+function isDocumentTextLine(
+ geometry: FloorplanGeometry | undefined,
+): geometry is Extract {
+ return geometry?.kind === 'text' && geometry.upright === true
+}
+
+function isSameDocumentTextRun(
+ first: Extract,
+ candidate: FloorplanGeometry | undefined,
+): candidate is Extract {
+ return (
+ isDocumentTextLine(candidate) &&
+ Math.abs(candidate.x - first.x) < 1e-6 &&
+ candidate.textAnchor === first.textAnchor &&
+ readFloorplanGeometryMetadata(candidate).annotationRole ===
+ readFloorplanGeometryMetadata(first).annotationRole
+ )
+}
+
+function positionDocumentTextRun(
+ run: Extract[],
+ annotationUnitsPerPoint: number,
+): Extract[] {
+ const centerY = run.reduce((sum, line) => sum + line.y, 0) / run.length
+ const steps = run.slice(0, -1).map((line, index) => {
+ const next = run[index + 1] ?? line
+ const largerFontPt = Math.max(documentTextSizePt(line), documentTextSizePt(next))
+ return largerFontPt * 1.25 * annotationUnitsPerPoint
+ })
+ const totalHeight = steps.reduce((sum, step) => sum + step, 0)
+ let y = centerY - totalHeight / 2
+ return run.map((line, index) => {
+ if (index > 0) y += steps[index - 1] ?? 0
+ return { ...line, y }
+ })
+}
+
function renderNode(
g: FloorplanGeometry,
keyHint: number,
pointerEventsOverride?: string,
+ sceneRotationDeg = 0,
+ annotationUnitsPerPoint?: number,
+ screenUnitsPerPixel?: number,
+ renderMode: FloorplanRenderMode = 'screen',
): React.ReactElement | null {
switch (g.kind) {
case 'path':
- return
+ return (
+
+ )
case 'polygon':
return (
)
@@ -93,34 +338,38 @@ function renderNode(
)
- case 'rect':
+ case 'rect': {
+ const attrs = documentRectGeometryAttrs(g, annotationUnitsPerPoint)
return (
)
+ }
- case 'circle':
+ case 'circle': {
+ const attrs = documentCircleGeometryAttrs(g, annotationUnitsPerPoint)
return (
)
+ }
case 'line':
return (
@@ -130,25 +379,65 @@ function renderNode(
x2={g.x2}
y1={g.y1}
y2={g.y2}
- {...styleAttrs(g, pointerEventsOverride)}
+ {...styleAttrs(g, pointerEventsOverride, annotationUnitsPerPoint, renderMode)}
/>
)
- case 'text':
+ case 'text': {
+ const fontSize =
+ annotationUnitsPerPoint !== undefined
+ ? documentTextFontSize(g, annotationUnitsPerPoint)
+ : g.fontSize
+ const textStyle = resolveDocumentFloorplanAnnotationStyle(g, annotationUnitsPerPoint)
+ const pdfOutlinedText = renderMode === 'pdf' && g.paintOrder === 'stroke' && !!g.stroke
+ const fill =
+ pdfOutlinedText && g.fill?.toLocaleLowerCase() === '#ffffff'
+ ? g.stroke
+ : (g.fill ?? '#171717')
+ if (g.upright) {
+ return (
+
+
+ {g.text}
+
+
+ )
+ }
return (
)
+ }
+
+ case 'dimension':
+ return (
+
+ )
+
+ case 'dimension-string':
+ return (
+
+ )
+
+ case 'dimension-label': {
+ const unitsPerPixel =
+ annotationUnitsPerPoint ?? screenUnitsPerPixel ?? STATIC_LABEL_UNITS_PER_PIXEL
+ const documentMode = annotationUnitsPerPoint !== undefined
+ const outlined = g.appearance === 'outlined'
+ const pdfOutlined = outlined && renderMode === 'pdf'
+ const padX = unitsPerPixel * 6
+ const padY = unitsPerPixel * 3
+ const fontSize = unitsPerPixel * (documentMode ? 8 : outlined ? 12 : 10)
+ const textWidth = g.text.length * unitsPerPixel * 6.2
+ const plateW = textWidth + padX * 2
+ const plateH = fontSize + padY * 2
+ const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
+ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * unitsPerPixel})`
+
+ return (
+
+ {outlined && !pdfOutlined ? null : (
+
+ )}
+
+ {g.text}
+
+
+ )
+ }
case 'image':
return (
@@ -174,15 +550,32 @@ function renderNode(
case 'group': {
const transform = formatTransform(g.transform)
+ const children = resolveDocumentAnnotationGroupChildren(g.children, annotationUnitsPerPoint)
return (
-
- {g.children.map((child, i) => renderNode(child, i, pointerEventsOverride))}
+
+ {children.map((child, i) =>
+ renderNode(
+ child,
+ i,
+ pointerEventsOverride,
+ sceneRotationDeg,
+ annotationUnitsPerPoint,
+ screenUnitsPerPixel,
+ renderMode,
+ ),
+ )}
)
}
- // The interactive primitives (hatch / hit-line / endpoint-handle /
- // dimension-label) need the SVG context + theme palette + units-per-
+ // The remaining interactive primitives (hatch / hit-line / endpoint-handle)
+ // need the SVG context + theme palette + units-per-
// pixel that only the registry layer has access to. They're rendered
// by `floorplan-registry-layer.tsx`'s interactive walker instead. If
// a caller routes one of these through this pure renderer it
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts
index e2e208a8..c8ef7df1 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts
@@ -3,15 +3,25 @@ import type {
AnyNode,
AnyNodeId,
FloorplanAffordanceSession,
+ FloorplanGeometry,
LiveNodeOverrides,
} from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
import { z } from 'zod'
+import {
+ FLOORPLAN_NODE_EXTENSION_KEY,
+ floorplanGeometryMetadata,
+} from '../../../lib/floorplan/floorplan-extension'
import {
cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes,
+ collectFloorplanLinkedLevelNodes,
computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance,
+ InteractiveGeometry,
+ splitFloorplanOverlay,
subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer'
@@ -209,6 +219,76 @@ describe('floorplan vertex double-click routing', () => {
})
})
+describe('floorplan annotation overlay routing', () => {
+ test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
+ const noop = () => {}
+ const renderAt180Degrees = (geometry: FloorplanGeometry) =>
+ renderToStaticMarkup(
+ createElement(
+ 'svg',
+ null,
+ createElement(InteractiveGeometry, {
+ activeDragId: null,
+ activeRotateNodeId: null,
+ geometry,
+ hatchPatternId: undefined,
+ hoveredHandleId: null,
+ isMarqueeSelectionActive: false,
+ nodeId: 'wall_test' as AnyNodeId,
+ onHandleDoubleClick: noop,
+ onHandleHoverChange: noop,
+ onHandlePointerDown: noop,
+ onMoveHandlePointerDown: noop,
+ palette: undefined,
+ sceneRotationDeg: 180,
+ unitsPerPixel: 0.01,
+ }),
+ ),
+ )
+ const dimensionString = (
+ end: readonly [number, number],
+ offsetNormal: readonly [number, number],
+ ): FloorplanGeometry => ({
+ kind: 'dimension-string',
+ segments: [{ start: [0, 0], end, text: '2m' }],
+ offsetNormal,
+ offsetDistance: 0.55,
+ extensionOvershoot: 0.12,
+ textPosition: 'above',
+ })
+
+ expect(renderAt180Degrees(dimensionString([2, 0], [0, 1]))).toContain('rotate(-180)')
+ expect(renderAt180Degrees(dimensionString([0, 2], [1, 0]))).toContain('rotate(-90)')
+ })
+
+ test('keeps a fixed mark pill together in the overlay pass', () => {
+ const mark = {
+ kind: 'group',
+ metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }),
+ children: [
+ { kind: 'line', x1: 0, y1: 0, x2: 0, y2: 0.4 },
+ { kind: 'rect', x: -0.2, y: 0.4, width: 0.4, height: 0.32 },
+ { kind: 'text', x: 0, y: 0.56, text: '107', fontSize: 0.15, upright: true },
+ ],
+ } satisfies FloorplanGeometry
+
+ expect(splitFloorplanOverlay(mark)).toEqual({ base: null, overlay: mark })
+ })
+
+ test('keeps fixed annotation symbols in the overlay pass for collision layout', () => {
+ const columnCenter = {
+ kind: 'line',
+ x1: 0,
+ y1: 0,
+ x2: 1,
+ y2: 0,
+ metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }),
+ } satisfies FloorplanGeometry
+
+ expect(splitFloorplanOverlay(columnCenter)).toEqual({ base: null, overlay: columnCenter })
+ })
+})
+
describe('computeAffectedSiblingIds', () => {
beforeEach(() => {
nodeRegistry._reset()
@@ -306,3 +386,45 @@ describe('collectFloorplanDependencyNodes', () => {
])
})
})
+
+describe('collectFloorplanLinkedLevelNodes', () => {
+ test('projects a node onto a linked destination level with its real children', () => {
+ nodeRegistry._reset()
+ registerNode({
+ kind: 'linked-floorplan-test',
+ schemaVersion: 1,
+ schema: z.object({ type: z.literal('linked-floorplan-test') }) as never,
+ category: 'structure',
+ defaults: () => ({}) as never,
+ floorplan: () => null,
+ extensions: {
+ [FLOORPLAN_NODE_EXTENSION_KEY]: {
+ linkedLevelIds: () => ['level_upper' as AnyNodeId],
+ },
+ },
+ } as unknown as AnyNodeDefinition)
+ const child = {
+ id: 'linked_child',
+ type: 'linked-child',
+ parentId: 'linked_parent',
+ } as unknown as AnyNode
+ const parent = {
+ id: 'linked_parent',
+ type: 'linked-floorplan-test',
+ parentId: 'level_lower',
+ children: [child.id],
+ } as unknown as AnyNode
+ const nodes = { [parent.id]: parent, [child.id]: child }
+
+ expect(collectFloorplanLinkedLevelNodes(nodes, 'level_upper' as AnyNodeId)).toEqual([
+ { id: parent.id, node: parent, children: [child] },
+ ])
+ expect(
+ collectFloorplanLinkedLevelNodes(
+ nodes,
+ 'level_upper' as AnyNodeId,
+ new Set([parent.id as AnyNodeId]),
+ ),
+ ).toEqual([])
+ })
+})
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
index a94b7b48..3959bb0f 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
@@ -33,6 +33,7 @@ import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -47,6 +48,16 @@ import {
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
+import {
+ type FloorplanAnnotationVisibility,
+ filterFloorplanAnnotationGeometry,
+} from '../../../lib/floorplan/annotation-visibility'
+import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
+import {
+ createFloorplanContextExtensions,
+ type FloorplanWallDimensionReference,
+ getFloorplanNodeExtension,
+} from '../../../lib/floorplan/floorplan-extension'
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
import {
type ActiveInteractionScope,
@@ -60,7 +71,10 @@ import {
import { sfxEmitter } from '../../../lib/sfx-bus'
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
+import useDrawingView from '../../../store/use-drawing-view'
import useEditor from '../../../store/use-editor'
+import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
+import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
import useInteractionScope, {
useEndpointReshape,
useMovingNode,
@@ -74,6 +88,14 @@ import {
startFloorplanGroupRotate,
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
+import {
+ floorplanAnnotationObstacleMode,
+ isFloorplanAnnotationObstacleGeometry,
+ observeSvgAnnotationLayoutChanges,
+ resolveSvgAnnotationCollisions,
+ svgAnnotationLabelId,
+} from './floorplan-annotation-layout'
+import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
@@ -276,6 +298,8 @@ type NodeDeps = {
node: AnyNode
live: LiveTransform | undefined
unit: 'metric' | 'imperial'
+ metricNotation: 'meters' | 'millimeters'
+ wallDimensionReference: FloorplanWallDimensionReference
selected: boolean
highlighted: boolean
hovered: boolean
@@ -343,7 +367,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit)
- const showMeasurements = useViewer((s) => s.showMeasurements)
+ const metricNotation = useViewer((s) => s.metricNotation)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
@@ -423,6 +447,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// selectors freeze to `undefined` so drag publishes do not re-render the
// hidden floor-plan tree.
const floorplanVisible = useEditor((s) => s.viewMode !== '3d')
+ const drawingType = useDrawingView((s) => s.drawingType)
+ const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
+ const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
// Elevator builders read runtime state imperatively, so entries include this
// rare-changing ref in their cache deps.
const interactiveElevators = useInteractive((s) => s.elevators)
@@ -838,15 +865,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
if (!isNodeKindEnabled(node.type, installedPlugins)) return
- const def = nodeRegistry.get(node.type)
+ const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType)
+ if (!drawingNode) return
+ const def = nodeRegistry.get(drawingNode.type)
if (!def?.floorplan) return
- if (node.type === 'measurement' && !showMeasurements) return
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
- const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
+ const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs }
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
out.push(descriptor)
}
@@ -863,6 +891,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
visit(levelId as AnyNodeId)
+ const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
+ if (activeLevelNode) {
+ const collectedIds = new Set(out.map((entry) => entry.id))
+ for (const linked of collectFloorplanLinkedLevelNodes(
+ nodes,
+ levelId as AnyNodeId,
+ collectedIds,
+ )) {
+ pushEntry(linked.id, linked.node, {
+ children: linked.children,
+ siblings: [],
+ parent: activeLevelNode,
+ })
+ }
+ }
+
// Building-scoped kinds (`def.floorplanScope === 'building'`) live
// as siblings of the level, not under it — the `visit(levelId)` DFS
// above doesn't reach them. Walk every node of those kinds whose
@@ -871,7 +915,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// builders that gate on the current floor — e.g. elevator service
// range — keep working). Pure registry-driven dispatch: no kind
// name appears in this file.
- const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined
const activeBuildingId = activeLevelNode
? resolveBuildingForLevel(levelId as AnyNodeId, nodes)
: null
@@ -907,7 +950,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
}
return { entries: out, levelNodeIdsByType }
- }, [installedPlugins, levelId, nodes, showMeasurements])
+ }, [drawingType, installedPlugins, levelId, nodes])
// ── Generic 2D affordance dispatch ─────────────────────────────────
//
@@ -1290,6 +1333,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
))}
+
{/* Dashed group bbox — shows what a group drag carries along while a
multi-selection exists, rides the live delta mid-drag, and doubles
as the group's whole-area drag handle. */}
@@ -1403,9 +1453,136 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
)
})
+function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
+ const markerRef = useRef(null)
+ const [layoutEpoch, setLayoutEpoch] = useState(0)
+ const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides)
+ const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
+ const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
+ const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
+ useLayoutEffect(() => {
+ if (!active) return
+ const svg = markerRef.current?.ownerSVGElement
+ if (!svg) return
+ return observeSvgAnnotationLayoutChanges(svg, () => {
+ setLayoutEpoch((epoch) => epoch + 1)
+ })
+ }, [active])
+ useLayoutEffect(() => {
+ // The epoch is only a trigger; collision inputs are measured from the live SVG below.
+ void layoutEpoch
+ if (!active) {
+ resetPreflightIssues()
+ return
+ }
+ const svg = markerRef.current?.ownerSVGElement
+ if (!svg) return
+ const preflightIssues = resolveSvgAnnotationCollisions(svg, {
+ layoutOverrides: annotationLayoutOverrides,
+ })
+ setPreflightIssues(preflightIssues)
+
+ const labels = Array.from(
+ svg.querySelectorAll('[data-floorplan-annotation-label]'),
+ )
+ const cleanup: Array<() => void> = []
+ for (const [index, label] of labels.entries()) {
+ const id = svgAnnotationLabelId(label, index)
+ label.dataset.floorplanAnnotationId = id
+ label.style.pointerEvents = 'all'
+ label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) return
+ event.preventDefault()
+ event.stopPropagation()
+ label.style.cursor = 'grabbing'
+ const matrix = label.getScreenCTM()
+ if (!matrix) return
+ const start = { x: event.clientX, y: event.clientY }
+ const existing = annotationLayoutOverrides[id] ?? {
+ ...readFloorplanAnnotationLayoutOffset(label),
+ pinned: true,
+ }
+ let latest = existing
+ let moved = false
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ moved = true
+ const local = screenVectorToFloorplanAnnotationLocal(
+ matrix,
+ moveEvent.clientX - start.x,
+ moveEvent.clientY - start.y,
+ )
+ latest = {
+ dx: existing.dx + local.x,
+ dy: existing.dy + local.y,
+ pinned: true,
+ }
+ const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? ''
+ label.setAttribute(
+ 'transform',
+ `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(),
+ )
+ }
+ const onPointerUp = () => {
+ label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
+ window.removeEventListener('pointermove', onPointerMove)
+ window.removeEventListener('pointerup', onPointerUp)
+ if (moved) setAnnotationLayoutOverride(id, latest)
+ }
+ window.addEventListener('pointermove', onPointerMove)
+ window.addEventListener('pointerup', onPointerUp)
+ }
+ const onDoubleClick = (event: MouseEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setAnnotationLayoutOverride(id, null)
+ }
+ label.addEventListener('pointerdown', onPointerDown)
+ label.addEventListener('dblclick', onDoubleClick)
+ cleanup.push(() => {
+ label.removeEventListener('pointerdown', onPointerDown)
+ label.removeEventListener('dblclick', onDoubleClick)
+ label.style.pointerEvents = ''
+ label.style.cursor = ''
+ })
+ }
+ return () => {
+ for (const fn of cleanup) fn()
+ }
+ }, [
+ active,
+ annotationLayoutOverrides,
+ layoutEpoch,
+ resetPreflightIssues,
+ setAnnotationLayoutOverride,
+ setPreflightIssues,
+ ])
+ return
+}
+
+function screenVectorToFloorplanAnnotationLocal(matrix: DOMMatrix, dx: number, dy: number) {
+ const determinant = matrix.a * matrix.d - matrix.b * matrix.c
+ if (Math.abs(determinant) < 1e-9) return { x: 0, y: 0 }
+ return {
+ x: (matrix.d * dx - matrix.c * dy) / determinant,
+ y: (-matrix.b * dx + matrix.a * dy) / determinant,
+ }
+}
+
+function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
+ const dx = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0)
+ const dy = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0)
+ return {
+ dx: Number.isFinite(dx) ? dx : 0,
+ dy: Number.isFinite(dy) ? dy : 0,
+ }
+}
+
type FloorplanRegistryEntryProps = {
activeDragId: string | null
activeRotateNodeId: AnyNodeId | null
+ annotationVisibility: FloorplanAnnotationVisibility
ctxOverrides: FloorplanContextOverrides | undefined
floorplanVisible: boolean
geometryCacheRef: { current: Map }
@@ -1453,6 +1630,8 @@ type FloorplanRegistryEntryProps = {
setMovingNodeOrigin: ReturnType['setMovingNodeOrigin']
siblingEpoch: number
unit: 'metric' | 'imperial'
+ metricNotation: 'meters' | 'millimeters'
+ wallDimensionReference: FloorplanWallDimensionReference
unitsPerPixel: number
visibilityRootId: AnyNodeId | undefined
}
@@ -1460,6 +1639,7 @@ type FloorplanRegistryEntryProps = {
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
activeDragId,
activeRotateNodeId,
+ annotationVisibility,
ctxOverrides,
floorplanVisible,
geometryCacheRef,
@@ -1493,6 +1673,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
setMovingNodeOrigin,
siblingEpoch,
unit,
+ metricNotation,
+ wallDimensionReference,
unitsPerPixel,
visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null {
@@ -1599,16 +1781,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
selected,
siblingEpoch,
unit,
+ metricNotation,
+ wallDimensionReference,
visibilityRootId,
})
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
+ const visibleGeometry = rawGeometry
+ ? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
+ : null
// Multi-selection shows highlight only: strip this member's edit handles /
// dimension chrome (all of which live in the overlay pass) while keeping
// its highlighted body geometry.
const geometry =
- rawGeometry && suppressHandles && pass === 'overlay'
- ? stripHandleChrome(rawGeometry)
- : rawGeometry
+ visibleGeometry && suppressHandles && pass === 'overlay'
+ ? stripHandleChrome(visibleGeometry)
+ : visibleGeometry
if (!geometry) return null
const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop
@@ -1664,6 +1851,8 @@ type BuildFloorplanEntryGeometryArgs = {
selected: boolean
siblingEpoch: number
unit: 'metric' | 'imperial'
+ metricNotation: 'meters' | 'millimeters'
+ wallDimensionReference: FloorplanWallDimensionReference
visibilityRootId: AnyNodeId | undefined
}
@@ -1707,6 +1896,8 @@ function buildFloorplanEntryGeometry({
selected,
siblingEpoch,
unit,
+ metricNotation,
+ wallDimensionReference,
visibilityRootId,
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
const def = nodeRegistry.get(node.type)
@@ -1731,6 +1922,8 @@ function buildFloorplanEntryGeometry({
node,
live,
unit,
+ metricNotation,
+ wallDimensionReference,
selected,
highlighted,
hovered,
@@ -1808,6 +2001,8 @@ function buildFloorplanEntryGeometry({
const viewState = {
selected,
unit,
+ metricNotation,
+ wallDimensionReference,
highlighted,
hovered,
moving,
@@ -1826,6 +2021,11 @@ function buildFloorplanEntryGeometry({
siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent,
levelData,
+ extensions: createFloorplanContextExtensions({
+ metricNotation,
+ purpose: 'edit',
+ wallDimensionReference,
+ }),
viewState: palette
? {
selected,
@@ -1925,7 +2125,7 @@ type InteractiveGeometryProps = {
onMoveHandlePointerDown: (event: ReactPointerEvent) => void
}
-const InteractiveGeometry = memo(function InteractiveGeometry({
+export const InteractiveGeometry = memo(function InteractiveGeometry({
geometry,
unitsPerPixel,
palette,
@@ -1948,7 +2148,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
case 'group': {
const transform = formatGroupTransform(g.transform)
return (
-
+
{g.children.map((child, i) => renderInteractive(child, i))}
)
@@ -2499,11 +2706,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
+ const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`
return (
{outlined ? null : (
>
- const stroke = g.stroke ?? palette.measurementStroke
- // Offset endpoints along the outward normal — this is where the
- // dimension line sits, parallel to the edge.
- const ox = g.offsetNormal[0] * g.offsetDistance
- const oy = g.offsetNormal[1] * g.offsetDistance
- const dStart: [number, number] = [g.start[0] + ox, g.start[1] + oy]
- const dEnd: [number, number] = [g.end[0] + ox, g.end[1] + oy]
-
- // Extension line endpoints — extend past the dimension line by
- // `extensionOvershoot` so the tip clears the dimension stroke.
- const eOvershoot = g.extensionOvershoot
- const eOx = g.offsetNormal[0] * (g.offsetDistance + eOvershoot)
- const eOy = g.offsetNormal[1] * (g.offsetDistance + eOvershoot)
- const eStartTip: [number, number] = [g.start[0] + eOx, g.start[1] + eOy]
- const eEndTip: [number, number] = [g.end[0] + eOx, g.end[1] + eOy]
-
- const dx = dEnd[0] - dStart[0]
- const dy = dEnd[1] - dStart[1]
- const length = Math.hypot(dx, dy)
- if (length < 1e-6) return <>>
- const dirX = dx / length
- const dirY = dy / length
-
- // Plan-unit constants matching the legacy `floorplan-
- // measurements-layer.tsx`. `strokeWidth` is intentionally a
- // raw value (not multiplied by `unitsPerPixel`) because every
- // stroke here uses `vectorEffect: non-scaling-stroke` — the
- // browser interprets it as screen-pixel-stable. Multiplying
- // by `unitsPerPixel` would shrink the strokes by ~100× and
- // make them invisible. Tick length, dash pattern, font size,
- // and the label gap stay in plan units (they're geometry,
- // not stroke width).
- const tickHalf = 0.09 // FLOORPLAN_MEASUREMENT_END_TICK / 2 = 0.18 / 2
- const perpX = -dirY * tickHalf
- const perpY = dirX * tickHalf
-
- const fontSize = 0.15 // FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE
- const labelGap = 0.5 // plan units — gap in the dimension line for the label
- const gapHalf = Math.min(labelGap / 2, length / 2 - 0.04)
-
- const midX = (dStart[0] + dEnd[0]) / 2
- const midY = (dStart[1] + dEnd[1]) / 2
- const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
- const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
-
- // Keep the label parallel to the dimension line, but decide the
- // 180° flip from the on-SCREEN angle, not the local one. The parent
- // `` is rotated by `sceneRotationDeg` (default 90° in the floor
- // plan), so a label kept upright in local coords still renders
- // upside down for half of the wall orientations. Same fix as the
- // `dimension-label` case above.
- let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
- let screenDeg = labelDeg + sceneRotationDeg
- screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
- if (screenDeg > 90) labelDeg -= 180
- else if (screenDeg <= -90) labelDeg += 180
-
return (
-
- {/* Extension lines (dashed). */}
-
-
- {/* Dimension line: two halves with the label in between. */}
-
-
- {/* End ticks. */}
-
-
- {/* Rotated label centered in the gap. */}
-
- {g.text}
-
-
+
)
}
case 'text': {
@@ -2747,7 +2824,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
// horizontally on screen even when the floor-plan view is
// rotated (default `sceneRotationDeg` is 90°).
return (
-
+
)
}
@@ -2853,6 +2935,9 @@ export function buildContext(
viewState: {
selected: boolean
unit: 'metric' | 'imperial'
+ metricNotation?: 'meters' | 'millimeters'
+ purpose?: 'edit' | 'document'
+ wallDimensionReference?: FloorplanWallDimensionReference
highlighted: boolean
hovered: boolean
moving: boolean
@@ -2892,6 +2977,11 @@ export function buildContext(
siblings,
parent,
levelData,
+ extensions: createFloorplanContextExtensions({
+ metricNotation: viewState.metricNotation ?? 'meters',
+ purpose: viewState.purpose ?? 'edit',
+ wallDimensionReference: viewState.wallDimensionReference,
+ }),
viewState: viewState.palette
? {
selected: viewState.selected,
@@ -2905,6 +2995,29 @@ export function buildContext(
}
}
+export function collectFloorplanLinkedLevelNodes(
+ nodes: Record,
+ levelId: AnyNodeId,
+ excludedIds: ReadonlySet = new Set(),
+): Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> {
+ const linked: Array<{ id: AnyNodeId; node: AnyNode; children: AnyNode[] }> = []
+ for (const [rawId, node] of Object.entries(nodes)) {
+ if (!node) continue
+ const definition = nodeRegistry.get(node.type)
+ const linkedLevelIds = getFloorplanNodeExtension(definition)?.linkedLevelIds
+ if (!definition?.floorplan || !linkedLevelIds) continue
+ const id = rawId as AnyNodeId
+ if (excludedIds.has(id)) continue
+ if (!linkedLevelIds(node).includes(levelId)) continue
+ const childIds = (node as { children?: AnyNodeId[] }).children
+ const children = Array.isArray(childIds)
+ ? childIds.map((childId) => nodes[childId]).filter((child): child is AnyNode => !!child)
+ : []
+ linked.push({ id, node, children })
+ }
+ return linked
+}
+
/**
* Stable id for a handle on a node, derived from the node id + opaque
* payload. Used to track hover / active visual state when multiple
@@ -2951,6 +3064,7 @@ const OVERLAY_KINDS = new Set([
'move-arrow',
'rotate-arrow',
'dimension',
+ 'dimension-string',
'dimension-label',
'equal-spacing-badge',
])
@@ -2969,6 +3083,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
base: FloorplanGeometry | null
overlay: FloorplanGeometry | null
} {
+ if (isFloorplanAnnotationObstacleGeometry(g)) {
+ return { base: null, overlay: g }
+ }
if (OVERLAY_KINDS.has(g.kind)) {
return { base: null, overlay: g }
}
@@ -3130,6 +3247,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'node',
'live',
'unit',
+ 'metricNotation',
+ 'wallDimensionReference',
'selected',
'highlighted',
'hovered',
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx
index deb71ffe..b1cef1bb 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx
@@ -95,6 +95,10 @@ function getFloorplanStairStepCount(stair: StairNode, minimum: number) {
return Math.max(minimum, Math.round(stair.stepCount ?? 10))
}
+function getFloorplanStairBreakStep(stepCount: number) {
+ return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * 0.68))
+}
+
function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) {
if (stair.stairType !== 'spiral' || (stair.topLandingMode ?? 'none') !== 'integrated') {
return 0
@@ -244,14 +248,15 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
- const dashedFromIndex = Math.floor(stepCount * 0.68)
+ if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
+ return null
+ }
return (
= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
@@ -330,6 +335,9 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
+ if (index >= getFloorplanStairBreakStep(stepCount) && index !== stepCount) {
+ return null
+ }
return (
- {treadBars.map((treadBar, treadIndex) => (
-
- ))}
+ {treadBars
+ .slice(0, Math.max(0, getFloorplanStairBreakStep(segment.stepCount) - 1))
+ .map((treadBar, treadIndex) => (
+
+ ))}
))}
{arrow?.polyline && arrow.polyline.length >= 2 ? (
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 9baf8848..b3d8b128 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -118,6 +118,7 @@ import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOver
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
+import { FloorplanRegisteredToolLayer } from '../editor-2d/floorplan-registered-tool-layer'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import {
@@ -2416,6 +2417,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
visible: true,
metadata: {},
children: [],
+ assemblyLayers: [],
start,
end,
frontSide: 'unknown',
@@ -2719,9 +2721,10 @@ function formatMeasurement(
value: number,
unit: 'metric' | 'imperial',
metersPerUnit: number | null = null,
+ metricNotation: 'meters' | 'millimeters' = 'meters',
) {
const measuredValue = metersPerUnit && metersPerUnit > 0 ? value * metersPerUnit : value
- return formatLinearMeasurement(measuredValue, unit)
+ return formatLinearMeasurement(measuredValue, unit, metricNotation)
}
function formatNumber(value: number, fractionDigits = 2) {
@@ -3614,6 +3617,7 @@ function FloorplanReferenceScaleDraftLine({
unitsPerPixel: number
}) {
const cursor = useFloorplanDraftPreview((s) => s.cursorPoint)
+ const metricNotation = useViewer((state) => state.metricNotation)
if (!cursor) {
return null
}
@@ -3625,6 +3629,8 @@ function FloorplanReferenceScaleDraftLine({
label={`Ref ${formatMeasurement(
Math.hypot(cursor[0] - start[0], cursor[1] - start[1]),
unit,
+ null,
+ metricNotation,
)}`}
palette={palette}
start={start}
@@ -4006,6 +4012,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
unit: 'metric' | 'imperial'
unitsPerPixel: number
}) {
+ const metricNotation = useViewer((state) => state.metricNotation)
if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
return null
}
@@ -4054,7 +4061,7 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
labelAngleDeg += 180
}
- const label = formatMeasurement(length, unit)
+ const label = formatMeasurement(length, unit, null, metricNotation)
const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
const height = fontSize + padY * 2
@@ -5029,6 +5036,7 @@ function FloorplanLinearDraftLayer({
unitsPerPixel: number
sceneRotationDeg: number
}) {
+ const metricNotation = useViewer((state) => state.metricNotation)
const wallDraftEnd = useFloorplanDraftPreview((s) => s.wallDraftEnd)
const fenceDraftEnd = useFloorplanDraftPreview((s) => s.fenceDraftEnd)
const roofDraftEnd = useFloorplanDraftPreview((s) => s.roofDraftEnd)
@@ -5147,7 +5155,7 @@ function FloorplanLinearDraftLayer({
}
return {
- lengthLabel: formatMeasurement(length, unit),
+ lengthLabel: formatMeasurement(length, unit, null, metricNotation),
midpoint: [
(wallDraftStart[0] + wallDraftEnd[0]) / 2,
(wallDraftStart[1] + wallDraftEnd[1]) / 2,
@@ -5155,7 +5163,7 @@ function FloorplanLinearDraftLayer({
direction: [dx / length, dy / length] as WallPlanPoint,
angleLabels,
}
- }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls])
+ }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls])
return (
<>
@@ -5244,6 +5252,7 @@ export function FloorplanPanel({
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
const unit = useViewer((state) => state.unit)
+ const metricNotation = useViewer((state) => state.metricNotation)
const showGrid = useViewer((state) => state.showGrid)
const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides)
@@ -11169,7 +11178,12 @@ export function FloorplanPanel({
Drawn line
- {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)}
+ {formatMeasurement(
+ pendingReferenceScale.measuredLengthUnits,
+ unit,
+ null,
+ metricNotation,
+ )}
@@ -11432,6 +11446,7 @@ export function FloorplanPanel({
+
{floorplanSceneSlot}
{/* Cursor-driven placement ghost for movingNode when the
diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx
index 793f9d2b..11d608d2 100644
--- a/packages/editor/src/components/editor/measurement-pill.tsx
+++ b/packages/editor/src/components/editor/measurement-pill.tsx
@@ -4,7 +4,11 @@ import { type ForwardedRef, Fragment, forwardRef } from 'react'
// Canonical in-world dimension formatter — metric metres or imperial
// feet/inches. Shared by every measurement readout so they read the same.
-export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string {
+export function formatMeasurement(
+ value: number,
+ unit: 'metric' | 'imperial',
+ metricNotation: 'meters' | 'millimeters' = 'meters',
+): string {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
@@ -12,6 +16,7 @@ export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): s
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
+ if (metricNotation === 'millimeters') return `${Math.round(value * 1000)}mm`
return `${Number.parseFloat(value.toFixed(2))}m`
}
diff --git a/packages/editor/src/components/editor/quick-measurement-card.tsx b/packages/editor/src/components/editor/quick-measurement-card.tsx
index 127d3093..420887e5 100644
--- a/packages/editor/src/components/editor/quick-measurement-card.tsx
+++ b/packages/editor/src/components/editor/quick-measurement-card.tsx
@@ -5,19 +5,25 @@ import { Crosshair, MapPin } from 'lucide-react'
import { memo } from 'react'
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
-function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
+function formatMetric(
+ metric: QuickMeasurementMetric,
+ unit: 'metric' | 'imperial',
+ metricNotation: 'meters' | 'millimeters',
+): string {
if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
- return formatLinearMeasurement(metric.value, unit)
+ return formatLinearMeasurement(metric.value, unit, metricNotation)
}
export const QuickMeasurementCard = memo(function QuickMeasurementCard({
report,
unit,
+ metricNotation,
lensState,
}: {
report: QuickMeasurementReport
unit: 'metric' | 'imperial'
+ metricNotation: 'meters' | 'millimeters'
lensState: 'live' | 'pinned'
}) {
const pinned = lensState === 'pinned'
@@ -53,7 +59,7 @@ export const QuickMeasurementCard = memo(function QuickMeasurementCard({
{metric.label}
- {formatMetric(metric, unit)}
+ {formatMetric(metric, unit, metricNotation)}