diff --git a/apps/editor/README.md b/apps/editor/README.md index ea6e6066..1329ad66 100644 --- a/apps/editor/README.md +++ b/apps/editor/README.md @@ -183,7 +183,6 @@ Systems are React components that run in the render loop (`useFrame`) to update | System | Responsibility | |--------|---------------| | `WallSystem` | Generates wall geometry with mitering and CSG cutouts for doors/windows | -| `SlabSystem` | Generates floor geometry from polygons | | `CeilingSystem` | Generates ceiling geometry | | `RoofSystem` | Generates roof geometry | | `ItemSystem` | Positions items on walls, ceilings, or floors (slab elevation) | diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index e4ccf7ed..7119d312 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -5,6 +5,7 @@ import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { BuildTab } from '@/components/build-tab' +import { FloorplanConstructionPreflight } from '@/components/floorplan-construction-preflight' import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight, @@ -89,6 +90,7 @@ const PROJECT_ID = 'local-editor' export default function Home() { return (
+ {PROJECT_ID === 'local-editor' && (
diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index bb476542..1ec86d04 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,7 +1,12 @@ 'use client' import { nodeRegistry } from '@pascal-app/core' -import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' +import { + getFloorplanNodeExtension, + MaterialPaintPanel, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -13,24 +18,6 @@ import { } from '@/components/toolbar-tooltip' import { cn } from '@/lib/utils' -/** - * Raw structure-tool kinds the Build tab can activate. These map 1:1 to the - * editor's `StructureTool` ids. - */ -type BuildToolKind = - | 'wall' - | 'fence' - | 'slab' - | 'ceiling' - | 'roof' - | 'stair' - | 'elevator' - | 'door' - | 'window' - | 'column' - | 'shelf' - | 'spawn' - /** * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" * group tile — its own sub-grid, like Roof's "Features". @@ -53,7 +40,8 @@ type BuildType = { /** Raster asset tile (legacy Build sidebar artwork). */ iconSrc: string /** Present for structure-tool types (absent for paint mode and the MEP group). */ - kind?: BuildToolKind + kind?: string + paletteOrder?: number /** Non-placement special mode. */ mode?: 'material-paint' } @@ -67,7 +55,7 @@ type MepItem = { } // Same icons + ordering as the community Build sidebar, minus presets. -const BUILD_TYPES: BuildType[] = [ +const BASE_BUILD_TYPES: BuildType[] = [ { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, @@ -85,6 +73,37 @@ const BUILD_TYPES: BuildType[] = [ { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, ] +function collectBuildTypes(): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + !extension?.tool || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) { + continue + } + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + // MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP // tools had in the community Build sidebar. const MEP_ITEMS: MepItem[] = [ @@ -105,8 +124,10 @@ const MEP_ITEMS: MepItem[] = [ * Activate a raw structure draw/cursor tool. Mirrors the editor's own * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). */ -function activateBuildTool(kind: BuildToolKind | MepToolKind): void { +function activateBuildTool(kind: string): void { const ed = useEditor.getState() + const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView + if (preferredView) ed.setViewMode(preferredView) ed.setPhase('structure') ed.setStructureLayer('elements') ed.setCatalogCategory(null) @@ -142,7 +163,7 @@ function activateRoofFeatureTool(kind: string): void { ed.setStructureLayer('elements') ed.setCatalogCategory(null) ed.setMode('build') - ed.setTool(kind as Parameters[0]) + ed.setTool(kind) } /** @@ -165,6 +186,7 @@ export function BuildTab() { const mode = useEditor((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) + const buildTypes = useMemo(collectBuildTypes, []) // The fitting / follow tools are armed from a segment's panel, not a grid // tile — keep the segment tile lit so the panel (and the way back) stays @@ -245,9 +267,9 @@ export function BuildTab() { didInitRef.current = true const ed = useEditor.getState() if (ed.mode === 'build' && ed.tool) return - const firstType = BUILD_TYPES.find((t) => t.kind) + const firstType = buildTypes.find((t) => t.kind) if (firstType) handleTypeClick(firstType) - }, [handleTypeClick]) + }, [buildTypes, handleTypeClick]) return (
@@ -256,7 +278,7 @@ export function BuildTab() { className="grid gap-1.5" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(56px, 1fr))' }} > - {BUILD_TYPES.map((type) => { + {buildTypes.map((type) => { const active = isTypeActive(type) return ( diff --git a/apps/editor/components/floorplan-construction-preflight.tsx b/apps/editor/components/floorplan-construction-preflight.tsx new file mode 100644 index 00000000..19d30b85 --- /dev/null +++ b/apps/editor/components/floorplan-construction-preflight.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useFloorplanPreflight } from '@pascal-app/editor' +import { + buildClearanceAdvisories, + buildConstructionModuleAdvisories, + buildDimensionCompletenessAudit, +} from '@pascal-app/nodes' +import { useEffect, useMemo, useState } from 'react' + +export function FloorplanConstructionPreflight() { + const nodes = useDebouncedSceneNodes() + const clearanceChecksEnabled = useFloorplanPreflight((state) => state.clearanceChecksEnabled) + const moduleChecksEnabled = useFloorplanPreflight((state) => state.moduleChecksEnabled) + const setAuditIssues = useFloorplanPreflight((state) => state.setAuditIssues) + + const issues = useMemo(() => { + const completeness = buildDimensionCompletenessAudit(nodes, { + includeAutomaticDimensions: true, + }).map((issue) => ({ + id: issue.id, + kind: 'dimension-completeness' as const, + severity: issue.severity, + message: issue.message, + })) + const clearance = clearanceChecksEnabled + ? buildClearanceAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'clearance-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + const modules = moduleChecksEnabled + ? buildConstructionModuleAdvisories(nodes, { includeDisabled: true }).map((issue) => ({ + id: issue.id, + kind: 'module-advisory' as const, + severity: issue.severity, + message: issue.message, + })) + : [] + return [...completeness, ...clearance, ...modules] + }, [clearanceChecksEnabled, moduleChecksEnabled, nodes]) + + useEffect(() => { + setAuditIssues(issues) + return () => setAuditIssues([]) + }, [issues, setAuditIssues]) + + return null +} + +function useDebouncedSceneNodes() { + const [nodes, setNodes] = useState(() => useScene.getState().nodes) + + useEffect(() => { + let pending: ReturnType | undefined + const unsubscribe = useScene.subscribe((state) => { + if (pending) clearTimeout(pending) + pending = setTimeout(() => setNodes(state.nodes), 100) + }) + return () => { + if (pending) clearTimeout(pending) + unsubscribe() + } + }, []) + + return nodes +} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index d4443a32..77809e10 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -2,6 +2,7 @@ import { Icon as IconifyIcon } from '@iconify/react' import { + DRAWING_TYPE_OPTIONS, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -10,7 +11,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + useDrawingView, useEditor, + useFloorplanAnnotationVisibility, useSidebarStore, type ViewMode, } from '@pascal-app/editor' @@ -32,12 +35,16 @@ import { EyeOff, Footprints, Grid2X2, + Layers3, Magnet, PenLine, Ruler, + ScanLine, SlidersHorizontal, Sparkles, + SquareUserRound, SwatchBook, + Tag, } from 'lucide-react' import Image from 'next/image' import { type ReactNode, useCallback } from 'react' @@ -133,6 +140,22 @@ const SHADING_OPTIONS = [ { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, ] as const +const FLOORPLAN_ANNOTATION_OPTIONS = [ + { id: 'automaticDimensions', name: 'Automatic dimensions', icon: Ruler }, + { id: 'manualDimensions', name: 'Manual dimensions', icon: Ruler }, + { id: 'measurements', name: 'Measurements', icon: ScanLine }, + { id: 'openingMarks', name: 'Door/window marks', icon: Tag }, + { id: 'structuralGrids', name: 'Structural grids & column centers', icon: Grid2X2 }, + { id: 'roomLabels', name: 'Room labels', icon: SquareUserRound }, + { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, +] as const + +const FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS = [ + { id: 'finished-faces', name: 'Finished faces', detail: 'Full wall thickness' }, + { id: 'centerline', name: 'Wall centerline', detail: 'Single wall axis' }, + { id: 'stud-faces', name: 'Face of stud', detail: 'Structural core face' }, +] as const + function ViewModeControl() { const viewMode = useEditor((state) => state.viewMode) const setViewMode = useEditor((state) => state.setViewMode) @@ -165,6 +188,49 @@ function ViewModeControl() { ) } +function DrawingTypeControl() { + const viewMode = useEditor((state) => state.viewMode) + const drawingType = useDrawingView((state) => state.drawingType) + const setDrawingType = useDrawingView((state) => state.setDrawingType) + if (viewMode === '3d') return null + + const active = + DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType) ?? DRAWING_TYPE_OPTIONS[0] + + return ( +
+ + + + + + + + {DRAWING_TYPE_OPTIONS.map((option) => ( + setDrawingType(option.id)}> + + {option.label} + {drawingType === option.id ? : null} + + ))} + + +
+ ) +} + function CollapseSidebarButton() { const isCollapsed = useSidebarStore((state) => state.isCollapsed) const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed) @@ -278,12 +344,15 @@ const EDGE_OPTIONS = [ const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/95 backdrop-blur-xl' function DisplayMenu() { + const viewMode = useEditor((state) => state.viewMode) const showGrid = useViewer((state) => state.showGrid) const setShowGrid = useViewer((state) => state.setShowGrid) const showMeasurements = useViewer((state) => state.showMeasurements) const setShowMeasurements = useViewer((state) => state.setShowMeasurements) const unit = useViewer((state) => state.unit) const setUnit = useViewer((state) => state.setUnit) + const metricNotation = useViewer((state) => state.metricNotation) + const setMetricNotation = useViewer((state) => state.setMetricNotation) const cameraMode = useViewer((state) => state.cameraMode) const setCameraMode = useViewer((state) => state.setCameraMode) const shading = useViewer((state) => state.shading) @@ -296,6 +365,14 @@ function DisplayMenu() { const setShadows = useViewer((state) => state.setShadows) const magneticSnap = useEditor((state) => state.magneticSnap) const setMagneticSnap = useEditor((state) => state.setMagneticSnap) + const annotationVisibility = useFloorplanAnnotationVisibility((state) => state.visibility) + const setAnnotationCategory = useFloorplanAnnotationVisibility((state) => state.setCategory) + const wallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.wallDimensionReference, + ) + const setWallDimensionReference = useFloorplanAnnotationVisibility( + (state) => state.setWallDimensionReference, + ) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -337,17 +414,82 @@ function DisplayMenu() { )} - keepOpen(e, () => setShowMeasurements(!showMeasurements))} - > - - Measurements - {showMeasurements ? ( - - ) : ( - - )} - + {viewMode !== '2d' ? ( + keepOpen(e, () => setShowMeasurements(!showMeasurements))} + > + + {viewMode === 'split' ? '3D measurements' : 'Measurements'} + {showMeasurements ? ( + + ) : ( + + )} + + ) : null} + {viewMode !== '3d' ? ( + <> + + + + Floor plan annotations + + + {FLOORPLAN_ANNOTATION_OPTIONS.map((option) => { + const OptionIcon = option.icon + const visible = annotationVisibility[option.id] + return ( + + keepOpen(e, () => setAnnotationCategory(option.id, !visible)) + } + > + + {option.name} + {visible ? ( + + ) : ( + + )} + + ) + })} + + + + + + Wall dimensions + + { + FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( + (option) => option.id === wallDimensionReference, + )?.name + } + + + + {FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => ( + + keepOpen(event, () => setWallDimensionReference(option.id)) + } + > +
+ {option.name} + {option.detail} +
+ {wallDimensionReference === option.id ? ( + + ) : null} +
+ ))} +
+
+ + ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> Magnetic snap @@ -377,17 +519,48 @@ function DisplayMenu() { {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} - keepOpen(e, () => setUnit(unit === 'metric' ? 'imperial' : 'metric'))} - > - - {unit === 'metric' ? 'm' : 'ft'} - - Units - - {unit === 'metric' ? 'Metric' : 'Imperial'} - - + + + + {unit === 'imperial' ? 'ft' : metricNotation === 'millimeters' ? 'mm' : 'm'} + + Units + + {unit === 'imperial' + ? 'Feet & inches' + : metricNotation === 'millimeters' + ? 'Millimeters' + : 'Meters'} + + + + setMetricNotation('meters')}> + + m + + Meters + {unit === 'metric' && metricNotation === 'meters' ? ( + + ) : null} + + setMetricNotation('millimeters')}> + + mm + + Millimeters + {unit === 'metric' && metricNotation === 'millimeters' ? ( + + ) : null} + + setUnit('imperial')}> + + ft + + Feet & inches + {unit === 'imperial' ? : null} + + + @@ -521,6 +694,7 @@ export function CommunityViewerToolbarLeft() { <> + ) } diff --git a/apps/editor/public/icons/structural-grid.webp b/apps/editor/public/icons/structural-grid.webp new file mode 100644 index 00000000..bc5ec505 Binary files /dev/null and b/apps/editor/public/icons/structural-grid.webp differ diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/bun.lock b/bun.lock index 2a57c1e6..5cbd6c7a 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -124,7 +124,7 @@ }, "packages/editor": { "name": "@pascal-app/editor", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -145,35 +145,37 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@visual-json/react": "^0.4.0", + "blob-stream": "^0.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "howler": "^2.2.4", - "jspdf": "^4.2.1", "lucide-react": "^1.7.0", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", - "svg2pdf.js": "^2.7.0", + "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", + "@types/blob-stream": "^0.1.33", "@types/bun": "^1.3.0", "@types/howler": "^2.2.12", + "@types/pdfkit": "^0.17.6", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", - "@pascal-app/viewer": "^0.9.1", + "@pascal-app/core": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "next": ">=15", @@ -201,7 +203,7 @@ }, "packages/ifc-converter": { "name": "@pascal-app/ifc-converter", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@pascal-app/core": "*", "nanoid": "^5.1.6", @@ -215,7 +217,7 @@ }, "packages/mcp": { "name": "@pascal-app/mcp", - "version": "0.3.1", + "version": "0.3.2", "bin": { "pascal-mcp": "./dist/bin/pascal-mcp.js", }, @@ -225,22 +227,22 @@ "zod": "^4.3.5", }, "devDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@pascal/typescript-config": "*", "@types/node": "^22.19.20", "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", }, }, "packages/nodes": { "name": "@pascal-app/nodes", - "version": "0.1.0", + "version": "0.1.1", "devDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@pascal/typescript-config": "*", "@types/bun": "^1.3.0", "@types/node": "^22.19.12", @@ -249,9 +251,9 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.0", - "@pascal-app/editor": "^0.9.0", - "@pascal-app/viewer": "^0.9.0", + "@pascal-app/core": "^0.9.2", + "@pascal-app/editor": "^0.9.2", + "@pascal-app/viewer": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "lucide-react": "^1", @@ -283,7 +285,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.9.1", + "version": "0.9.2", "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8", @@ -297,7 +299,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@pascal-app/core": "^0.9.1", + "@pascal-app/core": "^0.9.2", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", @@ -538,6 +540,10 @@ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -852,6 +858,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@types/blob-stream": ["@types/blob-stream@0.1.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HNHZ1S6W7F8PhxdyAastunpUC8cAZim78UIfqbL79gLzylp8EZep68yxAh11hTRoEvsqHAg/MECgmKF8+V0HzQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="], @@ -868,9 +876,7 @@ "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], - "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], - - "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -882,8 +888,6 @@ "@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="], - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -978,8 +982,6 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], @@ -988,12 +990,20 @@ "bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="], + + "blob-stream": ["blob-stream@0.1.3", "", { "dependencies": { "blob": "0.0.4" } }, "sha512-xXwyhgVmPsFVFFvtM5P0syI17/oae+MIjLn5jGhuD86mmSJ61EWMWmbPrV/0+bdcH9jQ2CzIhmTQKNUJL7IPog=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg=="], + + "browserify-zlib": ["browserify-zlib@0.2.0", "", { "dependencies": { "pako": "~1.0.5" } }, "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -1014,8 +1024,6 @@ "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], - "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1030,6 +1038,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -1056,18 +1066,12 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-env": ["cross-env@7.0.3", "", { "dependencies": { "cross-spawn": "^7.0.1" }, "bin": { "cross-env": "src/bin/cross-env.js", "cross-env-shell": "src/bin/cross-env-shell.js" } }, "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -1100,9 +1104,9 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + "dfa": ["dfa@1.2.0", "", {}, "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q=="], - "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="], @@ -1202,8 +1206,6 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="], - "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], @@ -1232,7 +1234,7 @@ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - "font-family-papandreou": ["font-family-papandreou@0.2.0-patch2", "", {}, "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw=="], + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], @@ -1302,8 +1304,6 @@ "howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="], - "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1330,8 +1330,6 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1404,6 +1402,8 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-md5": ["js-md5@0.8.3", "", {}, "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], @@ -1422,8 +1422,6 @@ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], - "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="], - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1460,6 +1458,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1580,7 +1580,7 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - "pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], @@ -1598,7 +1598,7 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], + "pdfkit": ["pdfkit@0.19.1", "", { "dependencies": { "@noble/ciphers": "^1.0.0", "@noble/hashes": "^1.6.0", "fontkit": "^2.0.4", "js-md5": "^0.8.3", "linebreak": "^1.1.0", "png-js": "^1.1.0" } }, "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1606,6 +1606,8 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "png-js": ["png-js@1.1.0", "", { "dependencies": { "browserify-zlib": "^0.2.0" } }, "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], @@ -1632,8 +1634,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1660,8 +1660,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1674,9 +1672,9 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], - "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1726,10 +1724,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="], - - "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], - "stats-gl": ["stats-gl@2.4.2", "", { "dependencies": { "@types/three": "*", "three": "^0.170.0" } }, "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ=="], "stats.js": ["stats.js@0.17.0", "", {}, "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw=="], @@ -1768,12 +1762,6 @@ "suspend-react": ["suspend-react@0.1.3", "", { "peerDependencies": { "react": ">=17.0" } }, "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ=="], - "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], - - "svg2pdf.js": ["svg2pdf.js@2.7.0", "", { "dependencies": { "cssesc": "^3.0.0", "font-family-papandreou": "^0.2.0-patch1", "specificity": "^0.4.1", "svgpath": "^2.3.0" }, "peerDependencies": { "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" } }, "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA=="], - - "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1782,8 +1770,6 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], - "three": ["three@0.185.1", "", {}, "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="], "three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="], @@ -1792,6 +1778,8 @@ "three-stdlib": ["three-stdlib@2.36.1", "", { "dependencies": { "@types/draco3d": "^1.4.0", "@types/offscreencanvas": "^2019.6.4", "@types/webxr": "^0.5.2", "draco3d": "^1.4.1", "fflate": "^0.6.9", "potpack": "^1.0.1" }, "peerDependencies": { "three": ">=0.128.0" } }, "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg=="], + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1844,6 +1832,10 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg=="], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], @@ -1860,8 +1852,6 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], - "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1962,6 +1952,8 @@ "agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "browserify-zlib/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "conf/semver": ["semver@7.8.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg=="], "deslop-js/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1978,6 +1970,8 @@ "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 00000000..8ed33c4f --- /dev/null +++ b/design-qa.md @@ -0,0 +1,64 @@ +**Comparison Target** + +- Source visual truth: `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-18484c1f-bc43-43a1-a329-791cb5605ff4.png` and `/var/folders/sn/2jgcj5r95qq3t4l_7tzjh2gh0000gn/T/codex-clipboard-1ff72f24-c84b-4b6a-94eb-4ced9a04bd4d.png`. +- Implementation screenshot: `/private/tmp/internal-dimension-lines-preview.png`. +- Viewport: 1280 × 720. +- State: local scene route after clicking 2D and waiting 2.5 seconds; 3D remained selected and the scene remained on its loading indicator. +- Intended state: internal dimension baselines, witness lines, ticks, and values render clear of the wall, and enclosed perimeter doors receive room-side width dimensions. + +**Full-view Comparison Evidence** + +- The source screenshots show internal values while their linework is collapsed onto the host walls, making the strings read as detached text. +- The larger left perimeter door has no room-side width dimension in the source state. +- The implementation screenshot could not be compared at the same scene state because the local scene did not finish loading. + +**Focused-region Comparison Evidence** + +- Source: the horizontal internal strings contain values such as `0.5m`, `0.9m`, `4.9m`, and `3.5m`, but the intended parallel baseline and extension linework is not visibly separated from the wall. +- Generated implementation geometry now places automatic internal baselines at `0.55 m` from their witness origins rather than explicitly pinning them at `0 m`. +- Generated plans now include room-side opening chains for enclosed perimeter walls in all four orientations, including a left-side door. +- A same-state rendered focused comparison is blocked by the local loading state. + +**Findings** + +- [P0] Browser-rendered implementation evidence unavailable. + Location: local editor preview. + Evidence: the captured implementation contains only the loading indicator; clicking 2D leaves 3D selected. + Impact: final screen-space line visibility, collisions, and door-width placement cannot be visually accepted. + Fix: restore a working local scene preview and recapture the reported room in 2D. + +**Required Fidelity Surfaces** + +- Fonts and typography: source labels remain unchanged by this fix; post-fix rendered typography is blocked from inspection. +- Spacing and layout rhythm: geometry tests verify the internal baseline clearance is restored to `0.55 m`; pixel-level rhythm is blocked from inspection. +- Colors and visual tokens: no color or token changes were made; rendered contrast is blocked from inspection. +- Image quality and asset fidelity: no image assets are involved. +- Copy and content: dimension values remain generated from the same measurements; the missing perimeter-door width is now included. + +**Comparison History** + +- Earlier P0: internal baseline coordinates were explicitly equal to witness coordinates, overriding `offsetDistance` and collapsing lines onto walls. +- Fix: preserve omitted automatic baselines so the renderer applies the configured offset; add enclosed room-side opening chains for perimeter walls. +- Post-fix evidence: SVG renderer regression asserts a `0.55 m` automatic baseline, and planner regressions cover top, right, bottom, and left perimeter doors. Browser-rendered evidence remains blocked. + +**Implementation Evidence** + +- Focused dimension, wall, floor-plan, and registry tests: 61 passed, 0 failed. +- Nodes package build: passed. +- Editor package type-check: blocked by the unrelated missing `resolveFloorplanExportViewport` export referenced by `floorplan-export.test.ts`. +- Biome check: passed. +- Git diff whitespace check: passed. +- Browser console warnings/errors: none reported. + +**Implementation Checklist** + +- Restore the local editor preview. +- Reopen the reported room in 2D. +- Confirm each internal string has a visible parallel baseline, witness lines, and ticks. +- Confirm the large left-side door displays its room-side width dimension. + +**Follow-up Polish** + +- Reassess internal line contrast only after the corrected geometry can be seen in the target scene. + +final result: blocked diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 78bc2619..25aed8e7 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -9,10 +9,12 @@ import type { CeilingNode, ChimneyNode, ColumnNode, + ConstructionDimensionNode, CupolaNode, DoorNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctFittingNode, DuctSegmentNode, DuctTerminalNode, @@ -42,6 +44,7 @@ import type { SpawnNode, StairNode, StairSegmentNode, + StructuralGridNode, TurbineVentNode, WallNode, WindowNode, @@ -101,10 +104,12 @@ export type SlabEvent = NodeEvent export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type ColumnEvent = NodeEvent +export type ConstructionDimensionEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent export type StairEvent = NodeEvent export type StairSegmentEvent = NodeEvent +export type StructuralGridEvent = NodeEvent export type WindowEvent = NodeEvent export type DoorEvent = NodeEvent export type ElevatorEvent = NodeEvent @@ -121,6 +126,7 @@ export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent export type DownspoutEvent = NodeEvent +export type DrawingSheetEvent = NodeEvent export type DuctSegmentEvent = NodeEvent export type DuctFittingEvent = NodeEvent export type DuctTerminalEvent = NodeEvent @@ -295,10 +301,12 @@ type EditorEvents = GridEvents & NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'column', ColumnEvent> & + NodeEvents<'construction-dimension', ConstructionDimensionEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & NodeEvents<'stair', StairEvent> & NodeEvents<'stair-segment', StairSegmentEvent> & + NodeEvents<'structural-grid', StructuralGridEvent> & NodeEvents<'window', WindowEvent> & NodeEvents<'door', DoorEvent> & NodeEvents<'scan', ScanEvent> & @@ -314,6 +322,7 @@ type EditorEvents = GridEvents & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & NodeEvents<'downspout', DownspoutEvent> & + NodeEvents<'drawing-sheet', DrawingSheetEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> & diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts new file mode 100644 index 00000000..9ad31a19 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts @@ -0,0 +1,180 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { type FenceSupportInput, resolveFenceSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [0, 4] × [0, 3]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +/** Ground floor slab under (and far beyond) the deck. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +/** Straight fence fully over the deck footprint. */ +function fenceOnDeck(overrides: Partial = {}): FenceSupportInput { + return { + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.08, + parentId: LEVEL_ID, + ...overrides, + } +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +function sceneWith(...slabs: SlabNode[]): Record { + const nodes = nodesFor(makeLevel(), ...(slabs as AnyNode[])) + useScene.setState({ nodes }) + for (const slab of slabs) addSlab(slab) + return nodes +} + +beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('resolveFenceSupportSlabPatch', () => { + test('a fence drawn over a deck stacked on the floor persists the deck (uncapped max election)', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('the pointer cap decides between stacked surfaces', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + // Aiming at the floor under the deck elects (and persists) the floor. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: FLOOR_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_ground' }) + // Aiming at the deck top keeps the deck. + expect( + resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: DECK_ELEVATION }), + ).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a lone elevated deck (balcony, nothing underneath) still persists its host', () => { + // Unambiguous single candidate — but fences resolve an absent host to + // the level floor, so an elevated winner must be written or the fence + // renders buried under the deck. + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: 'slab_deck', + }) + }) + + test('a plain default ground slab stays unpersisted (fence keeps sitting at the level base)', () => { + const nodes = sceneWith(makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) + + test('capped at bare ground under a deck-only overlap resolves to the floor default', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('no slabs / off-slab fence persists nothing', () => { + const nodes = sceneWith() + expect(resolveFenceSupportSlabPatch(fenceOnDeck(), nodes)).toEqual({ + supportSlabId: undefined, + }) + + const withDeck = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect( + resolveFenceSupportSlabPatch(fenceOnDeck({ start: [10, 10], end: [13, 10] }), withDeck), + ).toEqual({ supportSlabId: undefined }) + }) + + test('a spline fence elects through its path band segments', () => { + const nodes = sceneWith( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION), + makeSlab('slab_ground', GROUND_POLYGON, FLOOR_ELEVATION), + ) + const spline = fenceOnDeck({ + start: [0.5, 0.5], + end: [3.5, 2.5], + path: [ + [0.5, 0.5], + [2, 1.5], + [3.5, 2.5], + ], + }) + expect(resolveFenceSupportSlabPatch(spline, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a fence not parented to a level persists nothing', () => { + const nodes = sceneWith(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(resolveFenceSupportSlabPatch(fenceOnDeck({ parentId: 'not_a_level' }), nodes)).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index f0dfe76f..ebdb24c3 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -74,6 +74,8 @@ function addSlab(polygon: Array<[number, number]>, elevation: number, id = `slab holes: [], holeMetadata: [], elevation, + thickness: Math.max(elevation, 0), + recessed: elevation < 0, autoFromWalls: false, } as SlabNode spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts index b0b8315d..ca66b3cc 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.ts @@ -8,12 +8,29 @@ import type { import type { AnyNode, AnyNodeId } from '../../schema' import { spatialGridManager } from './spatial-grid-manager' +/** + * Sentinel `supportSlabId` meaning "hosted by the level base (ground)". + * Persisted when a pointer-capped commit elects the ground while one or + * more slabs (e.g. an elevated deck) still overlap the footprint above the + * cap — without it, the uncapped per-frame election would lift the + * committed node back onto the deck. + */ +export const GROUND_SUPPORT_ID = 'ground' + export type FloorPlacedElevationArgs = { node: AnyNode nodes: Record position: [number, number, number] rotation?: unknown levelId?: string | null + /** + * Pointer-decided support cap (level-local Y): only slabs whose walking + * surface sits at or below `maxElevation + SUPPORT_ELEVATION_EPSILON` + * may be elected, and the persisted `supportSlabId` is bypassed — during + * a drag the pointer, not the stored host, decides the target surface. + * Omit (or pass null) for the uncapped committed-read behavior. + */ + maxElevation?: number | null } function finiteSlabElevation(elevation: number): number { @@ -50,6 +67,7 @@ export function getFloorPlacedElevation({ position, rotation, levelId, + maxElevation, }: FloorPlacedElevationArgs): number { const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced if (!floorPlaced) return 0 @@ -66,8 +84,31 @@ export function getFloorPlacedElevation({ const resolvedLevelId = parent?.type === 'level' ? parent.id : levelId if (!resolvedLevelId) return 0 - let maxElevation = Number.NEGATIVE_INFINITY - for (const footprint of getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes })) { + const footprints = getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes }) + + // A persisted support host pins the elevation while it still exists and + // overlaps a footprint — deterministic across stacked slabs. A stale + // host (deleted or reshaped away) silently falls through to the + // election below; this per-frame read path never writes the field. + // Skipped entirely under a pointer cap: the cursor, not the stored + // host, decides the target surface during a drag. + const supportSlabId = (effectiveNode as { supportSlabId?: string | null }).supportSlabId + if (maxElevation == null && supportSlabId) { + if (supportSlabId === GROUND_SUPPORT_ID) return 0 + for (const footprint of footprints) { + const hosted = spatialGridManager.getHostSlabElevationForFootprint( + resolvedLevelId, + supportSlabId, + footprint.position ?? position, + footprint.dimensions, + footprint.rotation, + ) + if (hosted !== null) return finiteSlabElevation(hosted) + } + } + + let elected = Number.NEGATIVE_INFINITY + for (const footprint of footprints) { const footprintPosition = footprint.position ?? position const elevation = finiteSlabElevation( spatialGridManager.getSlabElevationForItem( @@ -75,14 +116,15 @@ export function getFloorPlacedElevation({ footprintPosition, footprint.dimensions, footprint.rotation, + maxElevation, ), ) - if (elevation > maxElevation) { - maxElevation = elevation + if (elevation > elected) { + elected = elevation } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + return elected === Number.NEGATIVE_INFINITY ? 0 : elected } export function getFloorStackedPosition(args: FloorPlacedElevationArgs): [number, number, number] { diff --git a/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts new file mode 100644 index 00000000..1846afa6 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/pointer-support-cap.test.ts @@ -0,0 +1,487 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' +import { GROUND_SUPPORT_ID, getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { resolveSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +/** Deck footprint in plan: x/z ∈ [-1, 1]. */ +const DECK_POLYGON: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +/** Ground floor slab under (and far beyond) the deck: x/z ∈ [-5, 5]. */ +const GROUND_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], +] + +const DECK_ELEVATION = 0.9 +const FLOOR_ELEVATION = 0.05 + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function addDeckAndFloor() { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) +}) + +describe('pointer-capped slab support election', () => { + test('hit at the floor under the deck elects the floor, not the deck above', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + FLOOR_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('hit on the deck top still elects the deck', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('no cap keeps the historical max election', () => { + addDeckAndFloor() + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + }) + + test('epsilon boundary: a slab within EPS above the cap is elected, beyond EPS is not', () => { + // Cap 0.05 with EPS 0.05: a slab at 0.10 is still electable, 0.11 is not. + addSlab(makeSlab('slab_within', DECK_POLYGON, 0.1)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0.1, slabId: 'slab_within' }) + + spatialGridManager.clear() + addSlab(makeSlab('slab_beyond', DECK_POLYGON, 0.11)) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0], 0.05), + ).toEqual({ elevation: 0, slabId: null }) + }) +}) + +describe('getPointedSupportSurface (ray → aimed-at walking surface)', () => { + test('ray aimed at the floor under the deck resolves the floor, aimed at the deck resolves the deck', () => { + addDeckAndFloor() + + // Camera in front of the deck (negative z), high up. Aiming at the + // floor point (0, FLOOR, 0) — a point that lies UNDER the deck in + // plan — crosses the deck's elevation plane before reaching the deck + // polygon, so only the floor is hit. + const origin: [number, number, number] = [0, 5, -10] + const toFloorUnderDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 0 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toFloorUnderDeck)).toEqual( + { elevation: FLOOR_ELEVATION, slabId: 'slab_floor', point: [0, 0] }, + ) + + // Aiming at the deck's top surface: the deck plane crossing lands + // inside the deck polygon and is nearer along the ray than the floor. + const toDeckTop: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.5 - origin[2], + ] + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, toDeckTop)).toEqual({ + elevation: DECK_ELEVATION, + slabId: 'slab_deck', + point: [0, 0.5], + }) + }) + + test('a ray through a deck hole falls through to the surface below', () => { + addSlab( + makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION, { + holes: [ + [ + [-0.5, -0.5], + [0.5, -0.5], + [0.5, 0.5], + [-0.5, 0.5], + ], + ], + }), + ) + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + + // Straight down through the hole center. + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, -1, 0])).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 0], + }) + }) + + test('no slab crossing resolves the level base (with the base-plane point)', () => { + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [3, 5, 3], [0, -1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: [3, 3], + }) + }) + + test('a ray that cannot reach any surface has no point', () => { + addDeckAndFloor() + expect(spatialGridManager.getPointedSupportSurface(LEVEL_ID, [0, 5, 0], [0, 1, 0])).toEqual({ + elevation: 0, + slabId: null, + point: null, + }) + }) +}) + +describe('pointed point — stacked-deck hop repro (ray ∩ pointed-surface plane)', () => { + // Manual repro this pins down: deck slab stacked above a floor slab, + // move an item over the deck near its far edge with an angled camera. + // The grid event plane rides at the ghost's LAST surface height, so the + // same screen ray produces hit points whose XZ differ by metres + // depending on which storey the plane rode at. The cap (ray → pointed + // surface) is plane-height independent, but electing at the RAW hit XZ + // is not: the floor-height hit is perspective-skewed past the deck, its + // footprint misses the deck polygon, and the capped election falls to + // the floor — dropping the ghost, which drops the plane, which keeps + // the hit skewed (a second self-consistent state). Transitions between + // the two states are the hop. Electing at the ray-derived `point` + // leaves a single fixed point per pointer ray. + const origin: [number, number, number] = [0, 5, -10] + /** Aimed at the deck top near its far edge: (0, DECK_ELEVATION, 0.8). */ + const aimAtDeck: [number, number, number] = [ + 0 - origin[0], + DECK_ELEVATION - origin[1], + 0.8 - origin[2], + ] + + test('same ray reconstructed from either plane-height hit: pointed point elects the deck every time', () => { + addDeckAndFloor() + + // The two grid hits the SAME screen ray produces — one per event-plane + // height (plane riding at the deck vs at the floor slab). + const tDeck = (DECK_ELEVATION - origin[1]) / aimAtDeck[1] + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const planeHits = [tDeck, tFloor].map((t): [number, number, number] => [ + origin[0] + aimAtDeck[0] * t, + origin[1] + aimAtDeck[1] * t, + origin[2] + aimAtDeck[2] * t, + ]) + + for (const hit of planeHits) { + const direction: [number, number, number] = [ + hit[0] - origin[0], + hit[1] - origin[1], + hit[2] - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, direction) + expect(pointed.slabId).toBe('slab_deck') + expect(pointed.elevation).toBe(DECK_ELEVATION) + expect(pointed.point?.[0]).toBeCloseTo(0, 10) + expect(pointed.point?.[1]).toBeCloseTo(0.8, 10) + + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [pointed.point![0], 0, pointed.point![1]], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: DECK_ELEVATION, slabId: 'slab_deck' }) + } + }) + + test('electing at the raw floor-height hit flips to the floor — the hop mechanism, kept as documentation', () => { + addDeckAndFloor() + + const tFloor = (FLOOR_ELEVATION - origin[1]) / aimAtDeck[1] + const floorPlaneHit: [number, number, number] = [ + origin[0] + aimAtDeck[0] * tFloor, + 0, + origin[2] + aimAtDeck[2] * tFloor, + ] + // The skew carries the hit metres past the deck's far edge (z = 1)… + expect(floorPlaneHit[2]).toBeGreaterThan(2) + // …so the same pointer ray, elected at the raw hit XZ, picks the + // FLOOR while the cap says the pointer is on the deck. + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + floorPlaneHit, + [1, 1, 1], + [0, 0, 0], + DECK_ELEVATION, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) + + test('pointer past the deck edge: pointed point lands on the floor and elects it', () => { + addDeckAndFloor() + + // Aimed at a floor point far enough out that the deck-plane crossing + // falls outside the deck polygon (the floor there is actually visible). + const aimPastDeck: [number, number, number] = [ + 0 - origin[0], + FLOOR_ELEVATION - origin[1], + 4 - origin[2], + ] + const pointed = spatialGridManager.getPointedSupportSurface(LEVEL_ID, origin, aimPastDeck) + expect(pointed).toEqual({ + elevation: FLOOR_ELEVATION, + slabId: 'slab_floor', + point: [0, 4], + }) + expect( + spatialGridManager.getSlabSupportForItem( + LEVEL_ID, + [0, 0, 4], + [1, 1, 1], + [0, 0, 0], + pointed.elevation, + ), + ).toEqual({ elevation: FLOOR_ELEVATION, slabId: 'slab_floor' }) + }) +}) + +describe('getFloorPlacedElevation under a pointer cap', () => { + test('cap at the floor keeps the item on the floor even though the deck overlaps in plan', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: DECK_ELEVATION, + }), + ).toBeCloseTo(DECK_ELEVATION) + + // Uncapped read keeps the historical max election. + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(DECK_ELEVATION) + }) + + test('the pointer cap bypasses a persisted host — the cursor decides during a drag', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_deck' } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + maxElevation: FLOOR_ELEVATION, + }), + ).toBeCloseTo(FLOOR_ELEVATION) + }) + + test('the ground sentinel pins a committed node to the level base under an overlapping deck', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: GROUND_SUPPORT_ID } as Partial) + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBe(0) + }) +}) + +describe('resolveSupportSlabPatch under a pointer cap (commit determinism)', () => { + test('a commit under the deck persists the elected lower slab', () => { + registerFloorPlacedItem() + addDeckAndFloor() + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_floor', + }) + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + // Uncapped commits keep the historical rule (max winner on ambiguity). + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('a commit on bare ground under the deck persists the ground sentinel', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: 0 })).toEqual({ + supportSlabId: GROUND_SUPPORT_ID, + }) + // Aiming at the deck top with only the deck overlapping stays + // unambiguous — no host persisted, same as the uncapped rule. + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) + + test('a single floor slab under the cap stays unpersisted (unambiguous)', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_floor', GROUND_POLYGON, FLOOR_ELEVATION)) + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node) + + expect(resolveSupportSlabPatch(node, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: undefined, + }) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 81c322e1..fa4b27bc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -2,36 +2,35 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import { getScaledDimensions, isLowProfileItemSurface } from '../../schema' +import { getWallPlaneTop } from '../../services/storey' import useScene from '../../store/use-scene' -import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallSlabSupport, +} from '../../systems/slab/slab-support' import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../../systems/wall/wall-top' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { SpatialGrid } from './spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid' +export { + computeWallSlabElevation, + computeWallSlabSupport, + pointInPolygon, + SUPPORT_ELEVATION_EPSILON, + type WallOverlapInput, + type WallSlabSupport, + type WallSlabSupportSegment, + wallOverlapsPolygon, +} from '../../systems/slab/slab-support' + // ============================================================================ // GEOMETRY HELPERS // ============================================================================ -/** - * Point-in-polygon test using ray casting algorithm. - */ -export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { - let inside = false - const n = polygon.length - for (let i = 0, j = n - 1; i < n; j = i++) { - const xi = polygon[i]![0], - zi = polygon[i]![1] - const xj = polygon[j]![0], - zj = polygon[j]![1] - - if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { - inside = !inside - } - } - return inside -} - /** * Compute the 4 XZ footprint corners of an item given its position, dimensions, and Y rotation. */ @@ -295,512 +294,29 @@ export function itemOverlapsPolygon( return false } -function pointSegmentDistance( - px: number, - pz: number, - ax: number, - az: number, - bx: number, - bz: number, -): number { - const dx = bx - ax - const dz = bz - az - const lengthSquared = dx * dx + dz * dz - if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) - const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) - return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) -} - -// Ray-cast pointInPolygon is unreliable for points exactly on the polygon -// boundary: the answer flips depending on which side of the polygon the edge -// is on. Interval classification below therefore treats "within this distance -// of the boundary" as inside explicitly, so walls sitting exactly on a slab -// edge (the common case — auto-slab polygons derive from wall centerlines) -// classify identically on every side of the slab. -const ON_BOUNDARY_EPSILON = 1e-4 - -function pointOnPolygonBoundary(px: number, pz: number, polygon: Array<[number, number]>): boolean { - const n = polygon.length - for (let i = 0; i < n; i++) { - const [ax, az] = polygon[i]! - const [bx, bz] = polygon[(i + 1) % n]! - if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true - } - return false -} - -/** Sub-interval along a segment or polyline: [start, end] in length units. */ -type LengthInterval = [number, number] - -function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { - if (intervals.length <= 1) return intervals - const sorted = [...intervals].sort((a, b) => a[0] - b[0]) - const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] - for (let i = 1; i < sorted.length; i++) { - const [intervalStart, intervalEnd] = sorted[i]! - const last = merged[merged.length - 1]! - if (intervalStart <= last[1] + 1e-9) { - last[1] = Math.max(last[1], intervalEnd) - } else { - merged.push([intervalStart, intervalEnd]) - } - } - return merged -} - -/** Total length of a merged (sorted, disjoint) interval list. */ -function intervalsLength(intervals: readonly LengthInterval[]): number { - let total = 0 - for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart - return total -} - -/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ -function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { - if (base.length === 0 || cut.length === 0) return mergeIntervals(base) - const cuts = mergeIntervals(cut) - const result: LengthInterval[] = [] - for (const [baseStart, baseEnd] of mergeIntervals(base)) { - let cursor = baseStart - for (const [cutStart, cutEnd] of cuts) { - if (cutEnd <= cursor) continue - if (cutStart >= baseEnd) break - if (cutStart > cursor) result.push([cursor, cutStart]) - cursor = cutEnd - if (cursor >= baseEnd) break - } - if (cursor < baseEnd) result.push([cursor, baseEnd]) - } - return result -} - -/** - * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, - * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the - * segment. The segment is split at every crossing with a polygon edge and - * each sub-interval is classified by its midpoint, so no test point ever - * sits on a crossing. - */ -function segmentInsideIntervals( - ax: number, - az: number, - bx: number, - bz: number, - polygon: Array<[number, number]>, - includeBoundary: boolean, -): LengthInterval[] { - const dx = bx - ax - const dz = bz - az - const length = Math.hypot(dx, dz) - if (length < 1e-9) return [] - - const ts = [0, 1] - const n = polygon.length - for (let i = 0; i < n; i++) { - const [px, pz] = polygon[i]! - const [qx, qz] = polygon[(i + 1) % n]! - const ex = qx - px - const ez = qz - pz - const denom = dx * ez - dz * ex - if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at - const t = ((px - ax) * ez - (pz - az) * ex) / denom - const s = ((px - ax) * dz - (pz - az) * dx) / denom - if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) - } - ts.sort((a, b) => a - b) - - const inside: LengthInterval[] = [] - for (let i = 1; i < ts.length; i++) { - const t0 = ts[i - 1]! - const t1 = ts[i]! - if (t1 - t0 < 1e-9) continue - const tm = (t0 + t1) / 2 - const mx = ax + dx * tm - const mz = az + dz * tm - const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) - ? includeBoundary - : pointInPolygon(mx, mz, polygon) - if (midpointInside) inside.push([t0, t1]) - } - return inside -} - -function polylineLength(points: Array<{ x: number; y: number }>): number { - let total = 0 - for (let i = 1; i < points.length; i++) { - total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) - } - return total -} - -/** - * Inside sub-intervals of a polyline against a polygon, in cumulative - * arc-length units from the polyline start (merged, disjoint). Boundary - * contact counts as inside for slab support (walls sit exactly on slab - * edges — see ON_BOUNDARY_EPSILON above); hole callers pass - * `includeBoundary: false` so a wall running along a stairwell hole's - * rim keeps the rim's support. - */ -function polylineInsideIntervals( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, - includeBoundary = true, -): LengthInterval[] { - const intervals: LengthInterval[] = [] - let offset = 0 - for (let i = 1; i < points.length; i++) { - const a = points[i - 1]! - const b = points[i]! - const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) - if (segmentLength < 1e-9) continue - for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { - intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) - } - offset += segmentLength - } - return mergeIntervals(intervals) -} - -function polylineInsideLength( - points: Array<{ x: number; y: number }>, - polygon: Array<[number, number]>, -): number { - return intervalsLength(polylineInsideIntervals(points, polygon)) -} - -export type WallOverlapInput = { - start: [number, number] - end: [number, number] - curveOffset?: number - thickness?: number -} - -// Minimum length of wall that must lie on/inside a slab polygon before the -// wall counts as overlapping it. Point contact (a perpendicular wall butting -// into a room's edge) clips to ~zero length and never reaches this, so such -// walls don't follow the slab's elevation. -const WALL_SLAB_MIN_OVERLAP = 0.05 - -/** - * Centerline of the wall plus its two face lines (centerline offset by - * ±halfThickness). The face lines catch walls whose centerline sits on or - * just outside the slab boundary but whose body reaches onto the slab — - * e.g. slab polygons drawn to the room's interior faces. - */ -function wallTestPolylines( - start: [number, number], - end: [number, number], - curveOffset: number, - halfThickness: number, -): Array> { - const wallLike = { start, end, curveOffset } - if (curveOffset !== 0 && isCurvedWall(wallLike)) { - const count = 16 - const center: Array<{ x: number; y: number }> = [] - const left: Array<{ x: number; y: number }> = [] - const right: Array<{ x: number; y: number }> = [] - for (let i = 0; i <= count; i++) { - const frame = getWallCurveFrameAt(wallLike, i / count) - center.push(frame.point) - left.push({ - x: frame.point.x + frame.normal.x * halfThickness, - y: frame.point.y + frame.normal.y * halfThickness, - }) - right.push({ - x: frame.point.x - frame.normal.x * halfThickness, - y: frame.point.y - frame.normal.y * halfThickness, - }) - } - return halfThickness > 0 ? [center, left, right] : [center] - } - - const center = [ - { x: start[0], y: start[1] }, - { x: end[0], y: end[1] }, - ] - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const len = Math.hypot(dx, dz) - if (len < 1e-10 || halfThickness <= 0) return [center] - const nx = (-dz / len) * halfThickness - const nz = (dx / len) * halfThickness - return [ - center, - [ - { x: start[0] + nx, y: start[1] + nz }, - { x: end[0] + nx, y: end[1] + nz }, - ], - [ - { x: start[0] - nx, y: start[1] - nz }, - { x: end[0] - nx, y: end[1] - nz }, - ], - ] -} - -/** - * Test whether a wall overlaps a slab polygon along a segment of its length. - * - * The wall's centerline and both face lines are clipped against the polygon; - * the wall overlaps when the longest clipped inside-or-on-boundary length - * exceeds a threshold (5cm, halved for very short walls). Because interval - * midpoints classify "on the boundary" as inside explicitly (never by - * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves - * identically on every side of the slab. - * - * A wall that only touches the polygon at a point — a perpendicular wall - * butting into a room's edge, or a corner-to-corner touch — clips to ~zero - * length and does NOT overlap. - */ -export function wallOverlapsPolygon( - startOrWall: [number, number] | WallOverlapInput, - endOrPolygon: [number, number] | Array<[number, number]>, - polygonArg?: Array<[number, number]>, -): boolean { - // Two call shapes: - // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware - // wallOverlapsPolygon(start, end, polygon) — legacy chord-only - let start: [number, number] - let end: [number, number] - let polygon: Array<[number, number]> - let curveOffset = 0 - let thickness = DEFAULT_WALL_THICKNESS - if (Array.isArray(startOrWall)) { - start = startOrWall as [number, number] - end = endOrPolygon as [number, number] - polygon = polygonArg as Array<[number, number]> - } else { - start = startOrWall.start - end = startOrWall.end - curveOffset = startOrWall.curveOffset ?? 0 - thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS - polygon = endOrPolygon as Array<[number, number]> - } - const halfThickness = Math.max(thickness / 2, 0) - - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const centerLength = polylineLength(polylines[0]!) - if (centerLength < 1e-9) return false - - let overlap = 0 - for (const line of polylines) { - overlap = Math.max(overlap, polylineInsideLength(line, polygon)) - } - const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) - return overlap >= threshold -} - -// A slab elevation must support at least this fraction of the wall's -// length before it can dictate the wall's base. Below majority, a raised -// slab reaching one endpoint would hoist the whole wall off the floor -// that actually carries it. -const WALL_SLAB_SUPPORT_MAJORITY = 0.5 - -// Slabs whose elevations differ by less than this pool their support: -// a wall shared between two rooms' slabs is covered roughly half by -// each, and must still follow their common elevation. -const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 - -/** - * Base elevation for a wall, decided by which slabs actually SUPPORT it. - * - * Support is measured as covered length: the wall's centerline and face - * lines are clipped against each slab's RENDERED footprint - * (`getRenderableSlabPolygon` with the level walls + siblings, not the - * stored polygon — legacy polygons stored at wall faces or with old - * baked offsets fall short of the wall body, but their band-adopted - * rendered edge reaches the wall's outer face) minus the slab's stored - * holes (holes are data, never render-offset). A slab supporting less - * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point - * contact, endpoint grazes). - * - * Same-elevation slabs pool their coverage. `elevation` preserves the - * existing wall-relative origin: the highest elevation covering at - * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered - * elevation when none reaches majority. `baseElevation` only fills down - * where a lower support remains exposed on a wall face after higher, - * overlapping support is accounted for. Coincident floor/platform slabs - * therefore keep the wall on the platform, while slabs on opposite wall - * sides bridge correctly. A slab touching only one endpoint never enters - * either result. Pure; - * exported for tests. - */ -export type WallSlabSupport = { - /** Existing wall-relative floor elevation used by hosted children and wall height. */ - elevation: number - /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ - baseElevation: number - /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ - baseSegments: WallSlabSupportSegment[] -} - -export type WallSlabSupportSegment = { - start: number - end: number +/** One slab overlapping a queried footprint, as seen by support election. */ +export type SlabSupportCandidate = { + slabId: string elevation: number } -export function computeWallSlabSupport( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): WallSlabSupport { - const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike - const halfThickness = Math.max(thickness / 2, 0) - const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) - const polylineLengths = polylines.map(polylineLength) - const wallLength = polylineLengths[0]! - if (wallLength < 1e-9) { - return { elevation: 0, baseElevation: 0, baseSegments: [] } - } - - const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) - - type ElevationGroup = { elevation: number; perPolyline: LengthInterval[][] } - const groups: ElevationGroup[] = [] - - for (const slab of slabs) { - if (slab.polygon.length < 3) continue - const renderedPolygon = getRenderableSlabPolygon(slab, { - walls: levelWalls, - siblingSlabs: slabs.filter((other) => other.id !== slab.id), - }) - - let supported = 0 - const perPolyline = polylines.map((line) => { - let intervals = polylineInsideIntervals(line, renderedPolygon) - for (const hole of slab.holes || []) { - if (intervals.length === 0) break - if (hole.length < 3) continue - intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) - } - supported = Math.max(supported, intervalsLength(intervals)) - return intervals - }) - if (supported < minSupport) continue - - const elevation = slab.elevation ?? 0.05 - let group = groups.find( - (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, - ) - if (!group) { - group = { elevation, perPolyline: polylines.map(() => []) } - groups.push(group) - } - for (let i = 0; i < perPolyline.length; i++) { - group.perPolyline[i]!.push(...perPolyline[i]!) - } - } - - type EvaluatedGroup = ElevationGroup & { - coverage: number - mergedPerPolyline: LengthInterval[][] - } - const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { - let coverage = 0 - const mergedPerPolyline = group.perPolyline.map(mergeIntervals) - for (let i = 0; i < group.perPolyline.length; i++) { - const lineLength = polylineLengths[i]! - if (lineLength < 1e-9) continue - coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) - } - return { ...group, coverage, mergedPerPolyline } - }) - - let majorityElevation = Number.NEGATIVE_INFINITY - let bestElevation = Number.NEGATIVE_INFINITY - let bestCoverage = -1 - for (const group of evaluatedGroups) { - if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { - majorityElevation = Math.max(majorityElevation, group.elevation) - } - if ( - group.coverage > bestCoverage + 1e-6 || - (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) - ) { - bestCoverage = group.coverage - bestElevation = group.elevation - } - } - - const elevation = - majorityElevation !== Number.NEGATIVE_INFINITY - ? majorityElevation - : bestElevation === Number.NEGATIVE_INFINITY - ? 0 - : bestElevation - const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { - const lineLength = polylineLengths[polylineIndex]! - if (lineLength < 1e-9) return [] - return group.mergedPerPolyline[polylineIndex]!.map( - ([intervalStart, intervalEnd]) => - [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, - ) - } - - const normalizedByGroup = evaluatedGroups.map((group) => ({ - elevation: group.elevation, - perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), - })) - const breakpoints = [0, 1] - for (const group of normalizedByGroup) { - for (const intervals of group.perPolyline) { - for (const [intervalStart, intervalEnd] of intervals) { - breakpoints.push(intervalStart, intervalEnd) - } - } - } - breakpoints.sort((left, right) => left - right) - const uniqueBreakpoints = breakpoints.filter( - (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, - ) - - const highestAt = (polylineIndex: number, t: number) => { - let highest = Number.NEGATIVE_INFINITY - for (const group of normalizedByGroup) { - if ( - group.perPolyline[polylineIndex]?.some( - ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, - ) - ) { - highest = Math.max(highest, group.elevation) - } - } - return highest - } - - const baseSegments: WallSlabSupportSegment[] = [] - for (let index = 1; index < uniqueBreakpoints.length; index++) { - const start = uniqueBreakpoints[index - 1]! - const end = uniqueBreakpoints[index]! - if (end - start < 1e-7) continue - const midpoint = (start + end) / 2 - const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY - const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY - const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) - const segmentElevation = - faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) - const previous = baseSegments[baseSegments.length - 1] - if ( - previous && - Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON - ) { - previous.end = end - } else { - baseSegments.push({ start, end, elevation: segmentElevation }) - } - } - - if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) - const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) - return { elevation, baseElevation, baseSegments } +export type ItemSlabSupport = { + elevation: number + /** The winning slab, or null when no slab overlaps the footprint. */ + slabId: string | null } -export function computeWallSlabElevation( - wallLike: WallOverlapInput, - slabs: readonly SlabNode[], - levelWalls: WallNode[], -): number { - return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +export type PointedSupportSurface = ItemSlabSupport & { + /** + * Level-local XZ where the ray meets the pointed surface's plane, or + * null when the ray never reaches it (grazing / aimed above the base). + * This is the plan point the pointer actually indicates: unlike a grid + * event-plane hit — whose XZ shifts with whatever height the event + * plane currently rides at — it depends only on the ray and the + * aimed-at surface, so election/preview at this point cannot flip when + * the event plane changes storey. + */ + point: [number, number] | null } export class SpatialGridManager { @@ -842,7 +358,24 @@ export class SpatialGridManager { private getWallHeight(wallId: string): number { const wall = this.walls.get(wallId) - return wall?.height ?? 2.5 // Default wall height + if (!wall) return 0 + if (wall.height != null) return wall.height + + const nodes = useScene.getState().nodes + const levelId = resolveNodeLevelId(wall, nodes) + const support = this.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight( + wall, + getWallPlaneTop(wall, levelId, nodes), + support.elevation, + ) } private getCeilingGrid(ceilingId: string): SpatialGrid { @@ -859,15 +392,74 @@ export class SpatialGridManager { return this.slabsByLevel.get(levelId)! } + /** + * Per-slab RENDERED polygon cache (`getRenderableSlabPolygon`). Item + * support queries run per frame and the projection scans the level's + * walls + sibling slabs, so the result is cached per slab id and + * dropped for the whole level whenever a slab or wall on that level + * flows through the manager's create/update/delete handlers. + */ + private readonly renderedSlabPolygons = new Map>() + + private invalidateRenderedSlabPolygons(levelId: string) { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return + for (const slabId of slabMap.keys()) this.renderedSlabPolygons.delete(slabId) + } + + private getRenderedSlabPolygon(levelId: string, slab: SlabNode): Array<[number, number]> { + const cached = this.renderedSlabPolygons.get(slab.id) + if (cached) return cached + + const siblingSlabs: SlabNode[] = [] + for (const other of this.getSlabMap(levelId).values()) { + if (other.id !== slab.id) siblingSlabs.push(other) + } + const polygon = getRenderableSlabPolygon(slab, { + walls: this.getLevelWallNodes(levelId), + siblingSlabs, + }) + this.renderedSlabPolygons.set(slab.id, polygon) + return polygon + } + + /** + * Support test shared by election, candidate listing, and persisted-host + * validation: the footprint overlaps the slab's RENDERED polygon (what + * users see — matching the wall election in `computeWallSlabSupport`), + * with the center-point hole veto kept against the stored holes (holes + * are data, never render-offset). + */ + private slabSupportsFootprint( + levelId: string, + slab: SlabNode, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): boolean { + if (slab.polygon.length < 3) return false + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (!itemOverlapsPolygon(position, dimensions, rotation, rendered, 0.01)) return false + + const [cx, , cz] = position + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) return false + } + return true + } + // Called when nodes change handleNodeCreated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + // Rendered slab polygons adopt wall bands — a new wall can extend them. + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -920,11 +512,13 @@ export class SpatialGridManager { handleNodeUpdated(node: AnyNode, levelId: string) { if (node.type === 'slab') { this.getSlabMap(levelId).set(node.id, node as SlabNode) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'ceiling') { this.ceilings.set(node.id, node as CeilingNode) } else if (node.type === 'wall') { const wall = node as WallNode this.walls.set(wall.id, wall) + this.invalidateRenderedSlabPolygons(levelId) } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { @@ -982,12 +576,16 @@ export class SpatialGridManager { handleNodeDeleted(nodeId: string, nodeType: string, levelId: string) { if (nodeType === 'slab') { + // Invalidate before removal so the deleted slab's own cache entry + // (still keyed in the level map here) is dropped with its siblings'. + this.invalidateRenderedSlabPolygons(levelId) this.getSlabMap(levelId).delete(nodeId) } else if (nodeType === 'ceiling') { this.ceilings.delete(nodeId) this.ceilingGrids.delete(nodeId) } else if (nodeType === 'wall') { this.walls.delete(nodeId) + this.invalidateRenderedSlabPolygons(levelId) // Remove all items attached to this wall from the spatial grid const removedItemIds = this.getWallGrid(levelId).removeWall(nodeId) return removedItemIds // Caller can use this to delete the items from scene @@ -1201,45 +799,162 @@ export class SpatialGridManager { /** * Get the slab elevation for an item using its full footprint (bounding box). - * Checks if any part of the item's rotated footprint overlaps with any slab polygon (excluding holes). - * Returns the highest overlapping slab elevation, or 0 if none. + * Thin wrapper over {@link getSlabSupportForItem} for callers (and tests) + * that only need the number. */ getSlabElevationForItem( levelId: string, position: [number, number, number], dimensions: [number, number, number], rotation: [number, number, number], + maxElevation?: number | null, ): number { - const slabMap = this.slabsByLevel.get(levelId) - if (!slabMap) return 0 + return this.getSlabSupportForItem(levelId, position, dimensions, rotation, maxElevation) + .elevation + } - let maxElevation = Number.NEGATIVE_INFINITY + /** + * Elect the supporting slab for a footprint: the highest-elevation slab + * whose RENDERED polygon the footprint overlaps (center-point hole veto + * applies). Returns `{ elevation: 0, slabId: null }` when nothing + * overlaps. + * + * `maxElevation` is the pointer-decided cap: when set, only slabs whose + * walking surface sits at or below `maxElevation + + * SUPPORT_ELEVATION_EPSILON` may win — a deck hanging above the surface + * the cursor ray actually hit never captures the election. + */ + getSlabSupportForItem( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + maxElevation?: number | null, + ): ItemSlabSupport { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return { elevation: 0, slabId: null } + + let winningElevation = Number.NEGATIVE_INFINITY + let winnerId: string | null = null for (const slab of slabMap.values()) { - if ( - slab.polygon.length >= 3 && - itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01) - ) { - // Check if item is entirely within a hole (if so, ignore this slab) - // We consider it entirely in a hole if the item center is in the hole + const elevation = slab.elevation ?? 0.05 + if (maxElevation != null && elevation > maxElevation + SUPPORT_ELEVATION_EPSILON) continue + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + if (elevation > winningElevation) { + winningElevation = elevation + winnerId = slab.id + } + } + return winnerId === null + ? { elevation: 0, slabId: null } + : { elevation: winningElevation, slabId: winnerId } + } + + /** + * The walking surface the pointer actually points at: the nearest slab + * plane the ray crosses INSIDE that slab's rendered polygon (hole veto + * applies), or the level base (`elevation: 0, slabId: null`) when it + * crosses none. Ray origin/direction are level-local. Deliberately a + * point test, not a footprint test — it answers "which surface is under + * the cursor", which then caps the footprint election so a deck hanging + * above the aimed-at floor never lifts the placement. `point` is the + * ray's crossing of that surface's plane — the stable plan point + * callers should elect/preview at (see {@link PointedSupportSurface}). + */ + getPointedSupportSurface( + levelId: string, + rayOrigin: [number, number, number], + rayDirection: [number, number, number], + ): PointedSupportSurface { + const slabMap = this.slabsByLevel.get(levelId) + const [ox, oy, oz] = rayOrigin + const [dx, dy, dz] = rayDirection + if (Math.abs(dy) < 1e-9) return { elevation: 0, slabId: null, point: null } + + let best: { t: number; elevation: number; slabId: string } | null = null + if (slabMap) { + for (const slab of slabMap.values()) { + if (slab.polygon.length < 3) continue + const elevation = slab.elevation ?? 0.05 + const t = (elevation - oy) / dy + if (t <= 0) continue + if (best && t >= best.t) continue + const x = ox + dx * t + const z = oz + dz * t + const rendered = this.getRenderedSlabPolygon(levelId, slab) + if (rendered.length < 3 || !pointInPolygon(x, z, rendered)) continue let inHole = false - const [cx, , cz] = position - const holes = slab.holes || [] - for (const hole of holes) { - if (hole.length >= 3 && pointInPolygon(cx, cz, hole)) { + for (const hole of slab.holes || []) { + if (hole.length >= 3 && pointInPolygon(x, z, hole)) { inHole = true break } } - - if (!inHole) { - const elevation = slab.elevation ?? 0.05 - if (elevation > maxElevation) { - maxElevation = elevation - } - } + if (inHole) continue + best = { t, elevation, slabId: slab.id } } } - return maxElevation === Number.NEGATIVE_INFINITY ? 0 : maxElevation + if (best) { + return { + elevation: best.elevation, + slabId: best.slabId, + point: [ox + dx * best.t, oz + dz * best.t], + } + } + const tBase = -oy / dy + return { + elevation: 0, + slabId: null, + point: tBase > 0 ? [ox + dx * tBase, oz + dz * tBase] : null, + } + } + + /** + * All slabs supporting a footprint, one entry per overlapping slab + * (highest elevation first; slab id breaks ties deterministically). + * Commit-side ambiguity check: persist a `supportSlabId` only when the + * candidates carry ≥ 2 distinct elevations. + */ + getSupportCandidatesForFootprint( + levelId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): SlabSupportCandidate[] { + const slabMap = this.slabsByLevel.get(levelId) + if (!slabMap) return [] + + const candidates: SlabSupportCandidate[] = [] + for (const slab of slabMap.values()) { + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) continue + candidates.push({ slabId: slab.id, elevation: slab.elevation ?? 0.05 }) + } + candidates.sort( + (a, b) => + b.elevation - a.elevation || (a.slabId < b.slabId ? -1 : a.slabId > b.slabId ? 1 : 0), + ) + return candidates + } + + /** + * Elevation of a persisted support host for a footprint, or null when + * the slab no longer exists on the level or no longer overlaps the + * footprint (same overlap test as election). Deliberately read-only: a + * host reshaped away is NOT cleared — callers fall back to election and + * the stale reference resumes hosting if the slab's polygon returns. + * Slab deletion is the only writer (`deleteNodesAction` strips it). + */ + getHostSlabElevationForFootprint( + levelId: string, + slabId: string, + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ): number | null { + const slab = this.slabsByLevel.get(levelId)?.get(slabId) + if (!slab) return null + if (!this.slabSupportsFootprint(levelId, slab, position, dimensions, rotation)) return null + return slab.elevation ?? 0.05 } /** @@ -1255,8 +970,10 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, ): number { - return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness).elevation + return this.getSlabSupportForWall(levelId, start, end, curveOffset, thickness, preferredSlabId) + .elevation } getSlabSupportForWall( @@ -1265,11 +982,14 @@ export class SpatialGridManager { end: [number, number], curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS, + preferredSlabId?: string | null, + maxElevation?: number | null, ): WallSlabSupport { const slabMap = this.slabsByLevel.get(levelId) if (!slabMap) { return { elevation: 0, + electedSlabId: null, baseElevation: 0, baseSegments: [{ start: 0, end: 1, elevation: 0 }], } @@ -1279,6 +999,8 @@ export class SpatialGridManager { { start, end, curveOffset, thickness }, [...slabMap.values()], this.getLevelWallNodes(levelId), + preferredSlabId, + maxElevation, ) } @@ -1387,6 +1109,7 @@ export class SpatialGridManager { } clearLevel(levelId: string) { + this.invalidateRenderedSlabPolygons(levelId) this.floorGrids.delete(levelId) this.wallGrids.delete(levelId) this.slabsByLevel.delete(levelId) @@ -1400,8 +1123,33 @@ export class SpatialGridManager { this.ceilingGrids.clear() this.ceilings.clear() this.itemCeilingMap.clear() + this.renderedSlabPolygons.clear() } } // Singleton instance export const spatialGridManager = new SpatialGridManager() + +/** + * Effective (extruded) height of a wall resolved from a nodes record: + * {@link resolveWallEffectiveHeight} over the covering-clamped plane top + * (`getWallPlaneTop`) and the singleton manager's slab election — so the + * value always agrees with the rendered wall. One shared resolver for the + * editor overlays (measurement label, action menu, side handles) that used + * to copy this derivation locally. + */ +export function getWallEffectiveHeightForNodes( + wall: WallNode, + nodes: Record, +): number { + const levelId = resolveNodeLevelId(wall, nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId ?? null, + ) + return resolveWallEffectiveHeight(wall, getWallPlaneTop(wall, levelId, nodes), support.elevation) +} diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts new file mode 100644 index 00000000..280db599 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { spatialGridManager } from './spatial-grid-manager' +import { + initSpatialGridSync, + markCoveringDependentsBelow, + markLevelHeightDependents, +} from './spatial-grid-sync' + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function makeLevel(id: string, ordinal: number, height: number, children: string[]): AnyNode { + return { + id, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: ordinal, + height, + } as AnyNode +} + +function makeChild(id: string, type: string, parentId: string): AnyNode { + return { + id, + type, + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + start: [0, 1], + end: [4, 1], + thickness: 0.1, + polygon: SQUARE, + holes: [], + } as unknown as AnyNode +} + +function makeSlab(id: string, parentId: string, overrides: Partial = {}): AnyNode { + return { + id, + type: 'slab', + object: 'node', + parentId, + visible: true, + metadata: {}, + children: [], + polygon: SQUARE, + holes: [], + holeMetadata: [], + elevation: 0.05, + thickness: 0.05, + autoFromWalls: false, + ...overrides, + } as AnyNode +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +function dirtyIds(): string[] { + return [...useScene.getState().dirtyNodes].sort() +} + +describe('spatial-grid sync dirty rules (vertical model)', () => { + let stopSync = () => {} + + // Two orphan levels sharing the legacy stack: level_0 (below) carries a + // wall, ceiling, stair, fence, and zone; level_1 (above) carries a slab. + const wall = makeChild('wall_a', 'wall', 'level_0') + const ceiling = makeChild('ceiling_a', 'ceiling', 'level_0') + const stair = makeChild('stair_a', 'stair', 'level_0') + const fence = makeChild('fence_a', 'fence', 'level_0') + const zone = makeChild('zone_a', 'zone', 'level_0') + const upperSlab = makeSlab('slab_up', 'level_1', { elevation: 0, thickness: 0.3 }) + const level0 = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'ceiling_a', + 'stair_a', + 'fence_a', + 'zone_a', + ]) + const level1 = makeLevel('level_1', 1, 2.5, ['slab_up']) + + function setScene(nodes: Record) { + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes, + readOnly: false, + rootNodeIds: ['level_0', 'level_1'] as AnyNodeId[], + } as never) + clearSceneHistory() + } + + beforeEach(() => { + spatialGridManager.clear() + setScene(nodesFor(level0, level1, wall, ceiling, stair, fence, zone, upperSlab)) + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a level height marks its wall/stair/ceiling/fence children dirty', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + level_0: { ...level0, height: 3 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('a slab thickness change marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, thickness: 0.5 } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('a slab recessed toggle marks the walls and ceilings of the level below', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_up: { ...upperSlab, recessed: true } as AnyNode, + } as never, + }) + + expect(dirtyIds()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('creating a slab on the level above marks the level below, deleting it too', () => { + const added = makeSlab('slab_new', 'level_1', { elevation: 0, thickness: 0.2 }) + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_new: added, + level_1: { ...level1, children: ['slab_up', 'slab_new'] } as AnyNode, + } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + + useScene.setState({ dirtyNodes: new Set() }) + const { slab_new: _gone, ...rest } = useScene.getState().nodes as Record + useScene.setState({ + nodes: { ...rest, level_1: { ...level1, children: ['slab_up'] } as AnyNode } as never, + }) + expect(useScene.getState().dirtyNodes.has('wall_a' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('ceiling_a' as AnyNodeId)).toBe(true) + }) +}) + +describe('spatial-grid sync dirty rules (deck-attached stairs)', () => { + let stopSync = () => {} + + const deck = makeSlab('slab_deck', 'level_0', { elevation: 1.25, thickness: 0.05 }) + const attachedStair = { + ...makeChild('stair_deck', 'stair', 'level_0'), + deckSlabId: 'slab_deck', + } as AnyNode + const otherStair = makeChild('stair_other', 'stair', 'level_0') + const deckLevel = makeLevel('level_0', 0, 2.5, ['slab_deck', 'stair_deck', 'stair_other']) + + beforeEach(() => { + spatialGridManager.clear() + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(deckLevel, deck, attachedStair, otherStair), + readOnly: false, + rootNodeIds: ['level_0'] as AnyNodeId[], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + useScene.setState({ dirtyNodes: new Set() }) + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + test('changing a deck elevation marks its attached stair dirty, not other stairs', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { ...deck, elevation: 1.6 } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(true) + expect(useScene.getState().dirtyNodes.has('stair_other' as AnyNodeId)).toBe(false) + }) + + test('a deck polygon-only change leaves the attached stair alone', () => { + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + slab_deck: { + ...deck, + polygon: [ + [0, 0], + [5, 0], + [5, 5], + [0, 5], + ], + } as AnyNode, + } as never, + }) + + expect(useScene.getState().dirtyNodes.has('stair_deck' as AnyNodeId)).toBe(false) + }) +}) + +describe('sync dirty helpers (pure)', () => { + const collect = () => { + const marked: string[] = [] + return { marked, markDirty: (id: AnyNodeId) => marked.push(id) } + } + + test('markLevelHeightDependents marks only wall/stair/ceiling/fence children', () => { + const level = makeLevel('level_0', 0, 2.5, [ + 'wall_a', + 'stair_a', + 'ceiling_a', + 'fence_a', + 'zone_a', + 'missing', + ]) + const nodes = nodesFor( + level, + makeChild('wall_a', 'wall', 'level_0'), + makeChild('stair_a', 'stair', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('fence_a', 'fence', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markLevelHeightDependents(level as never, nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'fence_a', 'stair_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow marks walls and ceilings of the level below only', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a', 'ceiling_a', 'zone_a']), + makeLevel('level_1', 1, 2.5, []), + makeChild('wall_a', 'wall', 'level_0'), + makeChild('ceiling_a', 'ceiling', 'level_0'), + makeChild('zone_a', 'zone', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_1', nodes, markDirty) + expect(marked.sort()).toEqual(['ceiling_a', 'wall_a']) + }) + + test('markCoveringDependentsBelow is a no-op for the lowest level', () => { + const nodes = nodesFor( + makeLevel('level_0', 0, 2.5, ['wall_a']), + makeChild('wall_a', 'wall', 'level_0'), + ) + + const { marked, markDirty } = collect() + markCoveringDependentsBelow('level_0', nodes, markDirty) + expect(marked).toEqual([]) + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts index 8f37156e..886d5970 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts @@ -1,6 +1,7 @@ import { getRenderableSlabPolygon } from '../../lib/slab-polygon' import { nodeRegistry } from '../../registry' -import type { AnyNode, AnyNodeId, SlabNode, WallNode } from '../../schema' +import type { AnyNode, AnyNodeId, LevelNode, SlabNode, WallNode } from '../../schema' +import { getLevelBelow } from '../../services/storey' import useScene from '../../store/use-scene' import { getFloorPlacedFootprints } from './floor-placed-elevation' import { @@ -116,6 +117,7 @@ export function initSpatialGridSync(): () => void { // When a slab is added, mark overlapping items/walls dirty if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -129,6 +131,7 @@ export function initSpatialGridSync(): () => void { // When a slab is removed, mark items/walls that were on it dirty (using current state) if (node.type === 'slab') { markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) + markCoveringDependentsBelow(levelId, state.nodes, markDirty) } } } @@ -156,11 +159,11 @@ export function initSpatialGridSync(): () => void { } } } else if (node.type === 'slab' && prev.type === 'slab') { - if ( + const supportChanged = node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes - ) { + if (supportChanged) { const levelId = resolveLevelId(node, state.nodes) spatialGridManager.handleNodeUpdated(node, levelId) @@ -168,6 +171,35 @@ export function initSpatialGridSync(): () => void { markNodesOverlappingSlab(prev as SlabNode, state.nodes, markDirty) markNodesOverlappingSlab(node as SlabNode, state.nodes, markDirty) } + if (node.elevation !== prev.elevation) { + markDeckAttachedStairs(node.id, state.nodes, markDirty) + } + // The covering bound over the level below also moves with thickness + // (underside = elevation − thickness) and recessed (pools never + // cover), which same-level support ignores. + if ( + supportChanged || + node.thickness !== prev.thickness || + node.recessed !== prev.recessed + ) { + markCoveringDependentsBelow(resolveLevelId(node, state.nodes), state.nodes, markDirty) + } + } else if (node.type === 'level' && prev.type === 'level') { + if (node.height !== prev.height) { + markLevelHeightDependents(node as LevelNode, state.nodes, markDirty) + } + } else if (node.type === 'wall' && prev.type === 'wall') { + if ( + node.start !== prev.start || + node.end !== prev.end || + node.curveOffset !== prev.curveOffset || + node.thickness !== prev.thickness + ) { + // Rendered slab polygons adopt wall bands, so a wall reshape + // must reach the manager to refresh its wall map and drop the + // level's rendered-polygon cache. + spatialGridManager.handleNodeUpdated(node, resolveLevelId(node, state.nodes)) + } } } }) @@ -179,6 +211,68 @@ function arraysEqual(a: number[], b: number[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]) } +/** + * A level's stored height moved: plane-bound walls follow the new plane, + * stair rise re-derives, and ceilings/fences re-resolve their clamp — mark + * them all so their systems rebuild. Restacking the level containers alone + * leaves their geometry stale. + */ +export function markLevelHeightDependents( + level: LevelNode, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const childId of level.children) { + const child = nodes[childId] + if (!child) continue + if ( + child.type === 'wall' || + child.type === 'stair' || + child.type === 'ceiling' || + child.type === 'fence' + ) { + markDirty(child.id) + } + } +} + +/** + * A deck slab's walking surface moved: stairs attached to it via + * `deckSlabId` derive their rise from that elevation, so their geometry + * (and rise-derived affordances) must rebuild. + */ +export function markDeckAttachedStairs( + slabId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + for (const node of Object.values(nodes)) { + if (node.type === 'stair' && node.deckSlabId === slabId) { + markDirty(node.id) + } + } +} + +/** + * A slab on `slabLevelId` was created/deleted or changed shape/placement: + * the covering bound (slab underside) over the level BELOW moved, so that + * level's plane-bound walls and clamped ceilings must rebuild. + */ +export function markCoveringDependentsBelow( + slabLevelId: string, + nodes: Record, + markDirty: (id: AnyNodeId) => void, +) { + const below = getLevelBelow(slabLevelId, nodes) + if (!below) return + for (const childId of below.children) { + const child = nodes[childId] + if (child?.type === 'wall' || child?.type === 'ceiling') { + markDirty(child.id) + } + } +} + /** * Mark all floor items and walls that may be affected by a slab change as dirty. */ @@ -190,10 +284,11 @@ function markNodesOverlappingSlab( if (slab.polygon.length < 3) return const slabLevelId = resolveLevelId(slab, nodes) - // Walls follow the slab's RENDERED footprint (band-adopted edges reach - // the wall's outer face), so the dirty gate must test the same polygon - // `getSlabElevationForWall` will re-evaluate — a stored polygon that - // stops short of the wall body would otherwise never re-elevate it. + // Walls AND floor-placed nodes follow the slab's RENDERED footprint + // (band-adopted edges reach the wall's outer face), so the dirty gate + // must test the same polygon the support queries re-evaluate — a stored + // polygon that stops short of the wall body would otherwise never + // re-elevate nodes sitting over the adopted band. const levelWalls: WallNode[] = [] const siblingSlabs: SlabNode[] = [] for (const node of Object.values(nodes)) { @@ -249,7 +344,7 @@ function markNodesOverlappingSlab( footprint.position ?? position, footprint.dimensions, footprint.rotation, - slab.polygon, + renderedPolygon, 0.01, ) ) { diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts new file mode 100644 index 00000000..588ca7ee --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -0,0 +1,217 @@ +import { nodeRegistry } from '../../registry' +import type { AnyNode, AnyNodeId, FenceNode, SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../../systems/wall/wall-curve' +import { GROUND_SUPPORT_ID, getFloorPlacedFootprints } from './floor-placed-elevation' +import { SUPPORT_ELEVATION_EPSILON, spatialGridManager } from './spatial-grid-manager' + +export type SupportSlabPatch = { supportSlabId: string | undefined } + +export type SupportSlabPatchOptions = { + /** + * Pointer-decided support cap (level-local Y) — see + * `FloorPlacedElevationArgs.maxElevation`. When set, the persisted host + * reproduces the CAPPED election: the elected lower slab wins over a + * deck hanging above the cap, and `GROUND_SUPPORT_ID` is stored when the + * ground is elected while capped-out slabs still overlap the footprint. + */ + maxElevation?: number | null +} + +export function resolveSupportSlabPatch( + node: AnyNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) { + return { supportSlabId: undefined } + } + + const parentId = (node as { parentId?: AnyNodeId | null }).parentId ?? null + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const footprints = getFloorPlacedFootprints(floorPlaced, node, { nodes }) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + let cappedOut = false + + for (const footprint of footprints) { + const position = footprint.position ?? (node as { position?: unknown }).position + if (!Array.isArray(position) || position.length !== 3) continue + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position as [number, number, number], + footprint.dimensions, + footprint.rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + if (maxElevation != null && support.slabId === null && candidates.length > 0) { + cappedOut = true + } + } + + if (winner !== null) { + return { supportSlabId: candidateElevations.size >= 2 ? winner.slabId : undefined } + } + // Capped election chose the ground while overlapping slabs sit above the + // cap: persist the ground host, or the uncapped per-frame election would + // lift the committed node back onto the deck. + return { supportSlabId: cappedOut ? GROUND_SUPPORT_ID : undefined } +} + +export function resolveWallSupportSlabPatch( + wall: WallNode, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = wall.parentId ? nodes[wall.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + // Winner under the pointer cap (when given): a deck hanging above the + // aimed-at surface can't capture the elected base, so a wall drawn at the + // floor underneath it persists the floor slab the user actually targeted. + const support = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + null, + options?.maxElevation, + ) + const candidateElevations = new Set() + for (const node of Object.values(nodes)) { + if (node.type !== 'slab' || node.parentId !== parent.id) continue + const candidate = node as SlabNode + const preferred = spatialGridManager.getSlabSupportForWall( + parent.id, + wall.start, + wall.end, + wall.curveOffset, + wall.thickness, + candidate.id, + ) + if (preferred.electedSlabId === candidate.id) { + candidateElevations.add(candidate.elevation) + } + } + + return { + supportSlabId: candidateElevations.size >= 2 ? (support.electedSlabId ?? undefined) : undefined, + } +} + +/** Fence-like shape the fence host election needs — plain segment, arc, or spline. */ +export type FenceSupportInput = Pick< + FenceNode, + 'start' | 'end' | 'curveOffset' | 'path' | 'thickness' | 'parentId' +> + +/** Sample count for a curved (sagitta) fence centerline, matching the wall band test. */ +const FENCE_CURVE_SUPPORT_SAMPLES = 16 +/** Fallback fence thickness (schema default) when the node carries none. */ +const DEFAULT_FENCE_THICKNESS = 0.08 +/** Minimum band depth so the footprint survives the election's polygon inset. */ +const MIN_FENCE_SUPPORT_BAND = 0.05 + +function fenceCenterlinePoints(fence: FenceSupportInput): Array<[number, number]> { + if (fence.path && fence.path.length >= 2) { + return fence.path.map((point) => [point[0], point[1]]) + } + const wallLike = { start: fence.start, end: fence.end, curveOffset: fence.curveOffset ?? 0 } + if ((fence.curveOffset ?? 0) !== 0 && isCurvedWall(wallLike)) { + const points: Array<[number, number]> = [] + for (let i = 0; i <= FENCE_CURVE_SUPPORT_SAMPLES; i++) { + const frame = getWallCurveFrameAt(wallLike, i / FENCE_CURVE_SUPPORT_SAMPLES) + points.push([frame.point.x, frame.point.y]) + } + return points + } + return [ + [fence.start[0], fence.start[1]], + [fence.end[0], fence.end[1]], + ] +} + +/** + * Support-host patch for a fence: elect the slab the fence line stands on + * and persist it as `supportSlabId` (the fence lift resolves absent = + * level floor — see `packages/nodes/src/fence/lift.ts`). + * + * The centerline (chord, sampled arc, or spline path) is turned into thin + * band footprints and run through the same candidate machinery items use. + * `options.maxElevation` is the pointer-decided cap: aiming at the floor + * under a deck elects the floor, aiming at the deck top elects the deck. + * + * Persist rule: the items ambiguity rule (stacked candidates disagree) + * PLUS the elevated-host case — a winner sitting meaningfully above the + * level floor must be persisted even when unambiguous (a balcony deck with + * nothing underneath), or the commit loses the election entirely since + * fences run no per-frame election. A single default ground slab (its top + * within `SUPPORT_ELEVATION_EPSILON` of the floor) stays unpersisted so + * plain fences keep sitting at the level base. A capped-out election (all + * overlapping slabs above the aimed-at ground) also resolves to the floor + * via the same absent-host default. Pure; exported for tests. + */ +export function resolveFenceSupportSlabPatch( + fence: FenceSupportInput, + nodes: Record, + options?: SupportSlabPatchOptions, +): SupportSlabPatch { + const parent = fence.parentId ? nodes[fence.parentId] : null + if (parent?.type !== 'level') return { supportSlabId: undefined } + + const maxElevation = options?.maxElevation + const band = Math.max(fence.thickness ?? DEFAULT_FENCE_THICKNESS, MIN_FENCE_SUPPORT_BAND) + const points = fenceCenterlinePoints(fence) + const candidateElevations = new Set() + let winner: { slabId: string; elevation: number } | null = null + + for (let i = 1; i < points.length; i++) { + const [ax, az] = points[i - 1]! + const [bx, bz] = points[i]! + const length = Math.hypot(bx - ax, bz - az) + if (length < 1e-6) continue + const position: [number, number, number] = [(ax + bx) / 2, 0, (az + bz) / 2] + const dimensions: [number, number, number] = [length, 1, band] + // getItemFootprint's rotation convention: local +X maps to + // (cos yRot, sin yRot) in XZ, so the segment angle aligns the band. + const rotation: [number, number, number] = [0, Math.atan2(bz - az, bx - ax), 0] + + const candidates = spatialGridManager.getSupportCandidatesForFootprint( + parent.id, + position, + dimensions, + rotation, + ) + for (const candidate of candidates) candidateElevations.add(candidate.elevation) + + const support = spatialGridManager.getSlabSupportForItem( + parent.id, + position, + dimensions, + rotation, + maxElevation, + ) + if (support.slabId && (!winner || support.elevation > winner.elevation)) { + winner = { slabId: support.slabId, elevation: support.elevation } + } + } + + if (winner === null) return { supportSlabId: undefined } + const persist = candidateElevations.size >= 2 || winner.elevation > SUPPORT_ELEVATION_EPSILON + return { supportSlabId: persist ? winner.slabId : undefined } +} diff --git a/packages/core/src/hooks/spatial-grid/support-host.test.ts b/packages/core/src/hooks/spatial-grid/support-host.test.ts new file mode 100644 index 00000000..88ed9453 --- /dev/null +++ b/packages/core/src/hooks/spatial-grid/support-host.test.ts @@ -0,0 +1,628 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { z } from 'zod' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, AnyNodeId, SlabNode } from '../../schema' +import { WallNode } from '../../schema' +import useScene, { clearSceneHistory } from '../../store/use-scene' +import { resolveWallEffectiveHeight, resolveWallTop } from '../../systems/wall/wall-top' +import { getFloorPlacedElevation } from './floor-placed-elevation' +import { spatialGridManager } from './spatial-grid-manager' +import { initSpatialGridSync } from './spatial-grid-sync' +import { resolveSupportSlabPatch, resolveWallSupportSlabPatch } from './support-host-patch' + +const LEVEL_ID = 'level_test' + +const SQUARE: Array<[number, number]> = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], +] + +function makeDefinition( + kind: AnyNode['type'], + capabilities: AnyNodeDefinition['capabilities'] = {}, +): AnyNodeDefinition { + return { + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + capabilities, + } +} + +function registerFloorPlacedItem() { + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: () => ({ dimensions: [1, 1, 1], rotation: [0, 0, 0] }), + }, + }), + ) +} + +function makeLevel(children: string[] = []): AnyNode { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children, + level: 0, + } as AnyNode +} + +function makeFloorNode(overrides: Partial = {}): AnyNode { + return { + id: 'item_test', + type: 'item', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + asset: { + id: 'asset_test', + category: 'test', + name: 'Test', + thumbnail: '', + src: 'asset:test', + dimensions: [1, 1, 1], + source: 'library', + }, + ...overrides, + } as AnyNode +} + +function makeSlab( + id: string, + polygon: Array<[number, number]>, + elevation: number, + overrides: Partial = {}, +): SlabNode { + return { + id, + type: 'slab', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + polygon, + holes: [], + holeMetadata: [], + elevation, + autoFromWalls: false, + ...overrides, + } as SlabNode +} + +function addSlab(slab: SlabNode) { + spatialGridManager.handleNodeCreated(slab as AnyNode, LEVEL_ID) +} + +function nodesFor(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((node) => [node.id, node])) +} + +describe('persisted support hosts (items)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('no-host election over stacked slabs keeps returning the highest elevation', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode() + + expect( + getFloorPlacedElevation({ + node, + nodes: nodesFor(level, node), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a persisted host wins over the election, whichever slab it names', () => { + registerFloorPlacedItem() + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const hostedLow = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const hostedHigh = makeFloorNode({ supportSlabId: 'slab_high' } as Partial) + + expect( + getFloorPlacedElevation({ + node: hostedLow, + nodes: nodesFor(level, hostedLow), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.2) + expect( + getFloorPlacedElevation({ + node: hostedHigh, + nodes: nodesFor(level, hostedHigh), + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + ).toBeCloseTo(0.8) + }) + + test('a host reshaped away falls back without clearing the field, and resumes on return', () => { + registerFloorPlacedItem() + const host = makeSlab('slab_low', SQUARE, 0.2) + addSlab(host) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + const level = makeLevel() + const node = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const args = { + node, + nodes: nodesFor(level, node), + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } + + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + + // Reshape the host away from the item's footprint. + const movedAway: Array<[number, number]> = [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ] + spatialGridManager.handleNodeUpdated(makeSlab('slab_low', movedAway, 0.2) as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.8) + expect((node as { supportSlabId?: string }).supportSlabId).toBe('slab_low') + + // Reshape it back — the stale reference resumes hosting. + spatialGridManager.handleNodeUpdated(host as AnyNode, LEVEL_ID) + expect(getFloorPlacedElevation(args)).toBeCloseTo(0.2) + }) + + test('getSlabSupportForItem surfaces the winning slab id', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [0, 0, 0], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0.8, slabId: 'slab_high' }) + expect( + spatialGridManager.getSlabSupportForItem(LEVEL_ID, [20, 0, 20], [1, 1, 1], [0, 0, 0]), + ).toEqual({ elevation: 0, slabId: null }) + }) + + test('getSupportCandidatesForFootprint lists distinct overlapping slabs, highest first', () => { + addSlab(makeSlab('slab_low', SQUARE, 0.2)) + addSlab(makeSlab('slab_high', SQUARE, 0.8)) + addSlab( + makeSlab( + 'slab_far', + [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + 0.5, + ), + ) + + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [0, 0, 0], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([ + { slabId: 'slab_high', elevation: 0.8 }, + { slabId: 'slab_low', elevation: 0.2 }, + ]) + expect( + spatialGridManager.getSupportCandidatesForFootprint( + LEVEL_ID, + [20, 0, 20], + [1, 1, 1], + [0, 0, 0], + ), + ).toEqual([]) + }) + + test('resolveSupportSlabPatch persists only an ambiguous stacked-slab winner', () => { + registerFloorPlacedItem() + const low = makeSlab('slab_low', SQUARE, 0.2) + const high = makeSlab('slab_high', SQUARE, 0.8) + addSlab(low) + addSlab(high) + + const level = makeLevel() + const node = makeFloorNode() + const nodes = nodesFor(level, node, low as AnyNode, high as AnyNode) + expect(resolveSupportSlabPatch(node, nodes)).toEqual({ supportSlabId: 'slab_high' }) + + spatialGridManager.handleNodeDeleted(high.id, 'slab', LEVEL_ID) + expect(resolveSupportSlabPatch(node, nodesFor(level, node, low as AnyNode))).toEqual({ + supportSlabId: undefined, + }) + }) + + test('item support follows the RENDERED slab polygon (wall band adoption)', () => { + registerFloorPlacedItem() + + // Room slab drawn on the wall centerlines; the rendered polygon + // extends to the walls' outer faces (x/z ± 0.05 for 0.1-thick walls). + const roomPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 0], end: [4, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [4, 3], end: [0, 3], thickness: 0.1, parentId: LEVEL_ID }), + WallNode.parse({ start: [0, 3], end: [0, 0], thickness: 0.1, parentId: LEVEL_ID }), + ] + const level = makeLevel(walls.map((wall) => wall.id)) + const node = makeFloorNode() + useScene.setState({ nodes: nodesFor(level, node, ...(walls as AnyNode[])) }) + // Grounded raised floor (thickness = elevation): band adoption only + // applies to grounded slabs — a floating deck keeps its drawn polygon. + addSlab(makeSlab('slab_room', roomPolygon, 0.4, { thickness: 0.4 })) + + // Footprint fully outside the STORED polygon (x from 4.0 to 4.6 with a + // 0.01 overlap inset) but inside the rendered band edge at x = 4.05. + const elevation = spatialGridManager.getSlabElevationForItem( + LEVEL_ID, + [4.3, 0, 1.5], + [0.6, 1, 0.6], + [0, 0, 0], + ) + expect(elevation).toBeCloseTo(0.4) + + // The manager sees wall changes: removing the walls drops the adopted + // band, so the same footprint stops electing the slab. + for (const wall of walls) { + spatialGridManager.handleNodeDeleted(wall.id, 'wall', LEVEL_ID) + } + useScene.setState({ nodes: nodesFor(makeLevel(), node) }) + expect( + spatialGridManager.getSlabElevationForItem(LEVEL_ID, [4.3, 0, 1.5], [0.6, 1, 0.6], [0, 0, 0]), + ).toBe(0) + }) +}) + +describe('persisted support hosts (walls, via the manager)', () => { + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('preferred slab pins the elected elevation; invalid preference falls back', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ] + addSlab(makeSlab('slab_low', polygon, 0.1)) + addSlab(makeSlab('slab_high', polygon, 0.6)) + + const start: [number, number] = [0, 1.5] + const end: [number, number] = [4, 1.5] + + const elected = spatialGridManager.getSlabSupportForWall(LEVEL_ID, start, end) + expect(elected.elevation).toBeCloseTo(0.6) + expect(elected.electedSlabId).toBe('slab_high') + + const preferred = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_low', + ) + expect(preferred.elevation).toBeCloseTo(0.1) + expect(preferred.electedSlabId).toBe('slab_low') + + const fallback = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + start, + end, + 0, + 0.1, + 'slab_missing', + ) + expect(fallback.elevation).toBeCloseTo(0.6) + expect(fallback.electedSlabId).toBe('slab_high') + }) + + test('resolveWallSupportSlabPatch persists the winner over two elevations', () => { + const low = makeSlab( + 'slab_low', + [ + [-2, -1], + [0, -1], + [0, 1], + [-2, 1], + ], + 0.2, + ) + const high = makeSlab( + 'slab_high', + [ + [0, -1], + [2, -1], + [2, 1], + [0, 1], + ], + 0.8, + ) + const wall = WallNode.parse({ + id: 'wall_test', + parentId: LEVEL_ID, + start: [-2, 0], + end: [2, 0], + thickness: 0.1, + }) + const level = makeLevel([low.id, high.id, wall.id]) + const nodes = nodesFor(level, low as AnyNode, high as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(low) + addSlab(high) + + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ + supportSlabId: 'slab_high', + }) + }) + + // Elevated deck stacked over a ground floor slab — the "wall on a deck" + // fixture (both slabs cover the wall band; the deck sits above). + const DECK_ELEVATION = 0.9 + const FLOOR_ELEVATION = 0.05 + function makeDeckOverFloorFixture() { + const deck = makeSlab( + 'slab_deck', + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + DECK_ELEVATION, + ) + const ground = makeSlab( + 'slab_ground', + [ + [-6, -6], + [6, -6], + [6, 6], + [-6, 6], + ], + FLOOR_ELEVATION, + ) + const wall = WallNode.parse({ + id: 'wall_on_deck', + parentId: LEVEL_ID, + start: [0.5, 1.5], + end: [3.5, 1.5], + thickness: 0.1, + }) + const level = makeLevel([deck.id, ground.id, wall.id]) + const nodes = nodesFor(level, deck as AnyNode, ground as AnyNode, wall as AnyNode) + useScene.setState({ nodes }) + addSlab(deck) + addSlab(ground) + return { wall, nodes } + } + + test('a wall whose band lies over an elevated deck bases on the deck with a plane-bound top', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const support = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + ) + expect(support.electedSlabId).toBe('slab_deck') + expect(support.elevation).toBeCloseTo(DECK_ELEVATION) + + // Wall-top inversion: no stored height → the top stays at the storey + // plane, so the extruded body is the plane minus the deck base. + const storeyHeight = 2.7 + expect(resolveWallTop(wall, storeyHeight, support.elevation)).toBeCloseTo(storeyHeight) + expect(resolveWallEffectiveHeight(wall, storeyHeight, support.elevation)).toBeCloseTo( + storeyHeight - DECK_ELEVATION, + ) + + // Commit persists the deck deterministically (two candidate elevations). + expect(resolveWallSupportSlabPatch(wall, nodes)).toEqual({ supportSlabId: 'slab_deck' }) + }) + + test('pointer cap: aiming at the floor under the deck elects and persists the floor', () => { + const { wall, nodes } = makeDeckOverFloorFixture() + + const capped = spatialGridManager.getSlabSupportForWall( + LEVEL_ID, + wall.start, + wall.end, + 0, + wall.thickness, + null, + FLOOR_ELEVATION, + ) + expect(capped.electedSlabId).toBe('slab_ground') + expect(capped.elevation).toBeCloseTo(FLOOR_ELEVATION) + + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: FLOOR_ELEVATION })).toEqual({ + supportSlabId: 'slab_ground', + }) + // Aiming at the deck top keeps the deck. + expect(resolveWallSupportSlabPatch(wall, nodes, { maxElevation: DECK_ELEVATION })).toEqual({ + supportSlabId: 'slab_deck', + }) + }) +}) + +describe('deleteNodesAction strips supportSlabId references', () => { + let stopSync = () => {} + + beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() + registerFloorPlacedItem() + + const slabLow = makeSlab('slab_low', SQUARE, 0.2) + const slabHigh = makeSlab('slab_high', SQUARE, 0.8) + const item = makeFloorNode({ supportSlabId: 'slab_low' } as Partial) + const level = makeLevel(['slab_low', 'slab_high', item.id]) + + useScene.setState({ + collections: {}, + dirtyNodes: new Set(), + nodes: nodesFor(level, slabLow as AnyNode, slabHigh as AnyNode, item), + readOnly: false, + rootNodeIds: [LEVEL_ID as AnyNodeId], + } as never) + clearSceneHistory() + stopSync = initSpatialGridSync() + }) + + afterEach(() => { + stopSync() + stopSync = () => {} + }) + + function itemElevation(): number { + const nodes = useScene.getState().nodes + const item = nodes['item_test' as AnyNodeId]! + return getFloorPlacedElevation({ + node: item, + nodes, + position: [0, 0, 0], + rotation: [0, 0, 0], + }) + } + + test('deleting the host slab clears the reference and re-elects; undo restores both', () => { + expect(itemElevation()).toBeCloseTo(0.2) + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect(afterDelete['slab_low' as AnyNodeId]).toBeUndefined() + expect( + (afterDelete['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId, + ).toBeUndefined() + expect(itemElevation()).toBeCloseTo(0.8) + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['item_test' as AnyNodeId] as { supportSlabId?: string }).supportSlabId).toBe( + 'slab_low', + ) + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting a non-host slab leaves the reference alone', () => { + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['item_test' as AnyNodeId] as { supportSlabId?: string }) + .supportSlabId, + ).toBe('slab_low') + expect(itemElevation()).toBeCloseTo(0.2) + }) + + test('deleting the destination deck strips deckSlabId from stairs; undo restores it', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { + ...useScene.getState().nodes, + stair_test: stair, + [LEVEL_ID]: { + ...useScene.getState().nodes[LEVEL_ID as AnyNodeId]!, + children: ['slab_low', 'slab_high', 'item_test', 'stair_test'], + } as AnyNode, + } as never, + }) + clearSceneHistory() + + useScene.getState().deleteNodes(['slab_low' as AnyNodeId]) + + const afterDelete = useScene.getState().nodes + expect( + (afterDelete['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBeUndefined() + + useScene.temporal.getState().undo() + + const afterUndo = useScene.getState().nodes + expect(afterUndo['slab_low' as AnyNodeId]).toBeDefined() + expect((afterUndo['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId).toBe( + 'slab_low', + ) + }) + + test('deleting a slab that is not the destination deck leaves deckSlabId alone', () => { + const stair = { + id: 'stair_test', + type: 'stair', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + deckSlabId: 'slab_low', + } as unknown as AnyNode + + useScene.setState({ + nodes: { ...useScene.getState().nodes, stair_test: stair } as never, + }) + + useScene.getState().deleteNodes(['slab_high' as AnyNodeId]) + + expect( + (useScene.getState().nodes['stair_test' as AnyNodeId] as { deckSlabId?: string }).deckSlabId, + ).toBe('slab_low') + }) +}) diff --git a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts index b1da3b33..3a1b79fe 100644 --- a/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts +++ b/packages/core/src/hooks/spatial-grid/wall-slab-overlap.test.ts @@ -90,7 +90,7 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] - const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1 }) + const slab = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) const bottom = walls[0]! expect( @@ -102,6 +102,37 @@ describe('computeWallSlabElevation', () => { ).toBeCloseTo(0.1) }) + it('elects a floating deck for a wall standing on its drawn footprint', () => { + // Wall ON a deck: no band adoption needed — the wall body lies inside + // the deck's drawn polygon, which is exactly what a floating slab + // renders. + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + const wallOnDeck = parseWall([1, 2], [3, 2]) + + expect( + computeWallSlabElevation( + { start: [1, 2], end: [3, 2], thickness: 0.1 }, + [deck], + [wallOnDeck], + ), + ).toBeCloseTo(1.5) + }) + + it('a wall in the adoption band beside a floating deck does not stand on it', () => { + // Centerline 6cm below the deck's bottom edge — inside the adoption + // band (half-thickness + 0.06) but the body never reaches the drawn + // footprint. A grounded slab adopts the band and carries the wall; the + // deck keeps its drawn polygon and offers no support. + const bandWall = parseWall([0, -0.06], [4, -0.06]) + const wallLike = { start: bandWall.start, end: bandWall.end, thickness: bandWall.thickness } + + const deck = SlabNode.parse({ polygon: SLAB, elevation: 1.5 }) + expect(computeWallSlabElevation(wallLike, [deck], [bandWall])).toBe(0) + + const grounded = SlabNode.parse({ polygon: SLAB, elevation: 0.1, thickness: 0.1 }) + expect(computeWallSlabElevation(wallLike, [grounded], [bandWall])).toBeCloseTo(0.1) + }) + it('lifts a wall whose body a legacy stored polygon falls short of', () => { // Legacy hand-adjusted slab: edges 6cm inside the wall centerlines — // 1cm short of even the inner faces, so the STORED polygon never @@ -114,6 +145,8 @@ describe('computeWallSlabElevation', () => { parseWall([4, 4], [0, 4]), parseWall([0, 4], [0, 0]), ] + // Grounded (thickness = elevation): band adoption only applies to + // grounded room floors under the vertical model. const slab = SlabNode.parse({ polygon: [ [0.06, 0.06], @@ -122,6 +155,7 @@ describe('computeWallSlabElevation', () => { [0.06, 3.94], ], elevation: 0.1, + thickness: 0.1, }) const bottom = walls[0]! @@ -304,6 +338,7 @@ describe('computeWallSlabElevation', () => { computeWallSlabSupport({ start: [1, 2], end: [3, 2], thickness: 0.1 }, [floor, platform], []), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -329,6 +364,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: platform.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 2 / 3, elevation: 0.6 }, @@ -340,6 +376,8 @@ describe('computeWallSlabElevation', () => { it('keeps a shared wall on the higher slab that carries the full wall band', () => { const sharedWall = parseWall([4, 0], [4, 4]) const low = SlabNode.parse({ polygon: SLAB, elevation: 0.05 }) + // Raised room floor: grounded (thickness = elevation) so the band-carry + // rule applies — a floating deck would keep its drawn polygon instead. const high = SlabNode.parse({ polygon: [ [4, 0], @@ -348,6 +386,7 @@ describe('computeWallSlabElevation', () => { [4, 4], ], elevation: 0.6, + thickness: 0.6, }) expect( @@ -358,6 +397,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.6, baseSegments: [{ start: 0, end: 1, elevation: 0.6 }], }) @@ -374,6 +414,7 @@ describe('computeWallSlabElevation', () => { parseWall([8, 1.5], [8, 4.5]), parseWall([8, 4.5], [4, 4.5]), ] + // Grounded raised room floor (see the shared-wall test above). const high = SlabNode.parse({ polygon: [ [0, 0], @@ -382,6 +423,7 @@ describe('computeWallSlabElevation', () => { [0, 3], ], elevation: 0.6, + thickness: 0.6, }) const low = SlabNode.parse({ polygon: [ @@ -401,6 +443,7 @@ describe('computeWallSlabElevation', () => { ), ).toEqual({ elevation: 0.6, + electedSlabId: high.id, baseElevation: 0.05, baseSegments: [ { start: 0, end: 3.05 / 4.5, elevation: 0.6 }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c59b1766..a477a88f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,10 @@ export type { CeilingEvent, ChimneyEvent, ColumnEvent, + ConstructionDimensionEvent, DoorEvent, DormerEvent, + DrawingSheetEvent, ElevatorEvent, EventSuffix, FenceEvent, @@ -34,6 +36,7 @@ export type { SpawnEvent, StairEvent, StairSegmentEvent, + StructuralGridEvent, WallEvent, WindowEvent, ZoneEvent, @@ -46,12 +49,16 @@ export { } from './hooks/scene-registry/scene-registry' export { type FloorPlacedElevationArgs, + GROUND_SUPPORT_ID, getFloorPlacedElevation, getFloorPlacedFootprints, getFloorStackedPosition, } from './hooks/spatial-grid/floor-placed-elevation' export { + getWallEffectiveHeightForNodes, + type PointedSupportSurface, pointInPolygon, + SUPPORT_ELEVATION_EPSILON, spatialGridManager, type WallSlabSupportSegment, } from './hooks/spatial-grid/spatial-grid-manager' @@ -61,6 +68,14 @@ export { resolveBuildingForLevel, resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' +export { + type FenceSupportInput, + resolveFenceSupportSlabPatch, + resolveSupportSlabPatch, + resolveWallSupportSlabPatch, + type SupportSlabPatch, + type SupportSlabPatchOptions, +} from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { @@ -76,6 +91,7 @@ export { closestMeasurementFeatureBinding, MEASUREMENT_PLANAR_TOLERANCE, measurementAnchorFallback, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -86,6 +102,7 @@ export { measurementPerimeter, measurementPrismVolume, measurementReferenceNodeIds, + remapMeasurementAnchors, remapMeasurementReferences, } from './lib/measurement-geometry' export { @@ -123,7 +140,6 @@ export { planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, - projectAutoSlabsForPlan, resolveAutoZonePolygon, resumeSpaceDetection, type Space, @@ -146,7 +162,9 @@ export { } from './lib/zone-quantities' export { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialPresetByRef, getMaterialsForCategory, getSceneMaterialIdFromRef, @@ -157,12 +175,16 @@ export { type MaterialCatalogItem, type MaterialCategory, type MaterialRef, + type MaterialSource, type MaterialSurface, type ParsedMaterialRef, parseMaterialRef, + registerLibraryMaterials, SCENE_MATERIAL_REF_PREFIX, + subscribeLibraryMaterials, toLibraryMaterialRef, toSceneMaterialRef, + unregisterLibraryMaterials, } from './material-library' export type { FloorPlacedFootprint, @@ -248,9 +270,7 @@ export { } from './systems/elevator/elevator-runtime' export { ElevatorRuntimeSystem } from './systems/elevator/elevator-runtime-system' export { - DEFAULT_ELEVATOR_LEVEL_HEIGHT, type ElevatorLevelEntry, - getElevatorLevelHeight, resolveElevatorBuildingLevels, resolveElevatorLevels, resolveElevatorServiceLevelIds, @@ -269,13 +289,20 @@ export { isSplineFence, sampleFenceSpline, } from './systems/fence/fence-spline' +export { + clampSlabElevationForWalls, + getSlabElevationUpperBound, + type SlabElevationClamp, +} from './systems/slab/slab-support' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' export { StairOpeningSystem } from './systems/stair/stair-opening-system' +export { resolveStairTotalRise } from './systems/stair/stair-rise' export { getClampedWallCurveOffset, getMaxWallCurveOffset, + getWallArcData, getWallChordFrame, getWallCurveFrameAt, getWallCurveLength, @@ -313,6 +340,11 @@ export { type WallMoveLinkedWallTargetPlan, type WallPlanPoint, } from './systems/wall/wall-move' +export { + MIN_WALL_HEIGHT, + resolveWallEffectiveHeight, + resolveWallTop, +} from './systems/wall/wall-top' export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/lib/measurement-geometry.test.ts b/packages/core/src/lib/measurement-geometry.test.ts index 842f184d..48a8264b 100644 --- a/packages/core/src/lib/measurement-geometry.test.ts +++ b/packages/core/src/lib/measurement-geometry.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from 'bun:test' import type { MeasurementFeature } from '../registry/types' -import type { MeasurementPoint } from '../schema/nodes/measurement' +import type { MeasurementAnchor, MeasurementPoint } from '../schema/nodes/measurement' import { areMeasurementPointsCoplanar, closestMeasurementFeatureBinding, + measurementAnchorReferenceNodeIds, measurementAngle, measurementArea, measurementAreaVector, @@ -12,6 +13,7 @@ import { measurementNormal, measurementPerimeter, measurementPrismVolume, + remapMeasurementAnchors, } from './measurement-geometry' const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => { @@ -134,4 +136,33 @@ describe('measurement geometry', () => { expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24) expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24) }) + + test('remaps and collects references for arbitrary anchor strings', () => { + const anchors: MeasurementAnchor[] = [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [2, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_b', featureId: 'wall:end' }, + fallback: [4, 0, 0], + }, + ] + + expect(measurementAnchorReferenceNodeIds(anchors)).toEqual(['wall_a', 'wall_b']) + const remapped = remapMeasurementAnchors( + anchors, + new Map([ + ['wall_a', 'wall_a_copy'], + ['wall_b', 'wall_b_copy'], + ]), + ) + const first = remapped[0]! + const last = remapped[2]! + expect(Array.isArray(first) ? null : first.reference.nodeId).toBe('wall_a_copy') + expect(Array.isArray(last) ? null : last.reference.nodeId).toBe('wall_b_copy') + }) }) diff --git a/packages/core/src/lib/measurement-geometry.ts b/packages/core/src/lib/measurement-geometry.ts index f2184ad1..9dd7b7f9 100644 --- a/packages/core/src/lib/measurement-geometry.ts +++ b/packages/core/src/lib/measurement-geometry.ts @@ -1,4 +1,5 @@ import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types' +import type { ConstructionDimensionNode } from '../schema/nodes/construction-dimension' import type { MeasurementAnchor, MeasurementPayload, @@ -176,11 +177,8 @@ export function remapMeasurementReferences( measurement: MeasurementPayload, idMap: ReadonlyMap, ): MeasurementPayload { - const remap = (anchor: MeasurementAnchor): MeasurementAnchor => { - if (Array.isArray(anchor)) return anchor - const nodeId = idMap.get(anchor.reference.nodeId) - return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor - } + const remap = (anchor: MeasurementAnchor): MeasurementAnchor => + remapMeasurementAnchors([anchor], idMap)[0]! switch (measurement.kind) { case 'distance': @@ -205,11 +203,35 @@ export function remapMeasurementReferences( } } -export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { - const anchors = - measurement.kind === 'distance' || measurement.kind === 'angle' - ? measurement.points - : measurement.base +export function remapMeasurementAnchors( + anchors: readonly MeasurementAnchor[], + idMap: ReadonlyMap, +): MeasurementAnchor[] { + return anchors.map((anchor) => { + if (Array.isArray(anchor)) return anchor + const nodeId = idMap.get(anchor.reference.nodeId) + return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor + }) +} + +export function remapConstructionDimensionReferences( + dimension: ConstructionDimensionNode, + idMap: ReadonlyMap, +): ConstructionDimensionNode { + const controllingDimensionId = dimension.controllingDimensionId + ? ((idMap.get(dimension.controllingDimensionId) as ConstructionDimensionNode['id']) ?? + dimension.controllingDimensionId) + : null + return { + ...dimension, + anchors: remapMeasurementAnchors(dimension.anchors, idMap), + controllingDimensionId, + } +} + +export function measurementAnchorReferenceNodeIds( + anchors: readonly MeasurementAnchor[], +): AnyNodeId[] { const ids = new Set() for (const anchor of anchors) { if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId) @@ -217,6 +239,14 @@ export function measurementReferenceNodeIds(measurement: MeasurementPayload): An return [...ids] as AnyNodeId[] } +export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] { + const anchors = + measurement.kind === 'distance' || measurement.kind === 'angle' + ? measurement.points + : measurement.base + return measurementAnchorReferenceNodeIds(anchors) +} + export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint { if (points.length < 3) return [0, 0, 0] diff --git a/packages/core/src/lib/slab-polygon.test.ts b/packages/core/src/lib/slab-polygon.test.ts index 45490c1e..ef89df43 100644 --- a/packages/core/src/lib/slab-polygon.test.ts +++ b/packages/core/src/lib/slab-polygon.test.ts @@ -7,10 +7,22 @@ function wallOf(start: [number, number], end: [number, number], thickness = 0.1) return WallNode.parse({ start, end, thickness }) } -function slabOf(polygon: Array<[number, number]>, autoFromWalls = true, elevation?: number) { - return SlabNode.parse( - elevation === undefined ? { polygon, autoFromWalls } : { polygon, autoFromWalls, elevation }, - ) +function slabOf( + polygon: Array<[number, number]>, + autoFromWalls = true, + elevation?: number, + thickness?: number, +) { + return SlabNode.parse({ + polygon, + autoFromWalls, + ...(elevation === undefined ? {} : { elevation }), + // Raised ROOM FLOOR fixtures pass thickness = elevation so the slab + // stays grounded (underside 0) — adoption/seam rules only apply to + // grounded slabs; the schema-default 0.05 thickness would make an + // elevated fixture a floating deck. + ...(thickness === undefined ? {} : { thickness }), + }) } function xs(polygon: Array<[number, number]>) { @@ -398,6 +410,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.34, + 0.34, ) const low = slabOf( [ @@ -450,6 +463,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.4, + 0.4, ) const legacyLow = slabOf( [ @@ -471,8 +485,11 @@ describe('getRenderableSlabPolygon', () => { }) test('stacked slabs are not mistaken for rooms across a wall', () => { + // The platform is a grounded raised floor (thickness = elevation); the + // floating-deck variant of this shape is covered by the adoption-gate + // tests below. const floor = slabOf(roomA, false, 0.05) - const platform = slabOf(roomA, false, 0.4) + const platform = slabOf(roomA, false, 0.4, 0.4) const walls = [ wallOf([0, 0], [4, 0]), wallOf([4, 0], [4, 3]), @@ -527,6 +544,7 @@ describe('getRenderableSlabPolygon', () => { ], false, 0.3, + 0.3, ) const stepLow = slabOf( [ @@ -635,6 +653,7 @@ describe('getRenderableSlabPolygon', () => { ], true, 0.4, + 0.4, ) const low = slabOf( [ @@ -784,6 +803,76 @@ describe('getRenderableSlabPolygon', () => { }) }) +describe('grounded adoption gate', () => { + // Owner rule: wall adoption / per-edge extension exists so ROOM FLOORS + // tile with the walls standing on them. It applies only to grounded + // slabs (underside ≈ 0) and recessed pools; a floating deck keeps its + // drawn polygon exactly. + + test('a floating deck near walls keeps its drawn polygon exactly', () => { + // Same footprint as roomA — every edge inside a wall adoption band — + // but floating at 1.5m: no edge may extend to a wall face. + const deck = slabOf(roomA, false, 1.5, 0.05) + + const poly = getRenderableSlabPolygon(deck, { walls: twoRoomWalls, siblingSlabs: [] }) + + expect(poly).toEqual(roomA) + }) + + test('boundary case: underside 0.005 still counts as grounded and adopts', () => { + const nearlyGrounded = slabOf(roomA, false, 0.055, 0.05) + + const poly = getRenderableSlabPolygon(nearlyGrounded, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a slab floated just past the epsilon stops adopting', () => { + // Underside 0.02 > 0.01 epsilon — already a deck. + const justFloating = slabOf(roomA, false, 0.07, 0.05) + + const poly = getRenderableSlabPolygon(justFloating, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(0) + }) + + test('a recessed pool keeps band adoption (unchanged)', () => { + // Recessed slabs are sunk into the ground, never floating — their + // negative elevation encodes depth, so the gate must not strip the + // wall-face extension a sunken room floor relies on. + const pool = SlabNode.parse({ polygon: roomA, elevation: -0.15, recessed: true }) + + const poly = getRenderableSlabPolygon(pool, { + walls: [wallOf([0, 0], [4, 0])], + siblingSlabs: [], + }) + + expect(Math.min(...zs(poly))).toBeCloseTo(-0.05) + }) + + test('a grounded floor ignores a floating deck sibling as a seam target', () => { + // Deck butted across the x=4 wall band: were it a room floor, the + // grounded (lower) floor would terminate at its own wall face (3.95). + // As a deck it is no seam partner — the floor adopts the wall's outer + // face (4.05) as if alone, and the deck itself stays as drawn. + const floor = slabOf(roomA, false, 0.05) + const deck = slabOf(roomB, false, 1.5, 0.05) + const walls = [wallOf([4, 0], [4, 3])] + + const floorPoly = getRenderableSlabPolygon(floor, { walls, siblingSlabs: [deck] }) + const deckPoly = getRenderableSlabPolygon(deck, { walls, siblingSlabs: [floor] }) + + expect(Math.max(...xs(floorPoly))).toBeCloseTo(4.05) + expect(deckPoly).toEqual(roomB) + }) +}) + describe('snapSlabEdgeToWallBand', () => { test('an edge inside the band snaps onto the wall centerline', () => { const snap = snapSlabEdgeToWallBand([0.5, 0.08], [3.5, 0.08], [wallOf([0, 0], [4, 0])]) diff --git a/packages/core/src/lib/slab-polygon.ts b/packages/core/src/lib/slab-polygon.ts index 5b121adb..e8fded0d 100644 --- a/packages/core/src/lib/slab-polygon.ts +++ b/packages/core/src/lib/slab-polygon.ts @@ -38,6 +38,13 @@ import { getWallThickness } from '../systems/wall/wall-footprint' * render offsets. * - FREE — no neighbour, no wall. Rendered exactly as drawn. * + * The whole machinery exists to make ROOM FLOORS tile with the walls + * standing on them, so it only applies to GROUNDED slabs (underside on + * the level plane) and recessed pools. A floating deck keeps its drawn + * polygon exactly — it must not grow into a wall it happens to float + * beside — and is symmetrically ignored as a seam target by its + * grounded siblings. + * * Sub-edges of one edge with different projections are joined by a * perpendicular STEP connector at the breakpoint. Breakpoints sit on * candidate span boundaries — wall junctions — so the step's vertical @@ -74,9 +81,28 @@ const WALL_LATERAL_TIE_EPSILON = 0.02 const CURVED_WALL_SAMPLE_SEGMENTS = 32 const SLAB_SEAM_ELEVATION_EPSILON = 1e-4 const DEFAULT_SLAB_ELEVATION = 0.05 +const DEFAULT_SLAB_THICKNESS = 0.05 +/** + * A non-recessed slab whose underside (`elevation − thickness`) rises + * above the level plane by more than this is a floating deck: it keeps + * its drawn polygon (no wall adoption, no seam projection) and grounded + * siblings don't seam toward it. + */ +const GROUNDED_SLAB_UNDERSIDE_EPSILON = 0.01 /** Prevent near-parallel offset lines from producing unbounded corner spikes. */ const MAX_CORNER_MITER_RATIO = 10 +/** + * Floating deck test — see the module header. Recessed pools are never + * floating: their negative elevation encodes depth, not placement. + */ +function isFloatingSlab(slab: SlabNode): boolean { + if (slab.recessed) return false + const elevation = slab.elevation ?? DEFAULT_SLAB_ELEVATION + const thickness = slab.thickness ?? DEFAULT_SLAB_THICKNESS + return elevation - thickness > GROUNDED_SLAB_UNDERSIDE_EPSILON +} + export type SlabPolygonContext = { /** Walls on the slab's level. */ walls: WallNode[] @@ -138,7 +164,7 @@ export function getRenderableSlabPolygon( context: SlabPolygonContext, ): Array<[number, number]> { const polygon = slabNode.polygon - if (polygon.length < 3) { + if (polygon.length < 3 || isFloatingSlab(slabNode)) { return polygon.map(([x, z]) => [x, z] as [number, number]) } @@ -368,6 +394,11 @@ function computeEdgeSubSpans( const neighborSegments: NeighborSegment[] = [] for (const sibling of context.siblingSlabs) { + // A floating deck keeps its drawn polygon, so it can't be a seam + // partner: projecting toward it would move this slab's edge while the + // deck's stays put (asymmetric seam), and the higher/lower band rules + // only describe room floors meeting under a wall. + if (isFloatingSlab(sibling)) continue const siblingPolygon = sibling.polygon if (siblingPolygon.length < 2) continue const elevation = sibling.elevation ?? DEFAULT_SLAB_ELEVATION diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index 708f7153..c7eb3949 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { resolveCeilingHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' import { detectSpacesForLevel, + initSpaceDetectionSync, planAutoCeilingsForLevel, planAutoSlabsForLevel, planAutoZonesForLevel, @@ -38,16 +42,35 @@ function slab(elevation: number) { } describe('planAutoCeilingsForLevel', () => { - test('creates auto ceilings at the top of the room walls', () => { + test('creates auto ceilings height-less so they follow the level top', () => { const created = planAutoCeilingsForLevel([roomPolygon()], [], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }).create[0] - expect(created?.height).toBeCloseTo(2.55) + expect(created).toBeDefined() + // Follows-mode: no stored height — the effective height derives from + // the clamp bound at read time via resolveCeilingHeight. + expect('height' in created!).toBe(false) + expect(created?.autoFromWalls).toBe(true) }) - test('updates existing auto ceiling height when the slab elevation changes', () => { + test('never writes a height onto a matched auto ceiling', () => { + const ceiling = CeilingNode.parse({ + polygon: square, + autoFromWalls: true, + }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { + storeyHeight: 3, + }) + + // Same polygon, follows-mode height — nothing to update. + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(0) + expect(plan.delete).toHaveLength(0) + }) + + test('a leftover explicit height on a matched auto ceiling is not rewritten', () => { const ceiling = CeilingNode.parse({ polygon: square, height: 2.55, @@ -55,30 +78,12 @@ describe('planAutoCeilingsForLevel', () => { }) const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 3, }) - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.id).toBe(ceiling.id) - expect(plan.update[0]?.data.polygon).toBeUndefined() - expect(plan.update[0]?.data.height).toBeCloseTo(2.9) - }) - - test('updates existing auto ceiling height when wall height changes', () => { - const ceiling = CeilingNode.parse({ - polygon: square, - height: 2.55, - autoFromWalls: true, - }) - - const plan = planAutoCeilingsForLevel([roomPolygon()], [ceiling], { - walls: squareWalls(3), - slabs: [slab(0.05)], - }) - - expect(plan.update).toHaveLength(1) - expect(plan.update[0]?.data.height).toBeCloseTo(3.05) + // The sync no longer re-derives auto heights; a user-set explicit + // height survives (still under the bound, so no clamp either). + expect(plan.update).toHaveLength(0) }) test('does not replace a manual ceiling with an auto ceiling', () => { @@ -88,9 +93,10 @@ describe('planAutoCeilingsForLevel', () => { autoFromWalls: false, }) + // Storey plane above the stored 2.5 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [manualCeiling], { - walls: squareWalls(), - slabs: [slab(0.4)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -160,9 +166,10 @@ describe('planAutoCeilingsForLevel', () => { const demoted = CeilingNode.parse({ ...ceiling, ...demotion?.data }) expect(demoted.autoFromWalls).toBe(false) + // Storey plane above the stored 2.55 so the stage 3-B manual re-clamp + // stays out of this test's scope (suppression only). const plan = planAutoCeilingsForLevel([roomPolygon()], [demoted], { - walls: squareWalls(), - slabs: [slab(0.05)], + storeyHeight: 2.7, }) expect(plan.create).toHaveLength(0) @@ -171,6 +178,219 @@ describe('planAutoCeilingsForLevel', () => { }) }) +// Two stacked levels; the deck slab (occupying [-0.3, 0] over the upper +// level's plane) covers the queried level below, so the clamp bound is +// 2.5 - 0.3 - 0.01 = 2.19 (scenario gate 11's flush deck). +function stackedDeckNodes(): Record { + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const list: AnyNode[] = [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ id: 'level_0', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_1', + level: 1, + height: 2.5, + parentId: 'building_a', + children: ['slab_deck'], + }), + deck, + ] + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('stage 3-B ceiling clamp bound', () => { + test('height-less auto ceilings resolve under the covering-slab bound at read time', () => { + const nodes = stackedDeckNodes() + const created = planAutoCeilingsForLevel([roomPolygon()], [], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }).create[0] + + expect(created).toBeDefined() + expect('height' in created!).toBe(false) + // Follows-mode: the effective height is the deck-limited bound. + expect(resolveCeilingHeight({ ...created!, parentId: 'level_0' }, nodes)).toBeCloseTo(2.19) + }) + + test('clamps a manual ceiling above the bound down to it (plane-only degradation)', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.6, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.polygon).toBeUndefined() + expect(plan.update[0]?.data.height).toBeCloseTo(2.49) + }) + + test('never raises a manual ceiling sitting below the bound', () => { + const manual = CeilingNode.parse({ polygon: square, height: 2.0, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { storeyHeight: 2.5 }) + + expect(plan.update).toHaveLength(0) + }) + + test('skips follows-mode manual ceilings (never converts them to explicit)', () => { + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.update).toHaveLength(0) + }) + + test('a flush deck above clamps a manual ceiling at the plane margin to its underside', () => { + // Scenario gate 11: manual ceiling at storeyHeight - 0.01 (the no-deck + // bound) → deck occupying [-0.3, 0] above → clamps to 2.5 - 0.3 - 0.01. + const nodes = stackedDeckNodes() + const manual = CeilingNode.parse({ polygon: square, height: 2.49, autoFromWalls: false }) + + const plan = planAutoCeilingsForLevel([roomPolygon()], [manual], { + storeyHeight: 2.5, + ceilingClampBound: (polygon) => getCeilingClampBound('level_0', nodes, polygon), + }) + + expect(plan.create).toHaveLength(0) + expect(plan.update).toHaveLength(1) + expect(plan.update[0]?.id).toBe(manual.id) + expect(plan.update[0]?.data.height).toBeCloseTo(2.19) + }) +}) + +// Minimal store stand-ins for initSpaceDetectionSync: a zustand-shaped +// scene store (getState/subscribe/temporal) whose write methods mutate the +// nodes record and re-notify, and an editor store carrying `spaces`. +function createSceneStoreStub(initialNodes: Record) { + const listeners = new Set<(state: unknown) => void>() + const state: Record & { nodes: Record } = { + nodes: initialNodes, + } + const notify = () => { + for (const listener of [...listeners]) listener(state) + } + state.updateNodes = (updates: Array<{ id: string; data: Record }>) => { + const next: Record = { ...state.nodes } + for (const { id, data } of updates) { + const existing = next[id] + if (existing) next[id] = { ...existing, ...data } as AnyNode + } + state.nodes = next + notify() + } + state.deleteNodes = (ids: string[]) => { + const next: Record = { ...state.nodes } + for (const id of ids) delete next[id] + state.nodes = next + notify() + } + state.createNodes = (entries: Array<{ node: AnyNode; parentId: string }>) => { + const next: Record = { ...state.nodes } + for (const { node, parentId } of entries) { + next[node.id] = { ...node, parentId } as AnyNode + const parent = next[parentId] as (AnyNode & { children?: string[] }) | undefined + if (parent) { + next[parentId] = { ...parent, children: [...(parent.children ?? []), node.id] } as AnyNode + } + } + state.nodes = next + notify() + } + return { + getState: () => state, + subscribe: (listener: (state: unknown) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + temporal: { getState: () => ({ pause() {}, resume() {} }) }, + setNodes(next: Record) { + state.nodes = next + notify() + }, + } +} + +function createEditorStoreStub() { + const state = { + spaces: {} as Record, + setSpaces(next: Record) { + state.spaces = next + }, + } + return { getState: () => state } +} + +describe('reactive ceiling re-clamp through the detection sync', () => { + test('a flush deck created on the level above clamps the existing manual ceiling below', () => { + const walls = [ + WallNode.parse({ start: [0, 0], end: [4, 0], parentId: 'level_0' }), + WallNode.parse({ start: [4, 0], end: [4, 3], parentId: 'level_0' }), + WallNode.parse({ start: [4, 3], end: [0, 3], parentId: 'level_0' }), + WallNode.parse({ start: [0, 3], end: [0, 0], parentId: 'level_0' }), + ] + const manualCeiling = CeilingNode.parse({ + id: 'ceiling_main', + parentId: 'level_0', + polygon: square, + height: 2.49, + autoFromWalls: false, + }) + const initialNodes = Object.fromEntries( + [ + BuildingNode.parse({ id: 'building_a', children: ['level_0', 'level_1'] }), + LevelNode.parse({ + id: 'level_0', + level: 0, + height: 2.5, + parentId: 'building_a', + children: [...walls.map((wall) => wall.id), 'ceiling_main'], + }), + LevelNode.parse({ id: 'level_1', level: 1, height: 2.5, parentId: 'building_a' }), + ...walls, + manualCeiling, + ].map((node) => [node.id, node]), + ) as Record + + const sceneStore = createSceneStoreStub(initialNodes) + const editorStore = createEditorStoreStub() + const unsubscribe = initSpaceDetectionSync(sceneStore, editorStore) + + try { + // Scenario gate 11's reactive half: the deck lands on the level + // ABOVE, so only the covering-underside part of level_0's structure + // snapshot changes — the sync must still re-run and clamp down. + const deck = SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_1', + polygon: square, + elevation: 0, + thickness: 0.3, + }) + const current = sceneStore.getState().nodes + const levelAbove = current.level_1 as AnyNode + sceneStore.setNodes({ + ...current, + slab_deck: deck, + level_1: { ...levelAbove, children: ['slab_deck'] } as AnyNode, + }) + + const ceiling = sceneStore.getState().nodes.ceiling_main as CeilingNode + expect(ceiling.height).toBeCloseTo(2.5 - 0.3 - 0.01) + } finally { + unsubscribe() + } + }) +}) + describe('detectSpacesForLevel', () => { const areaOf = (polygon: Array<{ x: number; y: number }>) => { let area = 0 diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index c125e275..41302a74 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -2,12 +2,21 @@ import { type AnyNodeId, CeilingNode, type CeilingNode as CeilingNodeType, + type LevelNode, SlabNode, type SlabNode as SlabNodeType, type WallNode, ZoneNode, type ZoneNode as ZoneNodeType, } from '../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../services/level-height' +import { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + getCeilingClampBound, + getLevelElevations, + getStoredLevelHeight, +} from '../services/storey' import { getSceneHistoryPauseDepth, pauseSceneHistory, @@ -56,10 +65,6 @@ type DetectedRoom = { bbox: ReturnType } -type DetectedCeilingRoom = DetectedRoom & { - ceilingHeight: number -} - export type AutoSlabSyncPlan = { create: SlabNodeType[] update: Array<{ id: SlabNodeType['id']; data: Partial }> @@ -77,7 +82,6 @@ export type AutoZoneSyncPlan = { } const DEFAULT_AUTO_SLAB_ELEVATION = 0.05 -const DEFAULT_AUTO_CEILING_HEIGHT = 2.5 const CEILING_HEIGHT_EPSILON = 1e-6 const ROOM_CURVE_TOLERANCE = 0.04 const MAX_CURVE_SUBDIVISION_DEPTH = 6 @@ -94,9 +98,21 @@ const WALL_JUNCTION_TOLERANCE = 0.08 const ORPHAN_MERGE_COVERAGE_THRESHOLD = 0.6 const COVERAGE_SAMPLE_STEPS = 12 +// Auto ceilings are created height-less (follows-mode: they track the +// clamp bound live through `resolveCeilingHeight`), so the planner needs +// no wall/slab inputs anymore — only the bound for the explicit-height +// reactive re-clamp below. export type AutoCeilingPlanningContext = { - walls?: WallNode[] - slabs?: SlabNodeType[] + /** Stored storey height of the level being planned (floor-to-floor). */ + storeyHeight?: number + /** + * Stage 3-B clamp-bound resolver for a polygon on the planned level: + * `min(storey plane, lowest covering-slab underside from the level + * above) - CEILING_CLAMP_MARGIN` (see `getCeilingClampBound`). Absent + * (pure-planner callers without a nodes record), the bound degrades to + * the plane-only `storeyHeight - CEILING_CLAMP_MARGIN`. + */ + ceilingClampBound?: (polygon: Array<[number, number]>) => number } function pointFromTuple(point: [number, number]): Point2D { @@ -306,61 +322,17 @@ function wallBoundsRoom(wall: WallNode, roomPolygon: Point2D[]) { return matchingPoints.length >= 2 } -function pointIsOnSlab(point: Point2D, slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - const slabPolygon = slab.polygon.map(pointFromTuple) - if (!pointInPolygon(point, slabPolygon)) return false - - for (const hole of slab.holes ?? []) { - if (hole.length >= 3 && pointInPolygon(point, hole.map(pointFromTuple))) { - return false - } - } - - return true -} - -function slabSupportsRoom(roomPolygon: Point2D[], slab: SlabNodeType) { - if (slab.polygon.length < 3) return false - if (polygonSignature(slab.polygon.map(pointFromTuple)) === polygonSignature(roomPolygon)) { - return true - } - return pointIsOnSlab(polygonCentroid(roomPolygon), slab) -} - -function resolveRoomSlabElevation(roomPolygon: Point2D[], slabs: SlabNodeType[] = []) { - let maxElevation = 0 - - for (const slab of slabs) { - if (!slabSupportsRoom(roomPolygon, slab)) continue - maxElevation = Math.max(maxElevation, slab.elevation ?? DEFAULT_AUTO_SLAB_ELEVATION) - } - - return maxElevation -} - -function resolveRoomWallHeight(roomPolygon: Point2D[], walls: WallNode[] = []) { - let maxHeight = 0 - - for (const wall of walls) { - if (!wallBoundsRoom(wall, roomPolygon)) continue - const height = wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT - if (Number.isFinite(height)) { - maxHeight = Math.max(maxHeight, height) - } - } - - return maxHeight > 0 ? maxHeight : DEFAULT_AUTO_CEILING_HEIGHT -} - -function resolveAutoCeilingHeight( - roomPolygon: Point2D[], - context: AutoCeilingPlanningContext = {}, +/** + * The clamp bound for a ceiling polygon under this planning context — + * the context's cross-level resolver when provided, else the plane-only + * `storeyHeight - CEILING_CLAMP_MARGIN` degradation. + */ +function resolveCeilingClampBound( + polygon: Array<[number, number]>, + context: AutoCeilingPlanningContext, ) { - return ( - resolveRoomSlabElevation(roomPolygon, context.slabs) + - resolveRoomWallHeight(roomPolygon, context.walls) - ) + if (context.ceilingClampBound) return context.ceilingClampBound(polygon) + return (context.storeyHeight ?? DEFAULT_LEVEL_HEIGHT) - CEILING_CLAMP_MARGIN } function getWallDirection(wall: Pick) { @@ -809,7 +781,10 @@ function wallGeometrySignature(wall: WallNode) { wall.end[0].toFixed(4), wall.end[1].toFixed(4), (wall.thickness ?? 0.2).toFixed(4), - (wall.height ?? DEFAULT_AUTO_CEILING_HEIGHT).toFixed(4), + // Plane-bound (no stored height) is a distinct state, not a default + // value: it resolves to the storey plane, so it must not alias an + // explicit height of the same magnitude in the trigger signature. + wall.height == null ? 'plane' : wall.height.toFixed(4), getClampedWallCurveOffset(wall).toFixed(4), ].join('|') } @@ -827,13 +802,24 @@ function zoneGeometrySignature(zone: ZoneNodeType) { ].join('|') } -// Slabs and ceilings stay out of the trigger signature: including generated -// surfaces caused delete/recreate feedback. Zones are included only so a newly -// traced room footprint can adopt its enclosing walls without waiting for the -// next remodel. +// Slab/ceiling POLYGONS stay out of the trigger signature: including +// generated footprints caused delete/recreate feedback. Zones are included +// only so a newly traced room footprint can adopt its enclosing walls +// without waiting for the next remodel. Slab ELEVATIONS and the level's +// stored storey height ARE included — both feed the explicit-ceiling +// re-clamp bound (the storey plane), and neither is rewritten by +// the sync, so regeneration triggers when they change without feedback. +// Stage 3-B adds the LEVEL-ABOVE's covering-slab undersides (elevation − +// thickness, recessed pools excluded): a deck created, lowered, or +// thickened above must re-run the sync below so ceilings re-clamp under +// it. Same polygon exclusion applies — the level-above's own auto sync +// rewrites its slab footprints, and hashing them here would re-trigger +// this level on every remodel above. function levelStructureSnapshots(nodes: Record) { const wallsByLevel = new Map() const zonesByLevel = new Map() + const slabElevationsByLevel = new Map() + const coveringUndersidesByLevel = new Map() for (const node of Object.values(nodes)) { if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue @@ -846,17 +832,39 @@ function levelStructureSnapshots(nodes: Record) { const zones = zonesByLevel.get(levelId) ?? [] zones.push(ZoneNode.parse(node)) zonesByLevel.set(levelId, zones) + } else if ((node as any).type === 'slab') { + const elevations = slabElevationsByLevel.get(levelId) ?? [] + elevations.push( + `${(node as any).id}:${(((node as any).elevation as number | undefined) ?? DEFAULT_AUTO_SLAB_ELEVATION).toFixed(4)}`, + ) + slabElevationsByLevel.set(levelId, elevations) + if ((node as any).recessed !== true) { + const undersides = coveringUndersidesByLevel.get(levelId) ?? [] + const elevation = ((node as any).elevation as number | undefined) ?? 0.05 + const thickness = ((node as any).thickness as number | undefined) ?? 0.05 + undersides.push(`${(node as any).id}:${(elevation - thickness).toFixed(4)}`) + coveringUndersidesByLevel.set(levelId, undersides) + } } } + const levelElevations = getLevelElevations(nodes as Record) const snapshots = new Map() const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()]) for (const levelId of levelIds) { const walls = wallsByLevel.get(levelId) ?? [] const zones = zonesByLevel.get(levelId) ?? [] + const level = nodes[levelId] + const storeyKey = + level?.type === 'level' && typeof level.height === 'number' ? level.height.toFixed(4) : '' + const slabKey = (slabElevationsByLevel.get(levelId) ?? []).sort().join(';') + const aboveId = findLevelAboveId(levelId, levelElevations) + const aboveSlabKey = aboveId + ? (coveringUndersidesByLevel.get(aboveId) ?? []).sort().join(';') + : '' snapshots.set( levelId, - `${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`, + `${storeyKey}#${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}##${slabKey}##${aboveSlabKey}`, ) } @@ -1111,29 +1119,6 @@ function syncAutoSlabsForLevel( return plan } -export function projectAutoSlabsForPlan( - existingSlabs: SlabNodeType[], - plan: AutoSlabSyncPlan, -): SlabNodeType[] { - const slabsById = new Map(existingSlabs.map((slab) => [slab.id, slab])) - - for (const id of plan.delete) { - slabsById.delete(id) - } - - for (const update of plan.update) { - const slab = slabsById.get(update.id) - if (!slab) continue - slabsById.set(update.id, SlabNode.parse({ ...slab, ...update.data })) - } - - for (const slab of plan.create) { - slabsById.set(slab.id, slab) - } - - return [...slabsById.values()] -} - export function planAutoCeilingsForLevel( roomPolygons: Point2D[][], existingCeilings: CeilingNodeType[], @@ -1145,7 +1130,7 @@ export function planAutoCeilingsForLevel( ) const manualPolygons = manualCeilings.map((ceiling) => ceiling.polygon.map(pointFromTuple)) - const detectedAll: DetectedCeilingRoom[] = roomPolygons + const detectedAll: DetectedRoom[] = roomPolygons .map((poly) => ({ poly: simplifyClosedPolygon(poly.map(pointToTuple), AUTO_SLAB_POLYGON_SIMPLIFY_TOLERANCE).map( pointFromTuple, @@ -1161,7 +1146,6 @@ export function planAutoCeilingsForLevel( centroid: polygonCentroid(room.poly), area: Math.abs(polygonArea(room.poly)), bbox: bboxOf(room.poly), - ceilingHeight: resolveAutoCeilingHeight(room.poly, context), })) const detected = detectedAll.filter( @@ -1182,7 +1166,7 @@ export function planAutoCeilingsForLevel( const matchedCeilingIds = new Set() const matchedDetectedIdx = new Set() - const updatesById = new Map() + const updatesById = new Map() const autoBySignature = new Map>() for (const entry of existingAutoMeta) { @@ -1199,7 +1183,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(existing.ceiling.id) updatesById.set(existing.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) }) @@ -1237,7 +1220,6 @@ export function planAutoCeilingsForLevel( matchedCeilingIds.add(bestMatch.entry.ceiling.id) updatesById.set(bestMatch.entry.ceiling.id, { polygon: room.poly.map(pointToTuple), - height: room.ceilingHeight, }) } @@ -1255,27 +1237,36 @@ export function planAutoCeilingsForLevel( } } + // Stage 3-B reactive re-clamp (clamp-never-ask): a covering slab + // created, moved, or thickened on the level above can leave an EXISTING + // manual explicit-height ceiling poking into its solid. Clamp explicit + // heights down to the bound; never raise them — a user-lowered ceiling + // is intent, only an over-bound one is a conflict. Follows-mode + // ceilings (absent height) derive under the bound by construction and + // are skipped, so the clamp can never convert one to an explicit + // height. + const manualClamps: AutoCeilingSyncPlan['update'] = manualCeilings.flatMap((ceiling) => { + if (ceiling.height == null) return [] + const bound = resolveCeilingClampBound(ceiling.polygon, context) + if (!Number.isFinite(bound)) return [] + return ceiling.height > bound + CEILING_HEIGHT_EPSILON + ? [{ id: ceiling.id, data: { height: bound } }] + : [] + }) + const ceilingsToUpdate = [ + // Auto ceilings only track their room's POLYGON here — their height is + // follows-mode (absent) and derives from the level top at read time. ...existingAuto .filter((ceiling) => updatesById.has(ceiling.id)) .flatMap((ceiling) => { const update = updatesById.get(ceiling.id) if (!update) return [] - - const data: Partial = {} - if (!sameTuplePolygon(ceiling.polygon, update.polygon)) { - data.polygon = update.polygon - } - if ( - Math.abs((ceiling.height ?? DEFAULT_AUTO_CEILING_HEIGHT) - update.height) > - CEILING_HEIGHT_EPSILON - ) { - data.height = update.height - } - - return Object.keys(data).length === 0 ? [] : [{ id: ceiling.id, data }] + if (sameTuplePolygon(ceiling.polygon, update.polygon)) return [] + return [{ id: ceiling.id, data: { polygon: update.polygon } }] }), ...ceilingDemotions, + ...manualClamps, ] const plannedCeilingsForNaming: Array<{ name?: string }> = [...existingCeilings] @@ -1289,12 +1280,14 @@ export function planAutoCeilingsForLevel( const name = nextAutoRoomName(plannedCeilingsForNaming, 'Ceiling') plannedCeilingsForNaming.push({ name }) + // Height-less on purpose: auto ceilings follow the level top (the + // clamp bound) through `resolveCeilingHeight` instead of baking a + // derived height that would go stale on level-height edits. ceilingsToCreate.push( CeilingNode.parse({ name, polygon: room.poly.map(pointToTuple), holes: [], - height: room.ceilingHeight, autoFromWalls: true, }), ) @@ -1402,14 +1395,21 @@ function runSpaceDetection( } const parsedSlabs = slabs.map((slab: any) => SlabNode.parse(slab)) - const slabPlan = syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) - const projectedSlabs = projectAutoSlabsForPlan(parsedSlabs, slabPlan) + syncAutoSlabsForLevel(levelId, roomPolygons, parsedSlabs, sceneStore) + const levelNode = nodes[levelId] + const storeyHeight = + levelNode?.type === 'level' + ? getStoredLevelHeight(levelNode as LevelNode) + : DEFAULT_LEVEL_HEIGHT syncAutoCeilingsForLevel( levelId, roomPolygons, ceilings.map((ceiling: any) => CeilingNode.parse(ceiling)), sceneStore, - { walls, slabs: projectedSlabs }, + { + storeyHeight, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, nodes, polygon), + }, ) const zonePlan = planAutoZonesForLevel( spaces, diff --git a/packages/core/src/lib/zone-quantities.test.ts b/packages/core/src/lib/zone-quantities.test.ts index c8e529ce..4bdeb23a 100644 --- a/packages/core/src/lib/zone-quantities.test.ts +++ b/packages/core/src/lib/zone-quantities.test.ts @@ -17,7 +17,12 @@ function sceneRecord(nodes: AnyNode[]): Record { function roomNodes() { const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon }) const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon }) - const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon }) + const ceiling = CeilingNode.parse({ + id: 'ceiling_room', + parentId: 'level_main', + polygon, + height: 2.5, + }) const walls = polygon.map((start, index) => WallNode.parse({ id: `wall_${index}`, diff --git a/packages/core/src/lib/zone-quantities.ts b/packages/core/src/lib/zone-quantities.ts index f120ff02..b1830696 100644 --- a/packages/core/src/lib/zone-quantities.ts +++ b/packages/core/src/lib/zone-quantities.ts @@ -1,6 +1,11 @@ import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { AnyNodeId } from '../schema/types' +import { DEFAULT_LEVEL_HEIGHT, resolveCeilingHeight } from '../services/level-height' +import { getWallPlaneTop } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' import { sampleWallCenterline } from '../systems/wall/wall-curve' -import { DEFAULT_WALL_HEIGHT, DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { DEFAULT_WALL_THICKNESS } from '../systems/wall/wall-footprint' +import { resolveWallEffectiveHeight } from '../systems/wall/wall-top' import { detectSpacesForLevel, type Space } from './space-detection' type Point2D = readonly [number, number] @@ -466,13 +471,19 @@ function unavailable(reason: string): ZoneQuantityValue { export function deriveZoneQuantityReport( zone: ZoneNode, - sceneNodes: Record, + sceneNodes: Readonly>, ): ZoneQuantityReport { const levelId = zone.parentId const levelNodes = levelId ? Object.values(sceneNodes).filter((node) => node.parentId === levelId) : [] const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall') + const slabs = levelNodes.filter((node): node is SlabNode => node.type === 'slab') + const wallEffectiveHeight = (wall: WallNode) => { + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + const planeTop = levelId ? getWallPlaneTop(wall, levelId, sceneNodes) : DEFAULT_LEVEL_HEIGHT + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) + } const edgeLengths = zone.polygon.map((start, index) => { const end = zone.polygon[(index + 1) % zone.polygon.length] return end ? pointDistance(start, end) : 0 @@ -488,7 +499,7 @@ export function deriveZoneQuantityReport( const ceilingCoverage = proveSurfaceCoverage( zone, levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'), - (node) => node.height, + (node) => resolveCeilingHeight(node, sceneNodes as Record), { singular: 'ceiling', plural: 'Ceilings', datum: 'heights' }, ) @@ -517,7 +528,7 @@ export function deriveZoneQuantityReport( ? { status: 'available' as const, value: wallSpans!.reduce( - (sum, span) => sum + span.length * (span.wall.height ?? DEFAULT_WALL_HEIGHT), + (sum, span) => sum + span.length * wallEffectiveHeight(span.wall), 0, ), note: 'Gross indoor-facing wall surface within this zone, including both sides of interior partitions.', diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index e45a4887..7bf2b786 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4,10 +4,14 @@ import { MaterialTarget as MaterialTargetSchema, } from './schema/material' +export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace' + export type MaterialCatalogItem = { id: string label: string category: MaterialCategory + /** Origin of the entry. Absent = 'pascal' (all static catalog entries). */ + source?: MaterialSource /** * Where this finish is appropriate. Absent = universal (e.g. flat colors). * The paint picker may filter by the slot being painted; v1 shows everything. @@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [ 'roofing', 'ground', 'glass', + 'other', ] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] @@ -4149,13 +4154,64 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] +const STATIC_CATALOG_IDS = new Set(MATERIAL_CATALOG.map((item) => item.id)) + +// Embedder-registered library materials (user/community/workspace). Core stays +// passive: hosts push entries in; nothing here fetches. Static catalog entries +// win on id collision so a registration can never shadow a built-in. +const dynamicLibraryMaterials = new Map() +const dynamicLibraryListeners = new Set<() => void>() +let dynamicLibraryVersion = 0 + +function notifyDynamicLibraryChange(): void { + dynamicLibraryVersion += 1 + for (const listener of [...dynamicLibraryListeners]) { + listener() + } +} + +export function registerLibraryMaterials(items: MaterialCatalogItem[]): void { + if (items.length === 0) return + for (const item of items) { + dynamicLibraryMaterials.set(item.id, item) + } + notifyDynamicLibraryChange() +} + +export function unregisterLibraryMaterials(ids: string[]): void { + let changed = false + for (const id of ids) { + changed = dynamicLibraryMaterials.delete(id) || changed + } + if (changed) notifyDynamicLibraryChange() +} + +export function getDynamicLibraryMaterials(): MaterialCatalogItem[] { + return [...dynamicLibraryMaterials.values()] +} + +export function subscribeLibraryMaterials(listener: () => void): () => void { + dynamicLibraryListeners.add(listener) + return () => { + dynamicLibraryListeners.delete(listener) + } +} + +export function getLibraryMaterialsVersion(): number { + return dynamicLibraryVersion +} + export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.category === category) + const items = MATERIAL_CATALOG.filter((item) => item.category === category) + for (const item of dynamicLibraryMaterials.values()) { + if (item.category === category && !STATIC_CATALOG_IDS.has(item.id)) items.push(item) + } + return items } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { if (!id) return undefined - return MATERIAL_CATALOG.find((item) => item.id === id) + return MATERIAL_CATALOG.find((item) => item.id === id) ?? dynamicLibraryMaterials.get(id) } export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 437ee091..08d459be 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -65,6 +65,8 @@ export type { Capabilities, CapabilityCtx, CuttableConfig, + DimensionTerminator, + DimensionTextPosition, DistributionRole, DragAction, DuplicableConfig, @@ -88,6 +90,7 @@ export type { FloorplanPoint, FloorplanStyle, GeometryContext, + GroupMoveSnapArgs, HostableConfig, IconRef, Issue, diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts index ae7b8605..4f14671f 100644 --- a/packages/core/src/registry/subtree.test.ts +++ b/packages/core/src/registry/subtree.test.ts @@ -143,12 +143,121 @@ describe('cloneNodesInto', () => { ) { const anchor = clonedMeasurement.measurement.points[0] expect(Array.isArray(anchor)).toBe(false) - if (!Array.isArray(anchor)) { + if (anchor && !Array.isArray(anchor)) { expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) } } }) + test('remaps associative construction-dimension anchors inside the cloned subtree', () => { + const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' }) + const dimension = makeNode('construction-dimension_1', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:start' }, + fallback: [0, 0, 0], + }, + [1, 0, 0], + { + kind: 'feature', + reference: { nodeId: 'wall_1', featureId: 'wall:end' }, + fallback: [2, 0, 0], + }, + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'continuous', + }) + const result = cloneNodesInto([wall, dimension], { + rootId: 'wall_1' as AnyNodeId, + }) + const clonedDimension = result.nodes.find((node) => node.type === 'construction-dimension') + + expect(clonedDimension?.type).toBe('construction-dimension') + if (clonedDimension?.type === 'construction-dimension') { + const anchor = clonedDimension.anchors[0] + expect(Array.isArray(anchor)).toBe(false) + if (anchor && !Array.isArray(anchor)) { + expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + const lastAnchor = clonedDimension.anchors[2] + expect(Array.isArray(lastAnchor)).toBe(false) + if (lastAnchor && !Array.isArray(lastAnchor)) { + expect(lastAnchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!) + } + } + }) + + test('remaps a construction dimension foundation controller when both are cloned', () => { + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + drawingType: 'foundation-plan', + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [1, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + controllingDimensionId: controller.id, + }) + + const result = cloneNodesInto([controller, dependent], { + rootId: controller.id as AnyNodeId, + }) + const clonedDependent = result.nodes.find( + (node) => node.id === result.idMap.get(dependent.id as AnyNodeId), + ) + + expect(clonedDependent?.type).toBe('construction-dimension') + if (clonedDependent?.type === 'construction-dimension') { + expect(clonedDependent.controllingDimensionId).toBe( + result.idMap.get( + controller.id as AnyNodeId, + ) as typeof clonedDependent.controllingDimensionId, + ) + } + }) + + test('regenerates drawing-sheet identities while preserving external level references', () => { + const original = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + + const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId }) + const cloned = nodes[0] + + expect(cloned?.type).toBe('drawing-sheet') + if (cloned?.type === 'drawing-sheet') { + expect(cloned.placedViews[0]?.levelId).toBe('level_existing') + expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id) + expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id) + } + }) + test('parents the cloned root under opts.parentId when supplied', () => { const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' }) const { nodes } = cloneNodesInto([orig], { diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts index 894669c7..f3e3f10d 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -1,5 +1,9 @@ -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import { generateId } from '../schema/base' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' import type { AnyNode, AnyNodeId } from '../schema/types' // Generic, opinion-free primitives the host app composes to implement @@ -141,7 +145,7 @@ export function cloneNodesInto( const out: AnyNode[] = [] let root: AnyNode | null = null for (const original of nodes) { - const cloned = JSON.parse(JSON.stringify(original)) as AnyNode + let cloned = JSON.parse(JSON.stringify(original)) as AnyNode const freshId = idMap.get(original.id)! ;(cloned as { id: AnyNodeId }).id = freshId // parentId: root's parentId becomes opts.parentId (or preserved @@ -169,6 +173,12 @@ export function cloneNodesInto( if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } if (original.id === opts.rootId) { if (opts.position) { diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index de9e4089..22150bcb 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -51,6 +51,8 @@ export type GeometryContext = { * `scene:` refs. */ materials?: Record + /** Opaque host/plugin context. Core never interprets extension values. */ + extensions?: Readonly> /** * Optional view state — only populated for `def.floorplan` builders. The * 2D floor-plan layer surfaces selection / hover here so kinds can vary @@ -212,12 +214,18 @@ export type FloorplanPalette = { export type FloorplanPoint = readonly [x: number, y: number] +export type DimensionTerminator = 'architectural-tick' | 'filled-arrow' | 'open-arrow' | 'dot' + +export type DimensionTextPosition = 'above' | 'centered' + export type FloorplanStyle = { stroke?: string fill?: string strokeWidth?: number strokeDasharray?: string opacity?: number + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> /** * When `'non-scaling-stroke'`, the SVG renderer interprets `strokeWidth` * as a constant screen-pixel width regardless of viewport zoom. Maps @@ -398,6 +406,8 @@ export type FloorplanGeometry = * of the floor-plan's scene rotation (default 90°). */ upright?: boolean + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Bitmap overlay — captured top-down asset thumbnail, AI-generated @@ -426,6 +436,8 @@ export type FloorplanGeometry = children: FloorplanGeometry[] /** Optional transform applied to all children. Rotation in radians. */ transform?: { translate?: FloorplanPoint; rotate?: number } + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> } /** * Hatched fill overlay — same polygon shape as the kind's main fill but @@ -629,16 +641,64 @@ export type FloorplanGeometry = kind: 'dimension' start: FloorplanPoint end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint /** Outward-pointing unit normal — the dimension line offsets along this. */ offsetNormal: FloorplanPoint /** Distance (plan units) from the edge to the dimension line. */ offsetDistance: number /** How far past the offset point the extension line continues. */ extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position relative to the baseline. Defaults above the line. */ + textPosition?: DimensionTextPosition text: string /** Optional override for the line/text colour. Defaults to the palette accent. */ stroke?: string } + | { + kind: 'dimension-string' + segments: readonly { + start: FloorplanPoint + end: FloorplanPoint + /** + * Optional explicit dimension-line endpoints. Use these when the + * measured origins sit at different depths, such as stepped facades or + * an exterior column row. Extension lines still originate at + * `start`/`end`, while the measurement is drawn between these aligned + * baseline points. + */ + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint + text: string + }[] + /** Outward-pointing unit normal shared by every segment in the string. */ + offsetNormal: FloorplanPoint + /** Distance (plan units) from each measured origin to its dimension line. */ + offsetDistance: number + /** How far past each offset point the extension line continues. */ + extensionOvershoot: number + /** Optional gap before each extension line starts. Defaults to the project/document profile. */ + extensionStartGap?: number + /** Dimension-line terminator shared by every segment. Defaults to an architectural tick. */ + terminator?: DimensionTerminator + /** Dimension text position shared by every segment. Defaults above the line. */ + textPosition?: DimensionTextPosition + /** Optional override for the line/text colour. Defaults to the palette accent. */ + stroke?: string + /** Opaque renderer/plugin metadata. Core never interprets these values. */ + metadata?: Readonly> + } // ─── FloorplanAffordance ───────────────────────────────────────────── // @@ -853,6 +913,8 @@ export type NodeDefinition> = { schemaVersion: number schema: S category: NodeCategory + /** Opaque host/plugin contributions. Core stores but never interprets them. */ + extensions?: Readonly> surfaceRole?: SurfaceRole /** * Show a floor direction-triangle while placing/moving — the kind has a @@ -889,7 +951,6 @@ export type NodeDefinition> = { portConnectivityFollow?: boolean defaults: () => Omit, 'id' | 'type'> - migrate?: Record unknown> capabilities: Capabilities relations?: Relations diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 7727c986..fd0e8f7f 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -51,8 +51,33 @@ export { ColumnStyle, ColumnSupportStyle, } from './nodes/column' +export { + CONSTRUCTION_DRAWING_TYPES, + ConstructionDimensionBaseline, + ConstructionDimensionChainMode, + ConstructionDimensionDatumPolicy, + ConstructionDimensionDrawingOverride, + ConstructionDimensionDrawingPresentation, + ConstructionDimensionImperialPrecision, + ConstructionDimensionMetricNotation, + ConstructionDimensionMode, + ConstructionDimensionNode, + ConstructionDimensionTerminator, + ConstructionDimensionTextPosition, + ConstructionDrawingType, + constructionDimensionRequiredAnchorCount, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './nodes/construction-dimension' export { CupolaNode } from './nodes/cupola' -export { DoorNode, DoorSegment } from './nodes/door' +export { + DoorNode, + DoorSegment, + OpeningConstructionType, + OpeningDimensionReference, +} from './nodes/door' export { DormerNode, type DormerSurfaceMaterialRole, @@ -60,6 +85,25 @@ export { getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' export { DownspoutNode } from './nodes/downspout' +export { + DrawingSheetAnnotationProfile, + DrawingSheetDocumentMarker, + DrawingSheetDocumentMarkerKind, + DrawingSheetGeneralNote, + DrawingSheetGeneralNoteSet, + DrawingSheetKeyedNote, + DrawingSheetKeyedNoteDefinition, + DrawingSheetKeyedNoteInstance, + DrawingSheetNode, + DrawingSheetOrientation, + DrawingSheetPaperSize, + DrawingSheetPlacedView, + DrawingSheetRect, + DrawingSheetScale, + DrawingSheetSchedulePlacement, + DrawingSheetTitleBlock, + remapDrawingSheetReferences, +} from './nodes/drawing-sheet' export { DuctFittingNode } from './nodes/duct-fitting' export { DuctSegmentNode } from './nodes/duct-segment' export { DuctTerminalNode } from './nodes/duct-terminal' @@ -184,7 +228,7 @@ export { SkylightType, type SkylightTypePreset, } from './nodes/skylight' -export { SlabNode } from './nodes/slab' +export { MIN_SLAB_THICKNESS, SlabNode } from './nodes/slab' export { SolarPanelMaterialRole, SolarPanelNode, @@ -200,9 +244,13 @@ export { StairType, } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' +export { StructuralGridNode } from './nodes/structural-grid' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { TurbineVentNode } from './nodes/turbine-vent' export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, WallBandSurfaceSlotId, WallFaceBand, WallFaceBandConfig, @@ -215,11 +263,18 @@ export { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, getEffectiveWallSurfaceMaterial, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyLayers, + getWallAssemblyThickness, getWallBandSlotId, + getWallDatumEligibleLayers, getWallFaceBandConfig, getWallFaceBandForHeight, getWallSurfaceMaterialSignature, getWallSurfaceSideFromBandSlot, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -230,11 +285,18 @@ export { WALL_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WALL_TRIM_DEFAULTS, + WallAssemblyLayerRole, + WallDimensionDatum, WallNode, WallTreatmentSide, WallTrimProfile, } from './nodes/wall' -export { WindowNode, WindowType } from './nodes/window' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, + WindowType, +} from './nodes/window' export { ZoneNode } from './nodes/zone' export { generateSceneMaterialId, SceneMaterial, type SceneMaterialId } from './scene-material' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index e4a4e470..c1acb785 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,13 +1,16 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { DrawingSheetNode } from './drawing-sheet' import { ElevatorNode } from './elevator' import { LevelNode } from './level' export const BuildingNode = BaseNode.extend({ id: objectId('building'), type: nodeType('building'), - children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]), + children: z + .array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id])) + .default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), }).describe( @@ -15,7 +18,7 @@ export const BuildingNode = BaseNode.extend({ Building node - used to represent a building - position: position in site coordinate system - rotation: rotation in site coordinate system - - children: array of level nodes and building-level systems such as elevators + - children: array of level nodes, building-level systems such as elevators, and drawing sheets `, ) diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a84e3248..a0949a49 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -80,6 +80,8 @@ export type CabinetCompartmentSchema = z.infer const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), width: z.number().min(0.05).max(3).default(0.5), depth: z.number().min(0.3).max(1.2).default(0.5), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index b94aaaa2..cb673d2c 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -18,7 +18,12 @@ export const CeilingNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - height: z.number().default(2.5), // Height in meters + // Height in meters. Absent = the ceiling follows the level top: its + // effective height is the same bound its write-clamp uses — + // min(storey plane, lowest covering-slab underside over the polygon) + // − CEILING_CLAMP_MARGIN (see `resolveCeilingHeight`). Present = an + // explicit custom height, still write-clamped under that bound. + height: z.number().optional(), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -26,6 +31,7 @@ export const CeilingNode = BaseNode.extend({ - polygon: array of [x, z] points defining the ceiling boundary - holes: array of polygons representing holes in the ceiling - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts + - height: explicit height in meters; absent = follows the level top automatically - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index de411604..1dc704cf 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -86,6 +86,8 @@ export const ColumnNode = BaseNode.extend({ type: nodeType('column'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), style: ColumnStyle.default('plain'), crossSection: ColumnCrossSection.default('round'), height: z.number().positive().default(2.5), diff --git a/packages/core/src/schema/nodes/construction-dimension.test.ts b/packages/core/src/schema/nodes/construction-dimension.test.ts new file mode 100644 index 00000000..33cc7e56 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + ConstructionDimensionNode, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, +} from './construction-dimension' + +describe('ConstructionDimensionNode', () => { + test('creates valid free-anchor defaults', () => { + const node = ConstructionDimensionNode.parse({}) + + expect(node.type).toBe('construction-dimension') + expect(node.id).toMatch(/^construction-dimension_/) + expect(node.anchors).toEqual([ + [0, 0, 0], + [1, 0, 0], + ]) + expect(node.baseline).toEqual({ origin: [0, 0.6], direction: [1, 0] }) + expect(node.chainMode).toBe('point-to-point') + expect(node).toMatchObject({ + mode: 'linear', + featureCount: 1, + showCenterMark: true, + prefix: '', + suffix: '', + textOverride: null, + datumPolicy: 'centerline', + terminator: 'architectural-tick', + textPosition: 'above', + imperialPrecision: '1/16', + metricNotation: 'meters', + extensionStartGap: 0.075, + extensionOvershoot: 0.12, + drawingType: 'floor-plan', + drawingOverrides: [], + controllingDimensionId: null, + }) + }) + + test('accepts semantic anchors and rejects a collapsed baseline direction', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + { + kind: 'feature', + reference: { nodeId: 'wall_a', featureId: 'centerline', parameters: { t: 0.25 } }, + fallback: [1, 0, 0], + }, + [3, 0, 0], + ], + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ + baseline: { origin: [0, 0], direction: [0, 0] }, + }).success, + ).toBe(false) + }) + + test('accepts continuous strings with three or more anchors', () => { + expect( + ConstructionDimensionNode.safeParse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + ], + chainMode: 'continuous', + }).success, + ).toBe(true) + expect( + ConstructionDimensionNode.safeParse({ anchors: [[0, 0, 0]], chainMode: 'continuous' }) + .success, + ).toBe(false) + }) + + test('accepts curved and circular notation settings', () => { + expect( + ConstructionDimensionNode.safeParse({ + mode: 'diameter', + featureCount: 6, + prefix: 'TYP · ', + suffix: ' CLR', + }).success, + ).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'arc-length' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ mode: 'angular' }).success).toBe(true) + expect(ConstructionDimensionNode.safeParse({ featureCount: 0 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ textOverride: '' }).success).toBe(false) + }) + + test('accepts dimension-standard overrides and rejects invalid drafting distances', () => { + const node = ConstructionDimensionNode.parse({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + + expect(node).toMatchObject({ + datumPolicy: 'finish-face', + terminator: 'filled-arrow', + textPosition: 'centered', + imperialPrecision: '1/8', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + expect(ConstructionDimensionNode.safeParse({ extensionStartGap: -0.01 }).success).toBe(false) + expect(ConstructionDimensionNode.safeParse({ extensionOvershoot: 2 }).success).toBe(false) + }) + + test('coordinates one associative dimension across persistent drawing types', () => { + const node = ConstructionDimensionNode.parse({ + drawingType: 'foundation-plan', + drawingOverrides: [ + { drawingType: 'floor-plan', presentation: 'controlled' }, + { drawingType: 'roof-plan', presentation: 'shown' }, + ], + controllingDimensionId: 'construction-dimension_foundation', + }) + + expect(resolveConstructionDimensionDrawingPresentation(node, 'foundation-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'floor-plan')).toBe('controlled') + expect(resolveConstructionDimensionDrawingPresentation(node, 'roof-plan')).toBe('shown') + expect(resolveConstructionDimensionDrawingPresentation(node, 'site-plan')).toBe('omit') + }) + + test('stores only drawing presentations that differ from the primary defaults', () => { + const node = ConstructionDimensionNode.parse({}) + const shown = setConstructionDimensionDrawingPresentation(node, 'roof-plan', 'shown') + expect(shown).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown', suppressedSegmentIndexes: [] }, + ]) + expect( + setConstructionDimensionDrawingPresentation( + { ...node, drawingOverrides: shown }, + 'roof-plan', + 'omit', + ), + ).toEqual([]) + }) + + test('stores view-specific suppressed segment indexes without changing default presentation', () => { + const node = ConstructionDimensionNode.parse({}) + const drawingOverrides = setConstructionDimensionDrawingSuppressedSegments( + node, + 'floor-plan', + [3, 1, 1, -1], + ) + + expect(drawingOverrides).toEqual([ + { + drawingType: 'floor-plan', + presentation: 'shown', + suppressedSegmentIndexes: [1, 3], + }, + ]) + expect( + resolveConstructionDimensionDrawingOverride({ ...node, drawingOverrides }, 'floor-plan') + ?.suppressedSegmentIndexes, + ).toEqual([1, 3]) + expect( + setConstructionDimensionDrawingSuppressedSegments( + { ...node, drawingOverrides }, + 'floor-plan', + [], + ), + ).toEqual([]) + }) +}) diff --git a/packages/core/src/schema/nodes/construction-dimension.ts b/packages/core/src/schema/nodes/construction-dimension.ts new file mode 100644 index 00000000..c831b116 --- /dev/null +++ b/packages/core/src/schema/nodes/construction-dimension.ts @@ -0,0 +1,205 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MeasurementAnchor } from './measurement' + +const FiniteCoordinate = z.number().finite() + +export const ConstructionDimensionBaseline = z + .object({ + origin: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([0, 0.6]), + direction: z.tuple([FiniteCoordinate, FiniteCoordinate]).default([1, 0]), + }) + .superRefine((baseline, ctx) => { + if (Math.hypot(baseline.direction[0], baseline.direction[1]) <= 1e-9) { + ctx.addIssue({ + code: 'custom', + path: ['direction'], + message: 'Construction dimension baseline direction must be non-zero', + }) + } + }) + +export const ConstructionDimensionChainMode = z.enum(['point-to-point', 'continuous']) +export const ConstructionDimensionMode = z.enum([ + 'linear', + 'radius', + 'diameter', + 'center-mark', + 'chord', + 'arc-length', + 'angular', + 'coordinate', +]) +export const ConstructionDrawingType = z.enum([ + 'floor-plan', + 'foundation-plan', + 'reflected-ceiling-plan', + 'roof-plan', + 'site-plan', +]) +export const ConstructionDimensionDrawingPresentation = z.enum(['shown', 'omit', 'controlled']) +export const ConstructionDimensionDrawingOverride = z.object({ + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, + suppressedSegmentIndexes: z.array(z.number().int().min(0).max(999)).max(200).default([]), +}) +export const ConstructionDimensionDatumPolicy = z.enum([ + 'centerline', + 'wall-face', + 'structural-face', + 'finish-face', +]) +export const ConstructionDimensionTerminator = z.enum([ + 'architectural-tick', + 'filled-arrow', + 'open-arrow', + 'dot', +]) +export const ConstructionDimensionTextPosition = z.enum(['above', 'centered']) +export const ConstructionDimensionImperialPrecision = z.enum(['1', '1/2', '1/4', '1/8', '1/16']) +export const ConstructionDimensionMetricNotation = z.enum(['meters', 'millimeters']) + +export const ConstructionDimensionNode = BaseNode.extend({ + id: objectId('construction-dimension'), + type: nodeType('construction-dimension'), + anchors: z + .array(MeasurementAnchor) + .min(2) + .default([ + [0, 0, 0], + [1, 0, 0], + ]), + baseline: ConstructionDimensionBaseline.default({ origin: [0, 0.6], direction: [1, 0] }), + chainMode: ConstructionDimensionChainMode.default('point-to-point'), + mode: ConstructionDimensionMode.default('linear'), + featureCount: z.number().int().min(1).max(999).default(1), + showCenterMark: z.boolean().default(true), + prefix: z.string().max(40).default(''), + suffix: z.string().max(40).default(''), + textOverride: z.string().trim().min(1).max(120).nullable().default(null), + datumPolicy: ConstructionDimensionDatumPolicy.default('centerline'), + terminator: ConstructionDimensionTerminator.default('architectural-tick'), + textPosition: ConstructionDimensionTextPosition.default('above'), + imperialPrecision: ConstructionDimensionImperialPrecision.default('1/16'), + metricNotation: ConstructionDimensionMetricNotation.default('meters'), + extensionStartGap: z.number().finite().min(0).max(1).default(0.075), + extensionOvershoot: z.number().finite().min(0).max(1).default(0.12), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingOverrides: z.array(ConstructionDimensionDrawingOverride).max(5).default([]), + controllingDimensionId: objectId('construction-dimension').nullable().default(null), +}).describe( + dedent` + Construction dimension node - an associative floor-plan construction dimension + - anchors: two or more free or semantic feature anchors that supply the witness origins + - baseline.origin: a point on the independently placed dimension line + - baseline.direction: the fixed plan direction used to project the witness origins + - chainMode: point-to-point for one segment or continuous for adjacent dimension strings + - mode: linear, radius, diameter, center mark, chord, arc length, angular, or coordinate + - featureCount: repeated-feature multiplier used by diameter/radius and other notation + - showCenterMark: displays the resolved circle/angle center where applicable + - prefix/suffix/textOverride: document notation overrides without changing geometry + - datumPolicy/terminator/textPosition/imperialPrecision/metricNotation/extensionStartGap/extensionOvershoot: dimension-standard overrides + - drawingType: the primary persistent drawing that owns the dimension + - drawingOverrides: omit, show, or foundation-control presentation per drawing type + - controllingDimensionId: foundation dimension whose associative geometry controls this dimension + `, +) + +export type ConstructionDimensionBaseline = z.infer +export type ConstructionDimensionChainMode = z.infer +export type ConstructionDimensionMode = z.infer +export type ConstructionDrawingType = z.infer +export type ConstructionDimensionDrawingPresentation = z.infer< + typeof ConstructionDimensionDrawingPresentation +> +export type ConstructionDimensionDrawingOverride = z.infer< + typeof ConstructionDimensionDrawingOverride +> +export type ConstructionDimensionDatumPolicy = z.infer +export type ConstructionDimensionTerminator = z.infer +export type ConstructionDimensionTextPosition = z.infer +export type ConstructionDimensionImperialPrecision = z.infer< + typeof ConstructionDimensionImperialPrecision +> +export type ConstructionDimensionMetricNotation = z.infer< + typeof ConstructionDimensionMetricNotation +> +export type ConstructionDimensionNode = z.infer + +export const CONSTRUCTION_DRAWING_TYPES = ConstructionDrawingType.options + +export function resolveConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingPresentation { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override?.presentation ?? (node.drawingType === drawingType ? 'shown' : 'omit') +} + +export function resolveConstructionDimensionDrawingOverride( + node: Pick, + drawingType: ConstructionDrawingType, +): ConstructionDimensionDrawingOverride | null { + let override: ConstructionDimensionDrawingOverride | undefined + for (const entry of node.drawingOverrides) { + if (entry.drawingType === drawingType) override = entry + } + return override ?? null +} + +export function setConstructionDimensionDrawingPresentation( + node: Pick, + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { + drawingType, + presentation, + suppressedSegmentIndexes: existing?.suppressedSegmentIndexes ?? [], + } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function setConstructionDimensionDrawingSuppressedSegments( + node: Pick, + drawingType: ConstructionDrawingType, + suppressedSegmentIndexes: readonly number[], +): ConstructionDimensionDrawingOverride[] { + const defaultPresentation = node.drawingType === drawingType ? 'shown' : 'omit' + const existing = resolveConstructionDimensionDrawingOverride(node, drawingType) + const presentation = existing?.presentation ?? defaultPresentation + const suppressed = normalizeSuppressedSegmentIndexes(suppressedSegmentIndexes) + const withoutDrawing = node.drawingOverrides.filter((entry) => entry.drawingType !== drawingType) + const next = { drawingType, presentation, suppressedSegmentIndexes: suppressed } + return isDefaultConstructionDimensionDrawingOverride(next, defaultPresentation) + ? withoutDrawing + : [...withoutDrawing, next] +} + +export function constructionDimensionRequiredAnchorCount(mode: ConstructionDimensionMode): number { + return mode === 'arc-length' || mode === 'angular' ? 3 : 2 +} + +function isDefaultConstructionDimensionDrawingOverride( + override: ConstructionDimensionDrawingOverride, + defaultPresentation: ConstructionDimensionDrawingPresentation, +): boolean { + return ( + override.presentation === defaultPresentation && override.suppressedSegmentIndexes.length === 0 + ) +} + +function normalizeSuppressedSegmentIndexes(indexes: readonly number[]): number[] { + return [...new Set(indexes.filter((index) => Number.isInteger(index) && index >= 0))].sort( + (left, right) => left - right, + ) +} diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 26b4170e..ab0aaab9 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -19,6 +19,13 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer export const DoorCategory = z.enum(['interior', 'garage']) +export const OpeningConstructionType = z.enum(['framed', 'masonry']) +export const OpeningDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) export const DoorType = z.enum([ 'hinged', 'double', @@ -34,6 +41,8 @@ export const DoorType = z.enum([ export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) export type DoorCategory = z.infer +export type OpeningConstructionType = z.infer +export type OpeningDimensionReference = z.infer export type DoorType = z.infer export type DoorTrackStyle = z.infer @@ -63,6 +72,20 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Construction-document identity. `mark` overrides the deterministic + // level fallback (101, 102, ...). Rough-opening dimensions stay optional + // because they are manufacturer/framing inputs, not safe derivations from + // the nominal modeled size. + mark: z.string().trim().max(16).optional(), + constructionType: OpeningConstructionType.default('framed'), + dimensionReference: OpeningDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Door family doorCategory: DoorCategory.default('interior'), doorType: DoorType.default('hinged'), diff --git a/packages/core/src/schema/nodes/drawing-sheet.test.ts b/packages/core/src/schema/nodes/drawing-sheet.test.ts new file mode 100644 index 00000000..216295e1 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode } from './building' +import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet' + +describe('DrawingSheetNode', () => { + test('creates persistent sheet defaults', () => { + const sheet = DrawingSheetNode.parse({}) + + expect(sheet.type).toBe('drawing-sheet') + expect(sheet.id).toMatch(/^drawing-sheet_/) + expect(sheet).toMatchObject({ + sheetNumber: 'A1.0', + sheetTitle: 'Floor Plan', + paperSize: 'arch-b', + orientation: 'landscape', + customPaperWidth: null, + customPaperHeight: null, + annotationProfile: 'architectural-default', + placedViews: [], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [], + keyedNoteInstances: [], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + titleBlock: { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', + }, + }) + }) + + test('stores placed views, notes, schedules, and title-block metadata', () => { + const sheet = DrawingSheetNode.parse({ + sheetNumber: 'A2.1', + sheetTitle: 'Enlarged Plans', + paperSize: 'custom', + customPaperWidth: 24, + customPaperHeight: 36, + placedViews: [ + { + id: 'drawing-view_main', + drawingType: 'floor-plan', + drawingNumber: '2', + title: 'Main Floor Plan', + levelId: 'level_main', + scale: '1/4"=1\'-0"', + viewport: { x: 1, y: 1, width: 12, height: 8 }, + }, + ], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }], + }, + ], + generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }], + keyedNoteDefinitions: [ + { id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' }, + ], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_patch-slab-1', + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }, + { + id: 'keyed-note-instance_patch-slab-2', + definitionId: 'keyed-note_patch-slab', + position: [5, 4], + }, + ], + keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }], + documentMarkers: [ + { + id: 'sheet-marker_wall-a', + kind: 'wall-tag', + label: 'W1', + placedViewId: 'drawing-view_main', + position: [2, 3], + }, + { + id: 'sheet-marker_revision-a', + kind: 'revision-cloud', + label: '1', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }, + ], + schedules: [ + { + id: 'sheet-schedule_room', + scheduleType: 'room', + title: 'Room Schedule', + region: { x: 15, y: 1, width: 6, height: 5 }, + }, + ], + titleBlock: { + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + }, + }) + + expect(sheet.placedViews[0]).toMatchObject({ + drawingType: 'floor-plan', + levelId: 'level_main', + annotationProfile: 'architectural-default', + showNorthArrow: true, + showGraphicScale: true, + }) + expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.') + expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project']) + expect(sheet.generalNoteSets[0]).toMatchObject({ + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ text: 'COORDINATE WITH OWNER.' }], + }) + expect(sheet.keyedNoteLegend[0]).toEqual({ + key: 'A', + text: 'ALIGN WITH EXISTING WALL.', + }) + expect(sheet.keyedNoteDefinitions[0]).toEqual({ + id: 'keyed-note_patch-slab', + key: 'A', + text: 'PATCH EXISTING SLAB.', + }) + expect(sheet.keyedNoteInstances).toHaveLength(2) + expect(sheet.keyedNoteInstances[0]).toMatchObject({ + definitionId: 'keyed-note_patch-slab', + placedViewId: 'drawing-view_main', + position: [3.25, 2.5], + }) + expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull() + expect(sheet.documentMarkers).toHaveLength(2) + expect(sheet.documentMarkers[0]).toMatchObject({ + kind: 'wall-tag', + label: 'W1', + position: [2, 3], + }) + expect(sheet.documentMarkers[1]).toMatchObject({ + kind: 'revision-cloud', + revisionId: 'A', + points: [ + [1, 1], + [2, 1], + [2, 2], + [1, 2], + ], + }) + expect(sheet.schedules[0]?.title).toBe('Room Schedule') + expect(sheet.titleBlock).toMatchObject({ + projectName: 'House', + projectNumber: '2401', + clientName: 'Owner', + drawnBy: '', + }) + }) + + test('can live under a building instead of a level', () => { + const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' }) + + expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([ + 'level_main', + sheet.id, + ]) + }) + + test('remaps sheet-local identities and their references together', () => { + const sheet = DrawingSheetNode.parse({ + placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }], + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }], + }, + ], + generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a1', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + }, + ], + documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }], + schedules: [{ id: 'sheet-schedule_a' }], + }) + const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']])) + + expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id) + expect(remapped.placedViews[0]?.levelId).toBe('level_cloned') + expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id) + expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe( + sheet.generalNoteSets[0]?.notes[0]?.id, + ) + expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id) + expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id) + expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id) + expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id) + expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id) + expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id) + }) +}) diff --git a/packages/core/src/schema/nodes/drawing-sheet.ts b/packages/core/src/schema/nodes/drawing-sheet.ts new file mode 100644 index 00000000..372addf0 --- /dev/null +++ b/packages/core/src/schema/nodes/drawing-sheet.ts @@ -0,0 +1,263 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, generateId, nodeType, objectId } from '../base' +import { ConstructionDrawingType } from './construction-dimension' + +const PositiveFinite = z.number().finite().positive() +const SheetCoordinate = z.number().finite().min(0) + +export const DrawingSheetPaperSize = z.enum([ + 'letter', + 'tabloid', + 'arch-a', + 'arch-b', + 'arch-c', + 'a4', + 'a3', + 'custom', +]) +export const DrawingSheetOrientation = z.enum(['portrait', 'landscape']) +export const DrawingSheetScale = z.enum([ + '1:20', + '1:25', + '1:50', + '1:75', + '1:100', + '1/8"=1\'-0"', + '1/4"=1\'-0"', + '1/2"=1\'-0"', + '1"=1\'-0"', +]) +export const DrawingSheetAnnotationProfile = z.enum([ + 'architectural-default', + 'presentation', + 'permit', +]) + +export const DrawingSheetRect = z.object({ + x: SheetCoordinate.default(0), + y: SheetCoordinate.default(0), + width: PositiveFinite.default(1), + height: PositiveFinite.default(1), +}) + +export const DrawingSheetPlacedView = z.object({ + id: objectId('drawing-view'), + drawingType: ConstructionDrawingType.default('floor-plan'), + drawingNumber: z.string().trim().min(1).max(24).default('1'), + title: z.string().trim().min(1).max(80).default('Floor Plan'), + levelId: objectId('level').nullable().default(null), + scale: DrawingSheetScale.default('1/4"=1\'-0"'), + viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + showNorthArrow: z.boolean().default(true), + showGraphicScale: z.boolean().default(true), +}) + +export const DrawingSheetGeneralNote = z.object({ + id: objectId('sheet-note'), + number: z.number().int().positive().default(1), + text: z.string().trim().min(1).max(500).default('GENERAL NOTE'), +}) + +export const DrawingSheetGeneralNoteSet = z.object({ + id: objectId('sheet-note-set'), + name: z.string().trim().min(1).max(80).default('General Notes'), + notes: z.array(DrawingSheetGeneralNote).max(200).default([]), +}) + +export const DrawingSheetKeyedNote = z.object({ + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteDefinition = z.object({ + id: objectId('keyed-note'), + key: z.string().trim().min(1).max(16).default('1'), + text: z.string().trim().min(1).max(500).default('KEYED NOTE'), +}) + +export const DrawingSheetKeyedNoteInstance = z.object({ + id: objectId('keyed-note-instance'), + definitionId: DrawingSheetKeyedNoteDefinition.shape.id, + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), +}) + +export const DrawingSheetDocumentMarkerKind = z.enum([ + 'wall-tag', + 'glazing-tag', + 'assembly-tag', + 'section-callout', + 'elevation-callout', + 'detail-reference', + 'delta-marker', + 'revision-cloud', +]) + +export const DrawingSheetDocumentMarker = z.object({ + id: objectId('sheet-marker'), + kind: DrawingSheetDocumentMarkerKind.default('detail-reference'), + placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null), + label: z.string().trim().min(1).max(32).default('1'), + title: z.string().trim().max(120).default(''), + sheetReference: z.string().trim().max(24).default(''), + drawingReference: z.string().trim().max(24).default(''), + revisionId: z.string().trim().max(16).default(''), + position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]), + endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null), + points: z + .array(z.tuple([SheetCoordinate, SheetCoordinate])) + .max(64) + .default([]), +}) + +export const DrawingSheetSchedulePlacement = z.object({ + id: objectId('sheet-schedule'), + scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'), + title: z.string().trim().min(1).max(80).default('Room Schedule'), + region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }), +}) + +export const DrawingSheetTitleBlock = z.object({ + projectName: z.string().trim().max(120).default(''), + projectNumber: z.string().trim().max(40).default(''), + clientName: z.string().trim().max(120).default(''), + drawnBy: z.string().trim().max(40).default(''), + checkedBy: z.string().trim().max(40).default(''), + issueDate: z.string().trim().max(40).default(''), + revision: z.string().trim().max(20).default(''), +}) + +const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = { + projectName: '', + projectNumber: '', + clientName: '', + drawnBy: '', + checkedBy: '', + issueDate: '', + revision: '', +} + +export const DrawingSheetNode = BaseNode.extend({ + id: objectId('drawing-sheet'), + type: nodeType('drawing-sheet'), + sheetNumber: z.string().trim().min(1).max(24).default('A1.0'), + sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'), + paperSize: DrawingSheetPaperSize.default('arch-b'), + orientation: DrawingSheetOrientation.default('landscape'), + customPaperWidth: PositiveFinite.nullable().default(null), + customPaperHeight: PositiveFinite.nullable().default(null), + placedViews: z.array(DrawingSheetPlacedView).max(32).default([]), + annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'), + generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]), + generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]), + generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]), + keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]), + keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]), + keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]), + documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]), + schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]), + titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK), +}).describe( + dedent` + Drawing sheet node - persistent construction-document sheet metadata + - sheetNumber/sheetTitle: sheet identity in the drawing set + - paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition + - placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles + - generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes + - keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries + - documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds + - schedules/titleBlock: sheet-level documentation content and title-block metadata + `, +) + +export type DrawingSheetPaperSize = z.infer +export type DrawingSheetOrientation = z.infer +export type DrawingSheetScale = z.infer +export type DrawingSheetAnnotationProfile = z.infer +export type DrawingSheetRect = z.infer +export type DrawingSheetPlacedView = z.infer +export type DrawingSheetGeneralNote = z.infer +export type DrawingSheetGeneralNoteSet = z.infer +export type DrawingSheetKeyedNote = z.infer +export type DrawingSheetKeyedNoteDefinition = z.infer +export type DrawingSheetKeyedNoteInstance = z.infer +export type DrawingSheetDocumentMarker = z.infer +export type DrawingSheetDocumentMarkerKind = z.infer +export type DrawingSheetSchedulePlacement = z.infer +export type DrawingSheetTitleBlock = z.infer +export type DrawingSheetNode = z.infer + +/** + * Rewrites every scene and sheet-local identity carried by a drawing sheet. + * External scene references are preserved when they are not present in + * `sceneIdMap`, which keeps a duplicated sheet attached to its existing level. + */ +export function remapDrawingSheetReferences( + sheet: DrawingSheetNode, + sceneIdMap: ReadonlyMap, +): DrawingSheetNode { + const placedViewIds = new Map( + sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const), + ) + const noteSetIds = new Map( + sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const), + ) + const noteIds = new Map( + [...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map( + (note) => [note.id, generateId('sheet-note')] as const, + ), + ) + const keyedDefinitionIds = new Map( + sheet.keyedNoteDefinitions.map( + (definition) => [definition.id, generateId('keyed-note')] as const, + ), + ) + + return { + ...sheet, + placedViews: sheet.placedViews.map((view) => ({ + ...view, + id: placedViewIds.get(view.id)!, + levelId: view.levelId + ? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId) + : null, + })), + generalNoteSetIds: sheet.generalNoteSetIds.map( + (id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number], + ), + generalNoteSets: sheet.generalNoteSets.map((set) => ({ + ...set, + id: noteSetIds.get(set.id)!, + notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + })), + generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })), + keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({ + ...definition, + id: keyedDefinitionIds.get(definition.id)!, + })), + keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({ + ...instance, + id: generateId('keyed-note-instance'), + definitionId: (keyedDefinitionIds.get(instance.definitionId) ?? + instance.definitionId) as typeof instance.definitionId, + placedViewId: instance.placedViewId + ? ((placedViewIds.get(instance.placedViewId) ?? + instance.placedViewId) as typeof instance.placedViewId) + : null, + })), + documentMarkers: sheet.documentMarkers.map((marker) => ({ + ...marker, + id: generateId('sheet-marker'), + placedViewId: marker.placedViewId + ? ((placedViewIds.get(marker.placedViewId) ?? + marker.placedViewId) as typeof marker.placedViewId) + : null, + })), + schedules: sheet.schedules.map((schedule) => ({ + ...schedule, + id: generateId('sheet-schedule'), + })), + } +} diff --git a/packages/core/src/schema/nodes/duct-terminal.ts b/packages/core/src/schema/nodes/duct-terminal.ts index 5eefff31..6c5f058a 100644 --- a/packages/core/src/schema/nodes/duct-terminal.ts +++ b/packages/core/src/schema/nodes/duct-terminal.ts @@ -21,6 +21,8 @@ export const DuctTerminalNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), // Which surface the terminal mounts on. Drives face orientation and // which way the collar (and its port) points. diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index e8e76874..26ebff77 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -32,6 +32,9 @@ export const FenceNode = BaseNode.extend({ tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), height: z.number().default(1.8), thickness: z.number().default(0.08), + // Persisted slab-support host — the fence sits on that slab's walking + // surface (see ItemNode.supportSlabId for the host rules). + supportSlabId: z.string().optional(), curveOffset: z.number().optional(), baseHeight: z.number().default(0.22), postSpacing: z.number().default(2), @@ -54,6 +57,7 @@ export const FenceNode = BaseNode.extend({ - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - height/thickness: overall fence dimensions in meters + - supportSlabId: optional slab host; the fence stands on that slab's walking surface (elevation) - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts index a0e00a13..6bde1e2c 100644 --- a/packages/core/src/schema/nodes/hvac-equipment.ts +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -23,6 +23,8 @@ export const HvacEquipmentNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Yaw in radians. rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), // Cabinet dimensions in meters. Defaults match a typical upflow // furnace cabinet (~22" × 28" footprint, ~43" tall). diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index f8732482..00c5a998 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -143,6 +143,20 @@ export const ItemNode = BaseNode.extend({ roofSegmentId: z.string().optional(), roofFace: z.enum(['front', 'back', 'right', 'left']).optional(), + // Persisted floor-support host (canonical doc — the same field on other + // floor-placed kinds and walls follows these rules). Written at + // placement/commit ONLY when overlapping slabs disagree on elevation + // (ambiguity); absent/null means "elect the support fresh on every + // read", which is the historical behavior. Read paths PREFER this slab + // while it still exists and still overlaps the node's footprint, and + // silently fall back to election otherwise. Deleting the host slab + // strips the field (deleteNodesAction); a host merely reshaped away is + // deliberately kept so hosting resumes if the slab's polygon returns. + // The sentinel value 'ground' (GROUND_SUPPORT_ID) pins the node to the + // level base — written when a pointer-capped commit elected the ground + // while a slab (e.g. an elevated deck) still overlapped the footprint. + supportSlabId: z.string().optional(), + // Denormalized references to collections this node belongs to collectionIds: z.array(z.custom()).optional(), diff --git a/packages/core/src/schema/nodes/level.test.ts b/packages/core/src/schema/nodes/level.test.ts index f950f122..f30d91bc 100644 --- a/packages/core/src/schema/nodes/level.test.ts +++ b/packages/core/src/schema/nodes/level.test.ts @@ -56,4 +56,9 @@ describe('LevelNode', () => { expect(children).toEqual(['tree_plugin-child', 'flower_plugin-child', 'grass_plugin-child']) }) + + test('does not materialize height on parse — absence marks unmigrated legacy data', () => { + expect('height' in LevelNode.parse({})).toBe(false) + expect(LevelNode.parse({ height: 3 }).height).toBe(3) + }) }) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 0360598f..60d15aa9 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import type { CeilingNode } from './ceiling' import type { ColumnNode } from './column' +import type { ConstructionDimensionNode } from './construction-dimension' import type { DuctFittingNode } from './duct-fitting' import type { DuctSegmentNode } from './duct-segment' import type { DuctTerminalNode } from './duct-terminal' @@ -22,6 +23,7 @@ import type { ShelfNode } from './shelf' import type { SlabNode } from './slab' import type { SpawnNode } from './spawn' import type { StairNode } from './stair' +import type { StructuralGridNode } from './structural-grid' import type { WallNode } from './wall' import type { ZoneNode } from './zone' @@ -29,6 +31,8 @@ type CoreLevelChildId = | WallNode['id'] | FenceNode['id'] | ColumnNode['id'] + | ConstructionDimensionNode['id'] + | StructuralGridNode['id'] | ItemNode['id'] | ZoneNode['id'] | SlabNode['id'] @@ -60,11 +64,18 @@ export const LevelNode = BaseNode.extend({ children: z.array(LevelChildId).default([]), // Specific props level: z.number().default(0), + /** + * Stored storey height in meters (floor-to-floor). No zod default on + * purpose: absence marks unmigrated legacy data and gates the load-time + * migration; a schema default would materialize silently through .parse(). + */ + height: z.number().optional(), }).describe( dedent` Level node - used to represent a level in the building - children: array of architectural, equipment, and MEP distribution nodes - level: level number + - height: storey height in meters (floor-to-floor); absent only on unmigrated legacy data `, ) diff --git a/packages/core/src/schema/nodes/shelf.ts b/packages/core/src/schema/nodes/shelf.ts index 3e9b74b2..89faaedd 100644 --- a/packages/core/src/schema/nodes/shelf.ts +++ b/packages/core/src/schema/nodes/shelf.ts @@ -43,6 +43,8 @@ export const ShelfNode = BaseNode.extend({ children: z.array(ItemNode.shape.id).default([]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), // Dimensions (meters). Schema-level defaults intentionally reproduce // the v1 wall-shelf so existing v1 scenes that omit the v2-introduced diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index fe3c47c1..ec8e99b7 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -4,6 +4,11 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { SurfaceHoleMetadata } from './surface-hole-metadata' +// Edit-time floor for `thickness` — a thinner slab z-fights the ceiling's +// −0.01 underside offset. Applies to edits only; migration writes legacy +// intervals verbatim (including degenerate zero-thickness slabs). +export const MIN_SLAB_THICKNESS = 0.02 + export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), @@ -16,7 +21,9 @@ export const SlabNode = BaseNode.extend({ polygon: z.array(z.tuple([z.number(), z.number()])), holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), holeMetadata: z.array(SurfaceHoleMetadata).default([]), - elevation: z.number().default(0.05), // Elevation in meters + elevation: z.number().default(0.05), // Walking surface (slab top), meters above the level plane + thickness: z.number().default(0.05), // Grows downward from the surface + recessed: z.boolean().default(false), autoFromWalls: z.boolean().default(false), }).describe( dedent` @@ -24,7 +31,9 @@ export const SlabNode = BaseNode.extend({ - polygon: array of [x, z] points defining the slab boundary - holes: array of [x, z] polygons representing cutouts in the slab - holeMetadata: metadata parallel to holes, used to preserve manual and auto-managed cutouts - - elevation: elevation in meters + - elevation: the walking surface (slab top), in meters above the level plane + - thickness: grows downward from the surface; the solid occupies [elevation - thickness, elevation] + - recessed: open recess (pool) whose floor sits at elevation (< 0); the shell walls rise to the level plane - autoFromWalls: whether the slab is automatically generated from a closed wall loop `, ) diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts index 521d3f81..b3a620eb 100644 --- a/packages/core/src/schema/nodes/spawn.ts +++ b/packages/core/src/schema/nodes/spawn.ts @@ -6,6 +6,8 @@ export const SpawnNode = BaseNode.extend({ type: nodeType('spawn'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), }) export type SpawnNode = z.infer diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index abc1fe54..cf9d0664 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -37,13 +37,19 @@ export const StairNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), stairType: StairType.default('straight'), fromLevelId: z.string().nullable().default(null), toLevelId: z.string().nullable().default(null), + // Destination deck (a slab id). When set, the stair's rise follows that + // slab's elevation live. An explicit `totalRise` still wins when BOTH are + // set (edge case — the panel clears the custom rise when attaching). + deckSlabId: z.string().optional(), slabOpeningMode: StairSlabOpeningMode.default('none'), openingOffset: z.number().default(0), width: z.number().default(1.0), - totalRise: z.number().default(2.5), + totalRise: z.number().optional(), stepCount: z.number().default(10), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), @@ -66,6 +72,7 @@ export const StairNode = BaseNode.extend({ - rotation: rotation around Y axis - stairType: straight (segment-based), curved (arc-based), or spiral - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts + - deckSlabId: destination deck (slab) — the rise derives from its elevation while set - slabOpeningMode: whether a destination-level slab opening is generated for this stair - openingOffset: extra opening expansion applied after the cutout polygon is computed - width: stair width diff --git a/packages/core/src/schema/nodes/structural-grid.test.ts b/packages/core/src/schema/nodes/structural-grid.test.ts new file mode 100644 index 00000000..1b6afcfc --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test' +import { LevelNode } from './level' +import { StructuralGridNode } from './structural-grid' + +describe('StructuralGridNode', () => { + test('fills stable construction-document defaults', () => { + const grid = StructuralGridNode.parse({}) + + expect(grid.id).toStartWith('structural-grid_') + expect(grid).toMatchObject({ + type: 'structural-grid', + start: [0, 0], + end: [0, 5], + label: '1', + showStartBubble: true, + showEndBubble: true, + }) + }) + + test('is accepted as a level child', () => { + expect(LevelNode.parse({ children: ['structural-grid_axis-1'] }).children).toEqual([ + 'structural-grid_axis-1', + ]) + }) + + test('rejects empty labels and zero-length concerns stay in authoring', () => { + expect(() => StructuralGridNode.parse({ label: ' ' })).toThrow() + expect(StructuralGridNode.parse({ start: [1, 1], end: [1, 1], label: 'A' })).toMatchObject({ + start: [1, 1], + end: [1, 1], + }) + }) +}) diff --git a/packages/core/src/schema/nodes/structural-grid.ts b/packages/core/src/schema/nodes/structural-grid.ts new file mode 100644 index 00000000..09f071a6 --- /dev/null +++ b/packages/core/src/schema/nodes/structural-grid.ts @@ -0,0 +1,22 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const StructuralGridNode = BaseNode.extend({ + id: objectId('structural-grid'), + type: nodeType('structural-grid'), + start: z.tuple([z.number(), z.number()]).default([0, 0]), + end: z.tuple([z.number(), z.number()]).default([0, 5]), + label: z.string().trim().min(1).max(12).default('1'), + showStartBubble: z.boolean().default(true), + showEndBubble: z.boolean().default(true), +}).describe( + dedent` + Structural grid node - a persistent floor-plan datum axis with identification bubbles + - start/end: level-local plan coordinates defining the grid axis extent + - label: axis identifier, commonly numeric in one direction and alphabetic in the other + - showStartBubble/showEndBubble: independently control the two endpoint identifiers + `, +) + +export type StructuralGridNode = z.infer diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index abc2f5e8..164a5c37 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test' import { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, + getWallAssemblyDatumReferenceId, + getWallAssemblyFaceOffsets, + getWallAssemblyThickness, + getWallDatumEligibleLayers, getWallFaceBandConfig, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, WALL_CHAIR_RAIL_DEFAULT, WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, @@ -13,7 +19,8 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, - type WallNode, + WallNode, + type WallNode as WallNodeType, WallTrimConfig, } from './wall' @@ -41,16 +48,19 @@ describe('wall face bands', () => { }) expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 3, - lowerHeight: 0.84, - middleHeight: 0.61, - upperHeight: 0.61, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 3, lowerTop: 0.84, @@ -60,16 +70,19 @@ describe('wall face bands', () => { test('four bands adds an upper split below the final top band', () => { expect( - getWallFaceBandConfig({ - height: 2.5, - faceBands: { - enabled: true, - count: 4, - lowerHeight: 0.5, - middleHeight: 0.6, - upperHeight: 0.7, + getWallFaceBandConfig( + { + height: 2.5, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.6, + upperHeight: 0.7, + }, }, - }), + 2.5, + ), ).toMatchObject({ count: 4, lowerTop: 0.5, @@ -93,7 +106,7 @@ describe('wall face bands', () => { lowerInterior: 'library:stale-lower', middleExterior: 'library:stale-middle', }, - } as Pick) + } as Pick) expect(patch.faceBands).toEqual({ enabled: true, @@ -129,7 +142,7 @@ describe('wall face bands', () => { exterior: 'scene:exterior-finish', topInterior: 'library:stale-top', }, - } as Pick, + } as Pick, 3, ) @@ -153,7 +166,7 @@ describe('wall face bands', () => { middleInterior: 'library:stale-middle', upperExterior: 'library:stale-upper', }, - } as Pick) + } as Pick) expect(patch.slots).toEqual({ lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, @@ -181,7 +194,7 @@ describe('wall face bands', () => { lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }, - } as Pick, + } as Pick, 3, ) @@ -213,7 +226,7 @@ describe('wall face bands', () => { middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, upperExterior: 'library:painted-top-exterior', }, - } as Pick, + } as Pick, 4, ) @@ -254,3 +267,206 @@ describe('wall trim profiles', () => { expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) }) }) + +describe('wall assembly layers', () => { + test('defaults to legacy thickness when no assembly layers are modeled', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(wall.assemblyLayers).toEqual([]) + expect(getWallAssemblyThickness(wall)).toBe(0.14) + }) + + test('stores role, side, thickness, material reference, and datum eligibility', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face', 'finish-face'], + }, + ], + }) + + expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196) + expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([ + 'interior-gwb', + 'brick-veneer', + ]) + expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([ + { id: 'stud-core', role: 'structure', side: 'core' }, + ]) + expect(getWallAssemblyFaceOffsets(wall)).toEqual({ + interior: -0.061, + exterior: 0.135, + }) + }) + + test('resolves stable datum references for legacy single-thickness walls', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + thickness: 0.14, + }) + + expect(resolveWallAssemblyDatumReferences(wall)).toEqual([ + { id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 }, + { + id: 'wall:structural-face:interior', + datum: 'structural-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:structural-face:exterior', + datum: 'structural-face', + side: 'exterior', + offset: 0.07, + }, + { + id: 'wall:finish-face:interior', + datum: 'finish-face', + side: 'interior', + offset: -0.07, + }, + { + id: 'wall:finish-face:exterior', + datum: 'finish-face', + side: 'exterior', + offset: 0.07, + }, + ]) + }) + + test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => { + const wall = WallNode.parse({ + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.09, + materialRef: 'library:wood-framing', + datumEligible: ['centerline', 'structural-face'], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-sheathing', + role: 'exterior-sheathing', + side: 'exterior', + thickness: 0.012, + materialRef: 'library:sheathing', + datumEligible: ['finish-face'], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face'], + }, + ], + }) + + const references = resolveWallAssemblyDatumReferences(wall) + + expect(references).toContainEqual({ + id: 'wall:centerline:center', + datum: 'centerline', + side: 'center', + offset: 0, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:interior:stud-core', + datum: 'structural-face', + side: 'interior', + layerId: 'stud-core', + offset: -0.045, + }) + expect(references).toContainEqual({ + id: 'wall:structural-face:exterior:stud-core', + datum: 'structural-face', + side: 'exterior', + layerId: 'stud-core', + offset: 0.045, + }) + expect(references).toContainEqual({ + id: 'wall:finish-face:interior:interior-gwb', + datum: 'finish-face', + side: 'interior', + layerId: 'interior-gwb', + offset: -0.061, + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + ), + ).toMatchObject({ + datum: 'finish-face', + side: 'exterior', + layerId: 'exterior-sheathing', + }) + expect( + references.find( + (reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing', + )?.offset, + ).toBeCloseTo(0.057) + + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + }) + expect( + references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer') + ?.offset, + ).toBeCloseTo(0.147) + expect( + resolveWallAssemblyDatumReference( + wall, + getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'), + ), + ).toMatchObject({ + datum: 'veneer-face', + side: 'exterior', + layerId: 'brick-veneer', + offset: 0.147, + }) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 4bfaa063..82a81429 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -127,6 +127,48 @@ export const WALL_SURFACE_SLOT_DEFAULTS = { export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS +export const WallAssemblyLayerRole = z.enum([ + 'structure', + 'interior-finish', + 'exterior-sheathing', + 'exterior-finish', + 'masonry-veneer', + 'air-space', + 'concrete-block', + 'structural-masonry', + 'solid-concrete', + 'furring', +]) +export type WallAssemblyLayerRole = z.infer + +export const WallDimensionDatum = z.enum([ + 'centerline', + 'structural-face', + 'finish-face', + 'veneer-face', +]) +export type WallDimensionDatum = z.infer + +export const WallAssemblyLayer = z.object({ + id: z.string().trim().min(1).max(80).default('structure'), + role: WallAssemblyLayerRole.default('structure'), + side: z.enum(['core', 'interior', 'exterior']).default('core'), + thickness: z.number().finite().positive().default(0.1), + materialRef: z.string().trim().max(120).default(''), + datumEligible: z.array(WallDimensionDatum).max(8).default([]), +}) +export type WallAssemblyLayer = z.infer + +export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior' + +export type WallAssemblyDatumReference = { + id: string + datum: WallDimensionDatum + side: WallAssemblyDatumSide + layerId?: string + offset: number +} + export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), @@ -149,8 +191,11 @@ export const WallNode = BaseNode.extend({ // in a follow-up once migrated scenes are the norm. slots: z.record(z.string(), z.string()).optional(), thickness: z.number().optional(), + assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]), height: z.number().optional(), curveOffset: z.number().optional(), + // Persisted slab-support host — see ItemNode.supportSlabId for the rules. + supportSlabId: z.string().optional(), faceBands: WallFaceBandConfig.optional(), skirting: WallTrimConfig.optional(), crown: WallTrimConfig.optional(), @@ -165,6 +210,7 @@ export const WallNode = BaseNode.extend({ dedent` Wall node - used to represent a wall in the building - thickness: thickness in meters + - assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility - height: height in meters - curveOffset: midpoint sagitta offset used to bend the wall into an arc - start: start point of the wall in level coordinate system @@ -188,6 +234,222 @@ export type WallBandSurfaceSlotId = | 'upperExterior' | 'topExterior' +export function getWallAssemblyLayers(wall: Pick): WallAssemblyLayer[] { + return wall.assemblyLayers ?? [] +} + +export function getWallAssemblyThickness( + wall: Pick, +): number { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return wall.thickness ?? 0.1 + return layers.reduce((sum, layer) => sum + layer.thickness, 0) +} + +export function getWallAssemblyFaceOffsets(wall: Pick): { + interior: number + exterior: number +} { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return { interior: -halfThickness, exterior: halfThickness } + } + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const interiorFinishThickness = layers + .filter((layer) => layer.side === 'interior') + .reduce((sum, layer) => sum + layer.thickness, 0) + const exteriorFinishThickness = layers + .filter((layer) => layer.side === 'exterior') + .reduce((sum, layer) => sum + layer.thickness, 0) + + return { + interior: -coreThickness / 2 - interiorFinishThickness, + exterior: coreThickness / 2 + exteriorFinishThickness, + } +} + +export function getWallDatumEligibleLayers( + wall: Pick, + datum: WallDimensionDatum, +): WallAssemblyLayer[] { + return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum)) +} + +export function getWallAssemblyDatumReferenceId( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + layerId?: string, +): string { + return ['wall', datum, side, layerId].filter(Boolean).join(':') +} + +type WallAssemblyLayerSpan = { + layer: WallAssemblyLayer + interiorOffset: number + exteriorOffset: number +} + +function getWallAssemblyLayerSpans( + wall: Pick, +): WallAssemblyLayerSpan[] { + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return [] + + const coreLayers = layers.filter((layer) => layer.side === 'core') + const coreThickness = + coreLayers.length > 0 + ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) + : (wall.thickness ?? 0.1) + const coreInteriorFace = -coreThickness / 2 + const coreExteriorFace = coreThickness / 2 + const spans: WallAssemblyLayerSpan[] = [] + + let coreOffset = coreInteriorFace + for (const layer of coreLayers) { + const interiorOffset = coreOffset + const exteriorOffset = coreOffset + layer.thickness + spans.push({ layer, interiorOffset, exteriorOffset }) + coreOffset = exteriorOffset + } + + let interiorOffset = coreInteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'interior')) { + const exteriorOffset = interiorOffset + const nextInteriorOffset = exteriorOffset - layer.thickness + spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset }) + interiorOffset = nextInteriorOffset + } + + let exteriorOffset = coreExteriorFace + for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) { + const interiorFaceOffset = exteriorOffset + const nextExteriorOffset = interiorFaceOffset + layer.thickness + spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset }) + exteriorOffset = nextExteriorOffset + } + + return spans +} + +function createWallAssemblyDatumReference( + datum: WallDimensionDatum, + side: WallAssemblyDatumSide, + offset: number, + layerId?: string, +): WallAssemblyDatumReference { + return { + id: getWallAssemblyDatumReferenceId(datum, side, layerId), + datum, + side, + ...(layerId ? { layerId } : {}), + offset, + } +} + +export function resolveWallAssemblyDatumReferences( + wall: Pick, +): WallAssemblyDatumReference[] { + const layers = wall.assemblyLayers ?? [] + const references: WallAssemblyDatumReference[] = [ + createWallAssemblyDatumReference('centerline', 'center', 0), + ] + + if (layers.length === 0) { + const halfThickness = (wall.thickness ?? 0.1) / 2 + return [ + ...references, + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ] + } + + const spans = getWallAssemblyLayerSpans(wall) + + for (const span of spans) { + if (span.layer.datumEligible.includes('structural-face')) { + if (span.layer.side === 'core') { + references.push( + createWallAssemblyDatumReference( + 'structural-face', + 'interior', + span.interiorOffset, + span.layer.id, + ), + createWallAssemblyDatumReference( + 'structural-face', + 'exterior', + span.exteriorOffset, + span.layer.id, + ), + ) + } else { + const side = span.layer.side + references.push( + createWallAssemblyDatumReference( + 'structural-face', + side, + side === 'interior' ? span.interiorOffset : span.exteriorOffset, + span.layer.id, + ), + ) + } + } + + if (span.layer.datumEligible.includes('finish-face')) { + const side = span.layer.side === 'core' ? 'center' : span.layer.side + const offset = + span.layer.side === 'interior' + ? span.interiorOffset + : span.layer.side === 'exterior' + ? span.exteriorOffset + : (span.interiorOffset + span.exteriorOffset) / 2 + references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id)) + } + + if (span.layer.datumEligible.includes('veneer-face')) { + const side = span.layer.side === 'interior' ? 'interior' : 'exterior' + const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset + references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id)) + } + } + + if (!references.some((reference) => reference.datum === 'structural-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), + ) + } + + if (!references.some((reference) => reference.datum === 'finish-face')) { + const halfThickness = getWallAssemblyThickness(wall) / 2 + references.push( + createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), + createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), + ) + } + + return references +} + +export function resolveWallAssemblyDatumReference( + wall: Pick, + referenceId: string, +): WallAssemblyDatumReference | null { + return ( + resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ?? + null + ) +} + // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // slot declaration (nodes) and the material resolver (viewer) share one value. @@ -198,8 +460,11 @@ export const WALL_SLOT_DEFAULT: Record = { exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, } -export function getWallFaceBandConfig(wall: Pick) { - const wallHeight = wall.height ?? 2.5 +export function getWallFaceBandConfig( + wall: Pick, + effectiveWallHeight: number, +) { + const wallHeight = Math.max(0, effectiveWallHeight) const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 @@ -223,8 +488,9 @@ export function getWallFaceBandConfig(wall: Pick, y: number, + effectiveWallHeight: number, ): WallFaceBand { - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return 'upper' if (y < bands.lowerTop) return 'lower' if (y < bands.middleTop) return 'middle' diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 08c5ce4e..53b7fec5 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -17,6 +17,16 @@ export const WindowType = z.enum([ ]) export type WindowType = z.infer +export const WindowConstructionType = z.enum(['framed', 'masonry']) +export const WindowDimensionReference = z.enum([ + 'nominal', + 'rough-opening', + 'masonry-opening', + 'finish-opening', +]) +export type WindowConstructionType = z.infer +export type WindowDimensionReference = z.infer + export const WindowNode = BaseNode.extend({ id: objectId('window'), type: nodeType('window'), @@ -45,6 +55,18 @@ export const WindowNode = BaseNode.extend({ width: z.number().default(1.5), height: z.number().default(1.5), + // Construction-document identity and optional manufacturer rough opening. + // Legacy scenes omit these fields and continue to parse unchanged. + mark: z.string().trim().max(16).optional(), + constructionType: WindowConstructionType.default('framed'), + dimensionReference: WindowDimensionReference.default('nominal'), + roughOpeningWidth: z.number().positive().optional(), + roughOpeningHeight: z.number().positive().optional(), + masonryOpeningWidth: z.number().positive().optional(), + masonryOpeningHeight: z.number().positive().optional(), + finishOpeningWidth: z.number().positive().optional(), + finishOpeningHeight: z.number().positive().optional(), + // Opening mode - when set to "opening", the window is only a shaped cutout openingKind: z.enum(['window', 'opening']).default('window'), diff --git a/packages/core/src/schema/nodes/zone.test.ts b/packages/core/src/schema/nodes/zone.test.ts new file mode 100644 index 00000000..95a80512 --- /dev/null +++ b/packages/core/src/schema/nodes/zone.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { ZoneNode } from './zone' + +describe('ZoneNode architectural room data', () => { + test('keeps legacy zones generic while supplying room-safe defaults', () => { + const zone = ZoneNode.parse({ + id: 'zone_legacy', + name: 'Landscape area', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + }) + + expect(zone).toMatchObject({ + spaceRole: 'generic', + roomNumber: '', + enclosureStatus: 'auto', + floorFinish: '', + wallFinish: '', + ceilingFinish: '', + ceilingHeight: 2.7, + occupancy: '', + clearDimensionPolicy: 'none', + }) + }) + + test('persists a complete architectural room profile', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + spaceRole: 'room', + roomNumber: '101', + enclosureStatus: 'enclosed', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 3, + occupancy: 'Business', + clearDimensionPolicy: 'inside-faces', + }) + + expect(room.spaceRole).toBe('room') + expect(room.roomNumber).toBe('101') + expect(room.clearDimensionPolicy).toBe('inside-faces') + }) +}) diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts index 55a17957..227f5a4d 100644 --- a/packages/core/src/schema/nodes/zone.ts +++ b/packages/core/src/schema/nodes/zone.ts @@ -12,6 +12,17 @@ export const ZoneNode = BaseNode.extend({ // stored polygon remains a fallback for missing or temporarily open walls. autoFromWalls: z.boolean().default(false), boundaryWallIds: z.array(objectId('wall')).default([]), + // Generic zones remain available for sites and analysis. Architectural + // room documentation is opt-in so legacy zone behavior is unchanged. + spaceRole: z.enum(['generic', 'room']).default('generic'), + roomNumber: z.string().trim().max(32).default(''), + enclosureStatus: z.enum(['auto', 'enclosed', 'open']).default('auto'), + floorFinish: z.string().trim().max(120).default(''), + wallFinish: z.string().trim().max(120).default(''), + ceilingFinish: z.string().trim().max(120).default(''), + ceilingHeight: z.number().min(0.1).default(2.7), + occupancy: z.string().trim().max(80).default(''), + clearDimensionPolicy: z.enum(['none', 'inside-faces', 'finish-faces']).default('none'), // Visual styling color: z.string().default('#3b82f6'), // Default blue metadata: z.json().optional().default({}), @@ -25,6 +36,10 @@ export const ZoneNode = BaseNode.extend({ - polygon: array of [x, z] points defining the zone boundary - autoFromWalls: whether the boundary follows an enclosed wall loop - boundaryWallIds: wall ids that prove the procedural enclosure + - spaceRole: generic site/analysis zone or architectural room + - roomNumber/finishes/ceilingHeight/occupancy: construction-document room metadata + - enclosureStatus: auto-detected, explicitly enclosed, or open + - clearDimensionPolicy: optional room clear-dimension datum preference - color: hex color for visual styling - metadata: zone metadata (optional) `, diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index da37c6f2..2c980bfc 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -5,10 +5,12 @@ import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' +import { ConstructionDimensionNode } from './nodes/construction-dimension' import { CupolaNode } from './nodes/cupola' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' +import { DrawingSheetNode } from './nodes/drawing-sheet' import { DuctFittingNode } from './nodes/duct-fitting' import { DuctSegmentNode } from './nodes/duct-segment' import { DuctTerminalNode } from './nodes/duct-terminal' @@ -38,6 +40,7 @@ import { SolarPanelNode } from './nodes/solar-panel' import { SpawnNode } from './nodes/spawn' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' +import { StructuralGridNode } from './nodes/structural-grid' import { TurbineVentNode } from './nodes/turbine-vent' import { WallNode } from './nodes/wall' import { WindowNode } from './nodes/window' @@ -49,6 +52,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ElevatorNode, LevelNode, ColumnNode, + ConstructionDimensionNode, + StructuralGridNode, WallNode, FenceNode, CabinetNode, @@ -79,6 +84,7 @@ export const AnyNode = z.discriminatedUnion('type', [ SkylightNode, DormerNode, DownspoutNode, + DrawingSheetNode, DuctSegmentNode, DuctFittingNode, DuctTerminalNode, diff --git a/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts new file mode 100644 index 00000000..75841f1a --- /dev/null +++ b/packages/core/src/services/__fixtures__/wall-plane-top-boundary-repro.ts @@ -0,0 +1,89 @@ +// Real-scene repro for the max-side boundary clamp miss (project_O1z9NLOylyb5kFX4). +// Auto slab polygon derives from wall centerlines, putting wall samples exactly on +// the polygon boundary — the shape that exposed ray-cast side dependence. +export const wallPlaneTopBoundaryRepro = { + building_rr4rx7weux2fpdbh: { + id: 'building_rr4rx7weux2fpdbh', + type: 'building', + object: 'node', + visible: true, + children: ['level_pomuk0sbwec15mf3', 'level_5msog1z8hy2lyvxr'], + metadata: {}, + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + }, + level_pomuk0sbwec15mf3: { + id: 'level_pomuk0sbwec15mf3', + type: 'level', + level: 0, + height: 2.7, + object: 'node', + visible: true, + children: ['wall_39bnnq29h824ryy0', 'wall_on4rj410n69n3rzf'], + metadata: {}, + parentId: null, + }, + level_5msog1z8hy2lyvxr: { + id: 'level_5msog1z8hy2lyvxr', + type: 'level', + level: 1, + height: 2.5, + object: 'node', + visible: true, + children: ['slab_j3i4ebjg4nsu8xk7'], + metadata: {}, + parentId: 'building_rr4rx7weux2fpdbh', + }, + wall_39bnnq29h824ryy0: { + id: 'wall_39bnnq29h824ryy0', + end: [-1, 4], + name: 'Wall 1', + type: 'wall', + start: [3, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + wall_on4rj410n69n3rzf: { + id: 'wall_on4rj410n69n3rzf', + end: [-1, -1], + name: 'Wall 2', + type: 'wall', + start: [-1, 4], + object: 'node', + visible: true, + backSide: 'exterior', + children: [], + metadata: {}, + parentId: 'level_pomuk0sbwec15mf3', + frontSide: 'interior', + }, + slab_j3i4ebjg4nsu8xk7: { + id: 'slab_j3i4ebjg4nsu8xk7', + name: 'Room 1 Slab', + type: 'slab', + holes: [], + object: 'node', + polygon: [ + [-1, 4], + [-1, -1], + [4, -1], + [4, 1], + [3, 1], + [3, 4], + ], + visible: true, + metadata: {}, + parentId: 'level_5msog1z8hy2lyvxr', + recessed: false, + elevation: 0.19757210573188194, + thickness: 0.5, + holeMetadata: [], + autoFromWalls: true, + }, +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 85517d5c..245793f6 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -46,7 +46,7 @@ export { DEFAULT_LEVEL_HEIGHT, getCeilingAt, getCeilingHeightAt, - getLevelHeight, + resolveCeilingHeight, } from './level-height' export { type AxisLock, @@ -102,6 +102,19 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + CEILING_CLAMP_MARGIN, + findLevelAboveId, + findLevelBelowId, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, + type LevelElevation, +} from './storey' export { buildPortComponents, type SystemSummary, diff --git a/packages/core/src/services/level-height.test.ts b/packages/core/src/services/level-height.test.ts new file mode 100644 index 00000000..81698a26 --- /dev/null +++ b/packages/core/src/services/level-height.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'bun:test' +import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { deriveLegacyLevelHeight, getCeilingAt, resolveCeilingHeight } from './level-height' + +function createFixture(): Record { + const nodes: AnyNode[] = [ + LevelNode.parse({ id: 'level_empty', children: [] }), + LevelNode.parse({ id: 'level_no_slab', children: ['wall_no_slab'] }), + WallNode.parse({ + id: 'wall_no_slab', + parentId: 'level_no_slab', + start: [10, 0], + end: [12, 0], + }), + LevelNode.parse({ id: 'level_standard_slab', children: ['slab_standard', 'wall_standard'] }), + SlabNode.parse({ + id: 'slab_standard', + parentId: 'level_standard_slab', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation: 0.05, + }), + WallNode.parse({ + id: 'wall_standard', + parentId: 'level_standard_slab', + start: [1, 2], + end: [3, 2], + }), + LevelNode.parse({ id: 'level_tall_wall', children: ['slab_raised', 'wall_tall'] }), + SlabNode.parse({ + id: 'slab_raised', + parentId: 'level_tall_wall', + polygon: [ + [20, 0], + [24, 0], + [24, 4], + [20, 4], + ], + elevation: 0.35, + }), + WallNode.parse({ + id: 'wall_tall', + parentId: 'level_tall_wall', + start: [21, 2], + end: [23, 2], + height: 3.2, + }), + LevelNode.parse({ id: 'level_ceiling', children: ['wall_below_ceiling', 'ceiling_tall'] }), + WallNode.parse({ + id: 'wall_below_ceiling', + parentId: 'level_ceiling', + start: [40, 0], + end: [42, 0], + }), + CeilingNode.parse({ + id: 'ceiling_tall', + parentId: 'level_ceiling', + polygon: [ + [40, 0], + [42, 0], + [42, 2], + [40, 2], + ], + height: 3.4, + }), + LevelNode.parse({ id: 'level_negative_slab', children: ['slab_negative', 'wall_negative'] }), + SlabNode.parse({ + id: 'slab_negative', + parentId: 'level_negative_slab', + polygon: [ + [30, 0], + [34, 0], + [34, 4], + [30, 4], + ], + elevation: -0.4, + }), + WallNode.parse({ + id: 'wall_negative', + parentId: 'level_negative_slab', + start: [31, 2], + end: [33, 2], + height: 2.8, + }), + ] + + return Object.fromEntries(nodes.map((node) => [node.id, node])) as Record +} + +describe('deriveLegacyLevelHeight', () => { + const nodes = createFixture() + const cases = [ + ['level_no_slab', 2.5], + ['level_standard_slab', 2.5], + ['level_tall_wall', 3.55], + ['level_ceiling', 3.4], + ['level_negative_slab', 2.8], + ['level_empty', 2.5], + ] as const + + for (const [levelId, expected] of cases) { + it(`derives ${expected} for ${levelId}`, () => { + expect(deriveLegacyLevelHeight(levelId, nodes)).toBeCloseTo(expected) + }) + } +}) + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +// Post-migration stack: two stored-height levels; the upper level carries a +// deck slab occupying [-0.3, 0] over the lower level's plane, so the lower +// level's ceiling clamp bound is 2.5 − 0.3 − 0.01 = 2.19 under the deck. +function createResolverFixture(options: { deck?: boolean } = {}): Record { + const list: AnyNode[] = [ + BuildingNode.parse({ + id: 'building_a', + children: ['level_low', 'level_high'], + }), + LevelNode.parse({ id: 'level_low', level: 0, height: 2.5, parentId: 'building_a' }), + LevelNode.parse({ + id: 'level_high', + level: 1, + height: 2.5, + parentId: 'building_a', + children: options.deck ? ['slab_deck'] : [], + }), + ] + if (options.deck) { + list.push( + SlabNode.parse({ + id: 'slab_deck', + parentId: 'level_high', + polygon: SQUARE, + elevation: 0, + thickness: 0.3, + }), + ) + } + return Object.fromEntries(list.map((node) => [node.id, node])) as Record +} + +describe('resolveCeilingHeight', () => { + it('returns the explicit height verbatim when stored', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE, height: 2.0 }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBe(2.0) + }) + + it('resolves an absent height to the level-top clamp bound', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + }) + + it('tracks a level height change without any ceiling write', () => { + const nodes = createResolverFixture() + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.49) + + const level = nodes['level_low' as AnyNodeId] as AnyNode & { height?: number } + const raised = { + ...nodes, + level_low: { ...level, height: 3.2 } as AnyNode, + } as Record + + expect(resolveCeilingHeight(ceiling, raised)).toBeCloseTo(3.19) + }) + + it('resolves under a covering deck from the level above', () => { + const nodes = createResolverFixture({ deck: true }) + const ceiling = CeilingNode.parse({ parentId: 'level_low', polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, nodes)).toBeCloseTo(2.19) + }) + + it('falls back to the default plane when the level is unresolvable', () => { + const ceiling = CeilingNode.parse({ parentId: null, polygon: SQUARE }) + + expect(resolveCeilingHeight(ceiling, {} as Record)).toBeCloseTo(2.49) + }) +}) + +describe('getCeilingAt lowest-wins with mixed follows/explicit', () => { + it('picks the explicit low ceiling under a follows-mode one, and vice versa', () => { + const base = createResolverFixture() + const follows = CeilingNode.parse({ + id: 'ceiling_follows', + parentId: 'level_low', + polygon: SQUARE, + }) + const explicitLow = CeilingNode.parse({ + id: 'ceiling_low', + parentId: 'level_low', + polygon: SQUARE, + height: 2.0, + }) + const level = base['level_low' as AnyNodeId] as AnyNode & { children: string[] } + const nodes = { + ...base, + level_low: { ...level, children: ['ceiling_follows', 'ceiling_low'] } as AnyNode, + ceiling_follows: follows, + ceiling_low: explicitLow, + } as Record + + // Explicit 2.0 undercuts the 2.49 follows bound. + expect(getCeilingAt('level_low', nodes, 2, 2)?.id).toBe(explicitLow.id) + + // Raise the explicit one above the bound comparison: 2.6 stored — the + // follows ceiling (2.49) is now the lowest surface over the point. + const nodesHighExplicit = { + ...nodes, + ceiling_low: { ...explicitLow, height: 2.6 } as AnyNode, + } as Record + expect(getCeilingAt('level_low', nodesHighExplicit, 2, 2)?.id).toBe(follows.id) + }) +}) diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 016711ac..871303c4 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,40 +1,68 @@ -import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' -import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { CeilingNode, LevelNode, SlabNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' +import { computeWallSlabSupport, pointInPolygon } from '../systems/slab/slab-support' +import { resolveWallTop } from '../systems/wall/wall-top' +// Cycle with ./storey (it imports DEFAULT_LEVEL_HEIGHT from here) is safe: +// both sides only reference the other inside function bodies. +import { CEILING_CLAMP_MARGIN, getCeilingClampBound } from './storey' export const DEFAULT_LEVEL_HEIGHT = 2.5 /** - * Optional resolver for a wall's rendered base Y (mesh elevation). - * - * `packages/core` is pure domain logic and must not read viewer/Three.js - * state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have - * registry access (viewer systems, node tools) may pass a resolver so the - * mesh elevation is factored in; pure/headless callers (MCP, tests, server) - * omit it and get a deterministic result from serialized node data alone. + * Effective ceiling height in level-local meters. An explicit stored + * `height` wins; absent height means the ceiling follows the level top — + * the same bound its write-clamp uses: min(storey plane, lowest + * covering-slab underside over its polygon) − CEILING_CLAMP_MARGIN (see + * {@link getCeilingClampBound}). Falls back to the default plane minus + * the same margin when the owning level is unresolvable. */ -export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined +export function resolveCeilingHeight( + ceiling: Pick, + nodes: Record, +): number { + if (ceiling.height != null) return ceiling.height + const bound = + typeof ceiling.parentId === 'string' + ? getCeilingClampBound(ceiling.parentId, nodes, ceiling.polygon) + : Number.POSITIVE_INFINITY + return Number.isFinite(bound) ? bound : DEFAULT_LEVEL_HEIGHT - CEILING_CLAMP_MARGIN +} -export function getLevelHeight( +export function deriveLegacyLevelHeight( levelId: string, nodes: Record, - resolveWallBaseY?: WallBaseYResolver, ): number { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (!level) return DEFAULT_LEVEL_HEIGHT + const levelChildren = level.children + .map((childId) => nodes[childId as keyof typeof nodes]) + .filter((child): child is AnyNode => child !== undefined) + const slabs = levelChildren.filter((child): child is SlabNode => child.type === 'slab') + const walls = levelChildren.filter((child): child is WallNode => child.type === 'wall') + let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (!child) continue + for (const child of levelChildren) { if (child.type === 'ceiling') { - const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (ch > maxTop) maxTop = ch + // Absence here is the PRE-migration legacy schema default (2.5), not + // follows-mode — this derivation runs before the level has a height + // for a follows-mode bound to track. + const height = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (height > maxTop) maxTop = height } else if (child.type === 'wall') { - let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 - if (baseY < 0) baseY = 0 - const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + const wall = child as WallNode + const electedElevation = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const top = resolveWallTop(wall, level.height ?? DEFAULT_LEVEL_HEIGHT, electedElevation) if (top > maxTop) maxTop = top } } @@ -58,14 +86,18 @@ export function getCeilingAt( if (!level) return null let best: CeilingNode | null = null + let bestHeight = Number.POSITIVE_INFINITY for (const childId of level.children) { const child = nodes[childId as keyof typeof nodes] if (child?.type !== 'ceiling') continue const ceiling = child as CeilingNode if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue - const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT - if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + const h = resolveCeilingHeight(ceiling, nodes) + if (best === null || h < bestHeight) { + best = ceiling + bestHeight = h + } } return best } @@ -82,5 +114,5 @@ export function getCeilingHeightAt( z: number, ): number | null { const ceiling = getCeilingAt(levelId, nodes, x, z) - return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null + return ceiling ? resolveCeilingHeight(ceiling, nodes) : null } diff --git a/packages/core/src/services/storey.test.ts b/packages/core/src/services/storey.test.ts new file mode 100644 index 00000000..2cce58ef --- /dev/null +++ b/packages/core/src/services/storey.test.ts @@ -0,0 +1,527 @@ +import { describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode, SlabNode, type WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { wallPlaneTopBoundaryRepro as reproFixture } from './__fixtures__/wall-plane-top-boundary-repro' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' +import { + CEILING_CLAMP_MARGIN, + getCeilingClampBound, + getCoveringSlabUndersideAt, + getLevelAbove, + getLevelBelow, + getLevelElevations, + getStoredLevelHeight, + getWallPlaneTop, +} from './storey' + +const buildNodes = (list: AnyNode[]): Record => + Object.fromEntries(list.map((node) => [node.id, node])) as Record + +const level = ( + id: string, + ordinal: number, + opts: { height?: number; parentId?: string | null; children?: string[] } = {}, +): LevelNode => + LevelNode.parse({ + id, + level: ordinal, + parentId: opts.parentId ?? null, + children: opts.children ?? [], + ...(opts.height === undefined ? {} : { height: opts.height }), + }) + +const building = (id: string, children: string[]): BuildingNode => + BuildingNode.parse({ id, children }) + +const slabNode = ( + id: string, + opts: { + polygon?: Array<[number, number]> + holes?: Array> + elevation?: number + thickness?: number + recessed?: boolean + }, +): SlabNode => + SlabNode.parse({ + id, + polygon: + opts.polygon ?? + ([ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] as Array<[number, number]>), + holes: opts.holes ?? [], + ...(opts.elevation === undefined ? {} : { elevation: opts.elevation }), + ...(opts.thickness === undefined ? {} : { thickness: opts.thickness }), + ...(opts.recessed === undefined ? {} : { recessed: opts.recessed }), + }) + +describe('getStoredLevelHeight', () => { + test('returns the stored height when present', () => { + expect(getStoredLevelHeight(level('level_a', 0, { height: 3.25 }))).toBe(3.25) + }) + + test('falls back to the default for unmigrated legacy levels', () => { + expect(getStoredLevelHeight(level('level_a', 0))).toBe(DEFAULT_LEVEL_HEIGHT) + expect(getStoredLevelHeight(level('level_a', 0))).toBe(2.5) + }) +}) + +describe('getLevelElevations', () => { + test('single building matches a hand-computed prefix sum', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2', 'level_3']), + level('level_0', 0, { height: 3, parentId: 'building_a' }), + level('level_1', 1, { height: 2.5, parentId: 'building_a' }), + level('level_2', 2, { height: 2.75, parentId: 'building_a' }), + level('level_3', 3, { height: 4, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')).toEqual({ + baseY: 0, + height: 3, + buildingId: 'building_a', + ordinal: 0, + }) + expect(elevations.get('level_1')?.baseY).toBe(3) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_3')?.baseY).toBe(8.25) + }) + + test('stacks two buildings independently with interleaved, unsorted ordinals', () => { + const nodes = buildNodes([ + level('level_b1', 1, { height: 2.5, parentId: 'building_b' }), + level('level_a2', 2, { height: 3, parentId: 'building_a' }), + building('building_a', ['level_a0', 'level_a1', 'level_a2']), + level('level_a0', 0, { height: 3.5, parentId: 'building_a' }), + building('building_b', ['level_b0', 'level_b1']), + level('level_b0', 0, { height: 4, parentId: 'building_b' }), + level('level_a1', 1, { height: 3.25, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_a0')?.baseY).toBe(0) + expect(elevations.get('level_a1')?.baseY).toBe(3.5) + expect(elevations.get('level_a2')?.baseY).toBe(6.75) + expect(elevations.get('level_b0')?.baseY).toBe(0) + expect(elevations.get('level_b1')?.baseY).toBe(4) + expect(elevations.get('level_a2')?.buildingId).toBe('building_a') + expect(elevations.get('level_b1')?.buildingId).toBe('building_b') + }) + + test('negative ordinals stack from the lowest level up', () => { + const nodes = buildNodes([ + building('building_a', ['level_basement', 'level_ground', 'level_upper']), + level('level_upper', 1, { height: 3, parentId: 'building_a' }), + level('level_basement', -1, { height: 2.25, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_basement')?.baseY).toBe(0) + expect(elevations.get('level_ground')?.baseY).toBe(2.25) + expect(elevations.get('level_upper')?.baseY).toBe(4.75) + }) + + test('duplicate and fractional ordinals stack stably without NaN', () => { + const nodes = buildNodes([ + building('building_a', ['level_ground', 'level_mezz', 'level_dup_b', 'level_dup_a']), + level('level_dup_b', 1, { height: 3, parentId: 'building_a' }), + level('level_dup_a', 1, { height: 2.5, parentId: 'building_a' }), + level('level_mezz', 0.5, { height: 1.5, parentId: 'building_a' }), + level('level_ground', 0, { height: 2.5, parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_ground')?.baseY).toBe(0) + expect(elevations.get('level_mezz')?.baseY).toBe(2.5) + // Stable sort: equal ordinals keep nodes-record insertion order. + expect(elevations.get('level_dup_b')?.baseY).toBe(4) + expect(elevations.get('level_dup_a')?.baseY).toBe(7) + for (const elevation of elevations.values()) { + expect(Number.isFinite(elevation.baseY)).toBe(true) + expect(Number.isFinite(elevation.height)).toBe(true) + } + }) + + test('levels missing height fall back to 2.5 for both height and stacking', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_1', 'level_2']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_1', 1, { height: 3, parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_0')?.height).toBe(2.5) + expect(elevations.get('level_1')?.baseY).toBe(2.5) + expect(elevations.get('level_2')?.baseY).toBe(5.5) + expect(elevations.get('level_2')?.height).toBe(2.5) + }) + + test('resolves buildings via parentId, legacy children membership, and non-building parents', () => { + const nodes = buildNodes([ + // level_direct is not in children; level_site has a non-building parentId. + building('building_x', ['level_legacy', 'level_site']), + level('level_direct', 0, { height: 3, parentId: 'building_x' }), + level('level_legacy', 1, { height: 2.5, parentId: null }), + level('level_site', 2, { height: 2.75, parentId: 'site_main' }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_direct')?.buildingId).toBe('building_x') + expect(elevations.get('level_legacy')?.buildingId).toBe('building_x') + expect(elevations.get('level_site')?.buildingId).toBe('building_x') + expect(elevations.get('level_direct')?.baseY).toBe(0) + expect(elevations.get('level_legacy')?.baseY).toBe(3) + expect(elevations.get('level_site')?.baseY).toBe(5.5) + }) + + test('levels with no resolvable building share one legacy stack from 0', () => { + const nodes = buildNodes([ + level('level_orphan_1', 1, { height: 3 }), + level('level_orphan_0', 0, { height: 2.75 }), + ]) + + const elevations = getLevelElevations(nodes) + expect(elevations.get('level_orphan_0')).toEqual({ + baseY: 0, + height: 2.75, + buildingId: null, + ordinal: 0, + }) + expect(elevations.get('level_orphan_1')?.baseY).toBe(2.75) + }) +}) + +describe('getLevelAbove', () => { + test('returns the next-higher ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelAbove('level_0', nodes)?.id).toBe('level_2') + expect(getLevelAbove('level_2', nodes)?.id).toBe('level_5') + expect(getLevelAbove('level_5', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelAbove('level_a0', nodes)).toBeNull() + expect(getLevelAbove('level_b0', nodes)?.id).toBe('level_b1') + }) + + test('orphan levels resolve within the shared legacy stack', () => { + const nodes = buildNodes([ + level('level_orphan_0', 0, { height: 2.75 }), + level('level_orphan_1', 1, { height: 3 }), + ]) + + expect(getLevelAbove('level_orphan_0', nodes)?.id).toBe('level_orphan_1') + expect(getLevelAbove('level_orphan_1', nodes)).toBeNull() + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelAbove('level_missing', nodes)).toBeNull() + }) +}) + +describe('getLevelBelow', () => { + test('returns the next-lower ordinal in the same building, skipping ordinal gaps', () => { + const nodes = buildNodes([ + building('building_a', ['level_0', 'level_2', 'level_5']), + level('level_0', 0, { parentId: 'building_a' }), + level('level_5', 5, { parentId: 'building_a' }), + level('level_2', 2, { parentId: 'building_a' }), + ]) + + expect(getLevelBelow('level_5', nodes)?.id).toBe('level_2') + expect(getLevelBelow('level_2', nodes)?.id).toBe('level_0') + expect(getLevelBelow('level_0', nodes)).toBeNull() + }) + + test('never crosses into another building', () => { + const nodes = buildNodes([ + building('building_a', ['level_a0']), + building('building_b', ['level_b0', 'level_b1']), + level('level_a0', 0, { parentId: 'building_a' }), + level('level_b0', 0, { parentId: 'building_b' }), + level('level_b1', 1, { parentId: 'building_b' }), + ]) + + expect(getLevelBelow('level_a0', nodes)).toBeNull() + expect(getLevelBelow('level_b1', nodes)?.id).toBe('level_b0') + }) + + test('returns null for an unknown level id', () => { + const nodes = buildNodes([level('level_0', 0)]) + expect(getLevelBelow('level_missing', nodes)).toBeNull() + }) +}) + +// Two stacked levels in one building; `slabs` become children of the level +// above the queried one. +const stackedNodes = (slabs: SlabNode[], queriedHeight = 2.5) => + buildNodes([ + building('building_a', ['level_0', 'level_1']), + level('level_0', 0, { height: queriedHeight, parentId: 'building_a' }), + level('level_1', 1, { + height: 2.5, + parentId: 'building_a', + children: slabs.map((node) => node.id), + }), + ...slabs, + ]) + +describe('getCoveringSlabUndersideAt', () => { + test('expresses a flush deck underside in the queried level local Y', () => { + // Flush deck occupying [-0.3, 0] above the plane: underside sits at + // storeyHeight + (0 - 0.3) = 2.2 over the queried level's floor. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null outside the slab polygon', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 10, 10)).toBeNull() + }) + + test('a hole in the slab vetoes coverage', () => { + const nodes = stackedNodes([ + slabNode('slab_deck', { + elevation: 0, + thickness: 0.3, + holes: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + ], + ], + }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + expect(getCoveringSlabUndersideAt('level_0', nodes, 0.5, 0.5)).toBeCloseTo(2.2) + }) + + test('recessed pools never cover', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeNull() + }) + + test('the lowest underside wins among overlapping covering slabs', () => { + const nodes = stackedNodes([ + // Default floor slab occupying [0, 0.05]: underside at the plane (2.5). + slabNode('slab_floor', {}), + slabNode('slab_deck', { elevation: 0, thickness: 0.3 }), + ]) + expect(getCoveringSlabUndersideAt('level_0', nodes, 2, 2)).toBeCloseTo(2.2) + }) + + test('returns null when there is no level above', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCoveringSlabUndersideAt('level_1', nodes, 2, 2)).toBeNull() + }) +}) + +describe('getWallPlaneTop', () => { + const wallAt = ( + start: [number, number], + end: [number, number], + ): { start: [number, number]; end: [number, number] } => ({ start, end }) + + test('no covering slab → the stored level height', () => { + const nodes = stackedNodes([], 3) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(3) + }) + + test('a flush thick deck above clamps the plane to its underside', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a slab covering only part of the span clamps via the min of the samples', () => { + // Deck over x ∈ [3.5, 6]: start (0,2) and chord midpoint (2,2) miss it, + // only the end sample (4,2) lands inside — the min still clamps. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [3.5, 0], + [6, 0], + [6, 4], + [3.5, 4], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getWallPlaneTop(wallAt([0, 2], [4, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a recessed slab above is ignored', () => { + const nodes = stackedNodes([ + slabNode('slab_pool', { elevation: -1, thickness: 0.3, recessed: true }), + ]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_0', nodes)).toBe(2.5) + }) + + test('falls back to the default height when the level does not resolve', () => { + const nodes = stackedNodes([]) + expect(getWallPlaneTop(wallAt([0.5, 2], [3.5, 2]), 'level_missing', nodes)).toBe( + DEFAULT_LEVEL_HEIGHT, + ) + }) + + test('repro project: both boundary walls clamp to the covering slab underside', () => { + // Real scene subset (project_O1z9NLOylyb5kFX4): the level-1 auto slab's + // polygon derives from the level-0 wall CENTERLINES, so every perimeter + // wall's samples sit exactly ON the polygon boundary. Wall 2 (min-x edge) + // clamped while Wall 1 (max-z edge) ran full height — ray-cast + // pointInPolygon includes min-side boundaries and excludes max-side ones. + const nodes = reproFixture as unknown as Record + const levelId = 'level_pomuk0sbwec15mf3' + const wall1 = nodes['wall_39bnnq29h824ryy0' as AnyNodeId] as WallNode + const wall2 = nodes['wall_on4rj410n69n3rzf' as AnyNodeId] as WallNode + // storeyHeight 2.7 + (slab elevation 0.19757… - thickness 0.5) + const underside = 2.7 + (0.19757210573188194 - 0.5) + expect(getWallPlaneTop(wall1, levelId, nodes)).toBeCloseTo(underside) + expect(getWallPlaneTop(wall2, levelId, nodes)).toBeCloseTo(underside) + }) + + test('all four rectangle walls under a same-footprint covering slab clamp', () => { + // The repro shape distilled: wall centerlines lie exactly on the covering + // slab's polygon edges. Every orientation must clamp identically. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const walls: Array<[[number, number], [number, number]]> = [ + [ + [0, 0], + [4, 0], + ], + [ + [4, 0], + [4, 4], + ], + [ + [4, 4], + [0, 4], + ], + [ + [0, 4], + [0, 0], + ], + ] + for (const [start, end] of walls) { + expect(getWallPlaneTop(wallAt(start, end), 'level_0', nodes)).toBeCloseTo(2.2) + } + }) + + test('a diagonal wall under the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([0.5, 0.5], [3.5, 3.5]), 'level_0', nodes)).toBeCloseTo(2.2) + }) + + test('a wall fully outside the covering slab keeps the storey height', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([6, 0], [6, 4]), 'level_0', nodes)).toBe(2.5) + }) + + test('a wall partially overlapping the covering slab clamps', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getWallPlaneTop(wallAt([2, 2], [8, 2]), 'level_0', nodes)).toBeCloseTo(2.2) + }) +}) + +describe('getCeilingClampBound', () => { + const ceilingPolygon: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ] + + test('with no covering slab the bound is the storey plane minus the margin', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.5 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a covering deck lowers the bound to its underside minus the margin', () => { + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('a slab covering only the interior is caught by the centroid sample', () => { + // Deck hovers over the middle of the ceiling — every vertex sample + // misses, only the centroid (2, 2) lands inside it. + const nodes = stackedNodes([ + slabNode('slab_deck', { + polygon: [ + [1.5, 1.5], + [2.5, 1.5], + [2.5, 2.5], + [1.5, 2.5], + ], + elevation: 0, + thickness: 0.3, + }), + ]) + expect(getCeilingClampBound('level_0', nodes, ceilingPolygon)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) + + test('returns Infinity for an unresolvable level', () => { + const nodes = stackedNodes([]) + expect(getCeilingClampBound('level_missing', nodes, ceilingPolygon)).toBe( + Number.POSITIVE_INFINITY, + ) + }) + + test('vertices on the covering slab boundary clamp identically on every side', () => { + // Two mirrored strips share an edge with the 4x4 deck: one along its + // min-z edge, one along its max-z edge. Their interiors and centroids sit + // outside the deck, so only the shared-edge vertices can register — + // ray-cast pointInPolygon used to admit the min-side vertices and reject + // the max-side ones, giving orientation-dependent clamps. + const nodes = stackedNodes([slabNode('slab_deck', { elevation: 0, thickness: 0.3 })]) + const minSideStrip: Array<[number, number]> = [ + [0, -1], + [4, -1], + [4, 0], + [0, 0], + ] + const maxSideStrip: Array<[number, number]> = [ + [0, 4], + [4, 4], + [4, 5], + [0, 5], + ] + expect(getCeilingClampBound('level_0', nodes, minSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + expect(getCeilingClampBound('level_0', nodes, maxSideStrip)).toBeCloseTo( + 2.2 - CEILING_CLAMP_MARGIN, + ) + }) +}) diff --git a/packages/core/src/services/storey.ts b/packages/core/src/services/storey.ts new file mode 100644 index 00000000..4fd6563f --- /dev/null +++ b/packages/core/src/services/storey.ts @@ -0,0 +1,358 @@ +import type { BuildingNode, LevelNode, SlabNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { + pointInPolygon, + pointOnPolygonBoundary, + wallOverlapsSlabFootprint, +} from '../systems/slab/slab-support' +import { DEFAULT_LEVEL_HEIGHT } from './level-height' + +/** + * Gap kept between a ceiling's stored height and its clamp bound (storey + * plane or covering-slab underside), so the ceiling surface never + * coincides with the solid above it. + */ +export const CEILING_CLAMP_MARGIN = 0.01 + +/** + * Stored storey height in meters (floor-to-floor). Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} for unmigrated legacy levels whose `height` + * field is absent. + */ +export function getStoredLevelHeight(level: Pick): number { + return level.height ?? DEFAULT_LEVEL_HEIGHT +} + +export type LevelElevation = { + /** World Y of the level's floor: prefix sum of the storey heights below it. */ + baseY: number + /** Stored storey height of this level (fallback applied). */ + height: number + buildingId: string | null + ordinal: number +} + +/** + * Resolves the owning building: explicit `parentId` pointing at a building + * wins; legacy levels that only appear in a building's `children` array + * resolve through that membership. + */ +function resolveLevelBuildingId( + levelId: LevelNode['id'], + parentId: string | null, + buildings: readonly BuildingNode[], +): string | null { + const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined + if (directParent) return directParent.id + + return buildings.find((building) => building.children.includes(levelId))?.id ?? null +} + +/** + * Per-building stacked elevations from stored storey heights: levels are + * sorted by ordinal ascending within each building, the lowest level's floor + * sits at 0, and each next floor sits on top of the previous storey height. + * Levels with no resolvable building share one legacy stack from 0. + * + * Pure — operates on the serialized nodes record only. + */ +export function getLevelElevations(nodes: Record): Map { + const buildings = Object.values(nodes).filter( + (node): node is BuildingNode => node?.type === 'building', + ) + + const entries: Array<{ levelId: string } & LevelElevation> = [] + for (const node of Object.values(nodes)) { + if (node?.type !== 'level') continue + const level = node as LevelNode + entries.push({ + levelId: level.id, + baseY: 0, + height: getStoredLevelHeight(level), + buildingId: resolveLevelBuildingId(level.id, level.parentId, buildings), + ordinal: level.level, + }) + } + + const elevations = new Map() + const cumulativeYByBuilding = new Map() + for (const entry of entries.sort((a, b) => a.ordinal - b.ordinal)) { + const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 + elevations.set(entry.levelId, { + baseY, + height: entry.height, + buildingId: entry.buildingId, + ordinal: entry.ordinal, + }) + cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) + } + + return elevations +} + +/** + * The id of the level directly above `levelId` in its own stack (same + * resolved building, or the shared legacy stack for building-less levels): + * the level with the lowest ordinal strictly greater than the queried + * level's. `null` when the level is topmost or unresolvable. + */ +export function findLevelAboveId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let aboveId: string | null = null + let aboveOrdinal = Number.POSITIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal > entry.ordinal && candidate.ordinal < aboveOrdinal) { + aboveOrdinal = candidate.ordinal + aboveId = candidateId + } + } + return aboveId +} + +/** + * The level directly above `levelId` — see {@link findLevelAboveId}. + * `null` when topmost or unresolvable. Pure. + */ +export function getLevelAbove( + levelId: string, + nodes: Record, +): LevelNode | null { + const aboveId = findLevelAboveId(levelId, getLevelElevations(nodes)) + if (!aboveId) return null + const above = nodes[aboveId as LevelNode['id']] + return above?.type === 'level' ? (above as LevelNode) : null +} + +/** + * The id of the level directly below `levelId` in its own stack — mirror of + * {@link findLevelAboveId}: the level with the highest ordinal strictly less + * than the queried level's. `null` when the level is lowest or unresolvable. + */ +export function findLevelBelowId( + levelId: string, + elevations: Map, +): string | null { + const entry = elevations.get(levelId) + if (!entry) return null + + let belowId: string | null = null + let belowOrdinal = Number.NEGATIVE_INFINITY + for (const [candidateId, candidate] of elevations) { + if (candidateId === levelId) continue + if (candidate.buildingId !== entry.buildingId) continue + if (candidate.ordinal < entry.ordinal && candidate.ordinal > belowOrdinal) { + belowOrdinal = candidate.ordinal + belowId = candidateId + } + } + return belowId +} + +/** + * The level directly below `levelId` — see {@link findLevelBelowId}. + * `null` when lowest or unresolvable. Pure. + */ +export function getLevelBelow( + levelId: string, + nodes: Record, +): LevelNode | null { + const belowId = findLevelBelowId(levelId, getLevelElevations(nodes)) + if (!belowId) return null + const below = nodes[belowId as LevelNode['id']] + return below?.type === 'level' ? (below as LevelNode) : null +} + +type CoveringSlabContext = { + /** Stored storey height of the QUERIED level. */ + storeyHeight: number + /** Non-recessed slab children of the level above. */ + slabs: SlabNode[] +} + +/** + * Storey height of the queried level plus the level-above's covering + * (non-recessed) slabs. `null` when `levelId` doesn't resolve to a level. + * A missing level above yields an empty slab list, not `null` — the + * storey height is still meaningful for the clamp bound. + */ +function resolveCoveringSlabContext( + levelId: string, + nodes: Record, +): CoveringSlabContext | null { + const level = nodes[levelId as LevelNode['id']] + if (level?.type !== 'level') return null + + const above = getLevelAbove(levelId, nodes) + const slabs: SlabNode[] = [] + for (const childId of above?.children ?? []) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'slab') continue + const slab = child as SlabNode + // Recessed slabs (pools) are open shells, not covering solids. + if (slab.recessed === true) continue + if (slab.polygon.length < 3) continue + slabs.push(slab) + } + + return { storeyHeight: getStoredLevelHeight(level as LevelNode), slabs } +} + +/** + * Underside of `slab`'s solid in the QUERIED level's local Y. The solid + * occupies `[elevation - thickness, elevation]` in ITS level's local Y, + * which sits `storeyHeight` above the queried level's floor. + */ +function coveringUndersideY(storeyHeight: number, slab: SlabNode): number { + return storeyHeight + ((slab.elevation ?? 0.05) - (slab.thickness ?? 0.05)) +} + +/** + * Whether `slab`'s stored footprint (polygon minus holes) covers `[x, z]`. + * Ray-cast pointInPolygon flips arbitrarily for points exactly ON the + * boundary (min-side edges read inside, max-side edges outside), so + * boundary contact counts as covered explicitly — the same convention as + * the slab-support interval classification. A point on a hole's rim keeps + * coverage (mirrors the support election's hole handling). + * + * Raw stored polygon + holes on purpose (mirrors getCeilingAt): the + * clamp bound doesn't need the rendered footprint's junction trims, + * and staying off the render path keeps this query cheap and pure. + */ +function slabCoversPoint(slab: SlabNode, x: number, z: number): boolean { + if (!pointInPolygon(x, z, slab.polygon) && !pointOnPolygonBoundary(x, z, slab.polygon)) { + return false + } + for (const hole of slab.holes ?? []) { + if (hole.length < 3) continue + if (pointInPolygon(x, z, hole) && !pointOnPolygonBoundary(x, z, hole)) return false + } + return true +} + +/** + * Lowest underside among `slabs` covering `[x, z]`, in the queried + * level's local Y, or `null` when none covers the point. + */ +function lowestCoveringUndersideAt( + context: CoveringSlabContext, + x: number, + z: number, +): number | null { + let lowest: number | null = null + for (const slab of context.slabs) { + if (!slabCoversPoint(slab, x, z)) continue + const underside = coveringUndersideY(context.storeyHeight, slab) + if (lowest === null || underside < lowest) lowest = underside + } + return lowest +} + +/** + * Underside of the LOWEST slab from the level above that covers + * level-local point `[x, z]`, expressed in the queried level's local Y: + * `storeyHeight + (slab.elevation - slab.thickness)`. `recessed` slabs + * (pools) never cover. `null` when no covering slab (or no level above). + * + * Coordinate spaces: levels stack in Y only (`LevelNode` carries no XZ + * transform and the viewer's LevelSystem writes only `position.y`), so a + * level-local `[x, z]` is valid in every level of the stack unchanged. + */ +export function getCoveringSlabUndersideAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return null + return lowestCoveringUndersideAt(context, x, z) +} + +/** + * Top plane for a plane-bound wall on `levelId`, in level-local Y: + * `min(stored storey height, lowest covering-slab underside over the wall's + * span)` — a thick or flush slab on the level above SHORTENS the walls below + * instead of colliding with them (Revit-style automatic attach). + * + * Coverage: the wall's thickness band (centerline + face lines, arc-aware) + * is clipped against each covering slab's stored polygon minus holes via + * {@link wallOverlapsSlabFootprint} — the same overlap machinery as the + * support election. Point sampling is deliberately avoided: auto-slab + * polygons derive from wall CENTERLINES, so perimeter walls sit exactly ON + * the polygon boundary, where ray-cast point-in-polygon flips with the + * edge's orientation (one wall clamped, its neighbor didn't). Boundary + * contact counts as covered on every side of the slab. + * + * This is THE plane for a plane-bound wall (`height` absent). Explicit-height + * walls ignore the value (`resolveWallTop` returns their stored height), so + * passing it wherever a raw storey height feeds `resolveWallTop` / + * `resolveWallEffectiveHeight` is always safe. Falls back to + * {@link DEFAULT_LEVEL_HEIGHT} when `levelId` doesn't resolve to a level. + */ +export function getWallPlaneTop( + wall: Pick & Partial>, + levelId: string, + nodes: Record, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return DEFAULT_LEVEL_HEIGHT + + let plane = context.storeyHeight + for (const slab of context.slabs) { + const underside = coveringUndersideY(context.storeyHeight, slab) + if (underside >= plane) continue + if (!wallOverlapsSlabFootprint(wall, slab.polygon, slab.holes)) continue + plane = underside + } + return plane +} + +/** + * Upper bound for a ceiling's stored height over `polygon` on `levelId`: + * `min(storey plane, lowest covering-slab underside) - CEILING_CLAMP_MARGIN`. + * The covering underside is sampled at every polygon vertex plus the + * centroid — cheap, and a slab overlapping a convex-ish ceiling almost + * always covers one of those points; exact polygon-vs-polygon overlap is + * not worth its cost for a clamp bound. Ceiling outlines share footprint + * edges with the slabs above them the same way walls do, so vertices + * sitting exactly on a slab's boundary count as covered on every side + * (see `slabCoversPoint`) instead of flipping with the edge orientation. + * + * Returns `Infinity` when `levelId` doesn't resolve, so callers clamp + * against nothing rather than a garbage plane. + */ +export function getCeilingClampBound( + levelId: string, + nodes: Record, + polygon: ReadonlyArray<[number, number]>, +): number { + const context = resolveCoveringSlabContext(levelId, nodes) + if (!context) return Number.POSITIVE_INFINITY + + let bound = context.storeyHeight + if (polygon.length > 0) { + let cx = 0 + let cz = 0 + for (const [x, z] of polygon) { + cx += x + cz += z + } + const samples: Array<[number, number]> = [ + ...polygon, + [cx / polygon.length, cz / polygon.length], + ] + for (const [x, z] of samples) { + const underside = lowestCoveringUndersideAt(context, x, z) + if (underside !== null && underside < bound) bound = underside + } + } + + return bound - CEILING_CLAMP_MARGIN +} diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 3b0d09b5..e56623ec 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -498,8 +498,21 @@ function parseCreatedNode(node: AnyNode, parentId: AnyNodeId | null): AnyNode { return sanitized.value as AnyNode } +// An explicit `key: undefined` in update data REMOVES the key: optional +// fields like wall.height encode a mode by their absence (absent = +// plane-bound top), and zod's safeParse echoes explicit-undefined keys, so +// a plain spread would leave a lingering own key that breaks `'height' in +// node` checks. +function mergeNodeUpdate(currentNode: AnyNode, patch: Partial): AnyNode { + const merged: Record = { ...currentNode, ...patch } + for (const key of Object.keys(patch)) { + if ((patch as Record)[key] === undefined) delete merged[key] + } + return merged as AnyNode +} + function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode { - const candidate = { ...currentNode, ...data } + const candidate = mergeNodeUpdate(currentNode, data) const parsed = AnyNodeSchema.safeParse(candidate) if (parsed.success) return parsed.data @@ -507,12 +520,12 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode const sanitized = sanitizeNumericValue(schema, data, currentNode, []) if (sanitized.issues.length === 0) { - return candidate as AnyNode + return candidate } warnSanitizedNodeMutation('update', currentNode.id, sanitized.issues) - return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode + return mergeNodeUpdate(currentNode, sanitized.value as Partial) } function shouldRefreshDefaultRidgeVents(data: Partial) { @@ -590,7 +603,10 @@ function areWallStylesCompatible(a: WallNode, b: WallNode) { (a.parentId ?? null) === (b.parentId ?? null) && Math.abs((a.curveOffset ?? 0) - (b.curveOffset ?? 0)) <= 1e-6 && Math.abs((a.thickness ?? 0.2) - (b.thickness ?? 0.2)) <= 1e-6 && - Math.abs((a.height ?? 2.5) - (b.height ?? 2.5)) <= 1e-6 && + // Absent height means plane-bound (follows the storey), which must never + // merge with an explicit height — even one that currently matches the plane. + (a.height == null) === (b.height == null) && + Math.abs((a.height ?? 0) - (b.height ?? 0)) <= 1e-6 && aInterior === bInterior && aExterior === bExterior && a.frontSide === b.frontSide && @@ -1129,6 +1145,31 @@ export const deleteNodesAction = ( } } + // Deleting a slab strips `supportSlabId` / `deckSlabId` references from + // surviving nodes in the same undo commit (mirrors the collectionIds + // cleanup below), so those nodes re-elect their support / re-derive + // their rise. Deletion is the ONLY writer — a host merely reshaped away + // keeps the field and the read path falls back, letting hosting resume + // if the slab returns. + const deletedSlabIds = new Set() + for (const id of allIds) { + if (nextNodes[id]?.type === 'slab') deletedSlabIds.add(id) + } + if (deletedSlabIds.size > 0) { + for (const [nodeId, node] of Object.entries(nextNodes)) { + if (allIds.has(nodeId as AnyNodeId)) continue + const patch: { supportSlabId?: undefined; deckSlabId?: undefined } = {} + const hostId = (node as { supportSlabId?: string }).supportSlabId + if (hostId && deletedSlabIds.has(hostId)) patch.supportSlabId = undefined + const deckId = (node as { deckSlabId?: string }).deckSlabId + if (deckId && deletedSlabIds.has(deckId)) patch.deckSlabId = undefined + if (Object.keys(patch).length > 0) { + nextNodes[nodeId as AnyNodeId] = { ...node, ...patch } as AnyNode + nodesToMarkDirty.add(nodeId as AnyNodeId) + } + } + } + for (const id of allIds) { const node = nextNodes[id] if (!node) continue diff --git a/packages/core/src/store/actions/node-mutation-sanitize.test.ts b/packages/core/src/store/actions/node-mutation-sanitize.test.ts index 1863b538..3e54d6d4 100644 --- a/packages/core/src/store/actions/node-mutation-sanitize.test.ts +++ b/packages/core/src/store/actions/node-mutation-sanitize.test.ts @@ -14,6 +14,22 @@ type RafFn = (cb: (t: number) => void) => number const SHELF_ID = 'shelf_sanitize' as AnyNodeId const SOLAR_PANEL_ID = 'sp_x' as AnyNodeId +const WALL_ID = 'wall_keyremoval' as AnyNodeId + +function makeWall(): AnyNode { + return { + id: WALL_ID, + type: 'wall', + parentId: null, + object: 'node', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + height: 2.5, + } as unknown as AnyNode +} function makeShelf(overrides: Partial = {}): AnyNode { return { @@ -173,3 +189,45 @@ describe('node mutation numeric sanitization', () => { expect(Number.isFinite(created.thickness)).toBe(true) }) }) + +describe('node update explicit-undefined key removal', () => { + beforeEach(() => { + useScene.setState({ + nodes: { [WALL_ID]: makeWall() }, + rootNodeIds: [WALL_ID], + dirtyNodes: new Set(), + collections: {}, + readOnly: false, + } as never) + useScene.temporal.getState().clear() + }) + + test('an undefined value in update data removes the key from the stored node', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + }) + + test('undo restores a key removed via an undefined update value', () => { + useScene.getState().updateNode(WALL_ID, { height: undefined } as Partial) + expect('height' in (useScene.getState().nodes[WALL_ID] as Record)).toBe(false) + + useScene.temporal.getState().undo() + + const wall = useScene.getState().nodes[WALL_ID] as { height?: number } + expect('height' in wall).toBe(true) + expect(wall.height).toBe(2.5) + }) + + test('other keys in the same patch still apply when one is removed', () => { + useScene.getState().updateNode(WALL_ID, { + height: undefined, + name: 'Plane-bound wall', + } as Partial) + + const wall = useScene.getState().nodes[WALL_ID] as Record + expect('height' in wall).toBe(false) + expect(wall.name).toBe('Plane-bound wall') + }) +}) diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts index b2aef7dd..6c235583 100644 --- a/packages/core/src/store/use-live-transforms.ts +++ b/packages/core/src/store/use-live-transforms.ts @@ -7,6 +7,14 @@ import { create } from 'zustand' export type LiveTransform = { position: [number, number, number] rotation: number // Y-axis rotation (plan-view rotation) + /** + * Pointer-decided support cap (level-local Y) published by 3D drags: + * the elevation of the surface the cursor ray actually points at. The + * floor-elevation system passes it to the slab-support election so a + * deck above the aimed-at floor never lifts the dragged node. Absent + * for 2D floorplan drags (no camera ray) — election stays uncapped. + */ + supportElevationCap?: number } type LiveTransformState = { diff --git a/packages/core/src/store/use-scene-commits.test.ts b/packages/core/src/store/use-scene-commits.test.ts index 5dc7d332..88b9a82f 100644 --- a/packages/core/src/store/use-scene-commits.test.ts +++ b/packages/core/src/store/use-scene-commits.test.ts @@ -666,7 +666,9 @@ describe('scene commit boundary', () => { const snapshot = currentSnapshot() snapshot.nodes = { ...snapshot.nodes, - [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], level: 8 } as AnyNode, + // Marker must survive the load migration: level ordinals renumber on + // load, so the stored storey height marks the applied snapshot instead. + [LEVEL_ID]: { ...snapshot.nodes[LEVEL_ID], height: 8 } as AnyNode, } snapshot.installedPlugins = ['pascal:trees'] const commits: SceneCommit[] = [] @@ -674,7 +676,7 @@ describe('scene commit boundary', () => { useScene.getState().dirtyNodes.clear() expect(applySceneSnapshot(snapshot, { origin: 'host' })).toBe(true) - expect(levelNumber()).toBe(8) + expect((useScene.getState().nodes[LEVEL_ID] as { height?: number }).height).toBe(8) expect(useScene.getState().installedPlugins).toEqual(['pascal:trees']) expect(commits.map((commit) => commit.origin)).toEqual(['host']) expect(useScene.temporal.getState().pastStates).toHaveLength(0) diff --git a/packages/core/src/store/use-scene-construction-dimension-migration.test.ts b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts new file mode 100644 index 00000000..6becb6d1 --- /dev/null +++ b/packages/core/src/store/use-scene-construction-dimension-migration.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +describe('scene construction-dimension migrations', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('normalizes the legacy reference presentation before parsing', () => { + useScene.getState().setScene( + { + site_test: { + object: 'node', + id: 'site_test', + type: 'site', + parentId: null, + visible: true, + metadata: {}, + children: ['building_test'], + }, + building_test: { + object: 'node', + id: 'building_test', + type: 'building', + parentId: 'site_test', + visible: true, + metadata: {}, + children: ['level_test'], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: ['construction-dimension_test'], + level: 0, + }, + 'construction-dimension_test': { + object: 'node', + id: 'construction-dimension_test', + type: 'construction-dimension', + parentId: 'level_test', + visible: true, + metadata: {}, + reference: true, + referenceStyle: 'suffix', + drawingOverrides: [{ drawingType: 'roof-plan', presentation: 'reference' }], + }, + } as unknown as Record, + ['site_test'] as never, + ) + + const dimension = useScene.getState().nodes['construction-dimension_test'] as AnyNode & + Record + expect(dimension.reference).toBeUndefined() + expect(dimension.referenceStyle).toBeUndefined() + expect(dimension.drawingOverrides).toEqual([ + { drawingType: 'roof-plan', presentation: 'shown' }, + ]) + }) +}) diff --git a/packages/core/src/store/use-scene-vertical-migration.test.ts b/packages/core/src/store/use-scene-vertical-migration.test.ts new file mode 100644 index 00000000..a95e7839 --- /dev/null +++ b/packages/core/src/store/use-scene-vertical-migration.test.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { AnyNode } from '../schema' +import useScene from './use-scene' + +type RawNode = Record + +function baseNode(id: string, type: string, parentId: string | null, extra: RawNode = {}): RawNode { + return { object: 'node', id, type, parentId, visible: true, metadata: {}, ...extra } +} + +function site(children: string[]): RawNode { + return baseNode('site_test', 'site', null, { children }) +} + +function building(id: string, children: string[]): RawNode { + return baseNode(id, 'building', 'site_test', { children }) +} + +function level( + id: string, + buildingId: string, + ordinal: number, + children: string[], + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'level', buildingId, { level: ordinal, children, ...extra }) +} + +function wall( + id: string, + levelId: string, + start: [number, number], + end: [number, number], + height?: number, +): RawNode { + return baseNode(id, 'wall', levelId, { + start, + end, + children: [], + ...(height !== undefined ? { height } : {}), + }) +} + +function slab( + id: string, + levelId: string, + polygon: Array<[number, number]>, + elevation = 0.05, +): RawNode { + return baseNode(id, 'slab', levelId, { polygon, holes: [], elevation }) +} + +function ceiling( + id: string, + levelId: string, + polygon: Array<[number, number]>, + height: number, + extra: RawNode = {}, +): RawNode { + return baseNode(id, 'ceiling', levelId, { polygon, holes: [], height, ...extra }) +} + +function stair(id: string, levelId: string, extra: RawNode = {}): RawNode { + return baseNode(id, 'stair', levelId, { position: [1, 0, 1], children: [], ...extra }) +} + +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], +] + +function loadScene(nodes: Record): Record { + useScene.getState().setScene(nodes as unknown as Record, ['site_test'] as never) + return useScene.getState().nodes as Record +} + +type LevelResult = Extract +type WallResult = Extract +type StairResult = Extract +type SlabResult = Extract +type CeilingResult = Extract + +describe('scene vertical model migration', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + } as never) + useScene.temporal.getState().clear() + }) + + test('default legacy storey derives height 2.5 and keeps walls plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('hole pattern: walls within 0.20 of the plane become plane-bound', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_tall', 'wall_a', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + wall_b: wall('wall_b', 'level_a', [0, 4], [4, 4]), + }) + + // Plane 0.05 + 2.65 = 2.7; absent walls top out at 2.55, 0.15 short. + expect((nodes.level_a as LevelResult).height).toBe(0.05 + 2.65) + expect('height' in (nodes.wall_tall as WallResult)).toBe(false) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect('height' in (nodes.wall_b as WallResult)).toBe(false) + }) + + test('intentional short walls at or beyond 0.20 keep their explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a', 'wall_b']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0], 2.3), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.1), + }) + + expect((nodes.level_a as LevelResult).height).toBe(2.5) + expect((nodes.wall_a as WallResult).height).toBe(2.3) + expect((nodes.wall_b as WallResult).height).toBe(2.1) + }) + + test('absent-height wall well short of the plane materializes the 2.5 default', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'wall_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 3.0), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.wall_a as WallResult).height).toBe(2.5) + }) + + test('ordinal renumber compacts per building, anchored at zero', () => { + const nodes = loadScene({ + site_test: site(['building_a', 'building_b']), + building_a: building('building_a', ['level_a1', 'level_a2', 'level_a3']), + building_b: building('building_b', ['level_b1', 'level_b2', 'level_b3', 'level_b4']), + // Duplicate fractional ordinals (MCP wrote elevation params here). + level_a1: level('level_a1', 'building_a', 2.5, []), + level_a2: level('level_a2', 'building_a', 2.5, []), + level_a3: level('level_a3', 'building_a', 5, []), + // Basements compact upward toward -1, non-negatives down to 0. + level_b1: level('level_b1', 'building_b', -3, []), + level_b2: level('level_b2', 'building_b', -1, []), + level_b3: level('level_b3', 'building_b', 0, []), + level_b4: level('level_b4', 'building_b', 4, []), + }) + + expect((nodes.level_a1 as LevelResult).level).toBe(0) + expect((nodes.level_a2 as LevelResult).level).toBe(1) + expect((nodes.level_a3 as LevelResult).level).toBe(2) + + expect((nodes.level_b1 as LevelResult).level).toBe(-2) + expect((nodes.level_b2 as LevelResult).level).toBe(-1) + expect((nodes.level_b3 as LevelResult).level).toBe(0) + expect((nodes.level_b4 as LevelResult).level).toBe(1) + }) + + test('near-bound ceiling heights become follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + // Legacy default: ceiling 2.5 drives the derived level height 2.5, + // so the clamp bound is 2.49 and |2.5 − 2.49| < 0.20 → follows. + level_a: level('level_a', 'building_a', 0, ['ceiling_a']), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.5), + // Already write-clamped default: 2.49 under a derived 2.49 level + // (bound 2.48) → follows too. + level_b: level('level_b', 'building_a', 1, ['ceiling_b']), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 2.49), + }) + + expect('height' in (nodes.ceiling_a as CeilingResult)).toBe(false) + expect('height' in (nodes.ceiling_b as CeilingResult)).toBe(false) + }) + + test('an intentional low ceiling keeps its explicit height', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // The 3.0 wall drives the plane; the 2.0 ceiling sits 0.99 under + // the 2.99 bound — a deliberate dropped ceiling, kept explicit. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_low']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_low: ceiling('ceiling_low', 'level_a', SQUARE, 2.0), + }) + + expect((nodes.level_a as LevelResult).height).toBe(3.0) + expect((nodes.ceiling_low as CeilingResult).height).toBe(2.0) + }) + + test('autoFromWalls ceilings always convert to follows-mode', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // 2.2 is far from the 2.99 bound, but auto heights were always + // derived by the sync — never user intent — so it drops anyway. + level_a: level('level_a', 'building_a', 0, ['wall_tall', 'ceiling_auto']), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 3.0), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.2, { autoFromWalls: true }), + }) + + expect('height' in (nodes.ceiling_auto as CeilingResult)).toBe(false) + }) + + test('migrated scene keeps a near-bound ceiling height (gate respected)', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + // Post-migration scene (level carries height): a stored 2.49 IS a + // deliberately typed value and must survive reloads. + level_a: level('level_a', 'building_a', 0, ['ceiling_a', 'ceiling_auto'], { height: 2.5 }), + ceiling_a: ceiling('ceiling_a', 'level_a', SQUARE, 2.49), + ceiling_auto: ceiling('ceiling_auto', 'level_a', SQUARE, 2.49, { autoFromWalls: true }), + }) + + expect((nodes.ceiling_a as CeilingResult).height).toBe(2.49) + expect((nodes.ceiling_auto as CeilingResult).height).toBe(2.49) + }) + + test('legacy scene drops totalRise 2.5 but keeps other rises', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a', 'stair_b']), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + }) + + expect('totalRise' in (nodes.stair_a as StairResult)).toBe(false) + expect((nodes.stair_b as StairResult).totalRise).toBe(3.1) + }) + + test('migrated scene keeps a deliberately typed totalRise 2.5', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['stair_a'], { height: 2.5 }), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + }) + + expect((nodes.stair_a as StairResult).totalRise).toBe(2.5) + }) + + test('already-migrated level and its walls are untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'wall_a', 'wall_b'], { height: 4.0 }), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_a: wall('wall_a', 'level_a', [0, 0], [4, 0]), + wall_b: wall('wall_b', 'level_a', [4, 0], [4, 4], 2.5), + }) + + expect((nodes.level_a as LevelResult).height).toBe(4.0) + expect('height' in (nodes.wall_a as WallResult)).toBe(false) + expect((nodes.wall_b as WallResult).height).toBe(2.5) + }) + + test('slab split writes thickness = elevation exactly for legacy solids', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a', 'slab_b']), + slab_a: slab('slab_a', 'level_a', SQUARE, 0.3), + slab_b: slab('slab_b', 'level_a', SQUARE, 0), + }) + + const raised = nodes.slab_a as SlabResult + expect(raised.elevation).toBe(0.3) + expect(raised.thickness).toBe(0.3) + expect(raised.recessed).not.toBe(true) + + // Degenerate zero-elevation slab keeps its zero occupied interval — + // migration never clamps to MIN_SLAB_THICKNESS. + const flush = nodes.slab_b as SlabResult + expect(flush.elevation).toBe(0) + expect(flush.thickness).toBe(0) + }) + + test('slab split defaults an absent elevation to the effective 0.05 thickness', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: baseNode('slab_a', 'slab', 'level_a', { polygon: SQUARE, holes: [] }), + }) + + expect((nodes.slab_a as SlabResult).thickness).toBe(0.05) + }) + + test('legacy pool becomes recessed with its elevation unchanged', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + slab_a: slab('slab_a', 'level_a', SQUARE, -0.15), + }) + + const pool = nodes.slab_a as SlabResult + expect(pool.elevation).toBe(-0.15) + expect(pool.recessed).toBe(true) + expect(pool.thickness).toBe(0.05) + }) + + test('slab with thickness already present is untouched', () => { + const nodes = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a']), + level_a: level('level_a', 'building_a', 0, ['slab_a']), + // A below-plane SOLID (already-split scene): the gate must not + // reinterpret its negative elevation as a pool. + slab_a: baseNode('slab_a', 'slab', 'level_a', { + polygon: SQUARE, + holes: [], + elevation: -0.15, + thickness: 0.3, + }), + }) + + const deck = nodes.slab_a as SlabResult + expect(deck.elevation).toBe(-0.15) + expect(deck.thickness).toBe(0.3) + expect('recessed' in deck).toBe(false) + }) + + test('migration is idempotent', () => { + const first = loadScene({ + site_test: site(['building_a']), + building_a: building('building_a', ['level_a', 'level_b']), + level_a: level('level_a', 'building_a', 2.5, [ + 'slab_a', + 'wall_tall', + 'wall_a', + 'stair_a', + 'stair_b', + ]), + level_b: level('level_b', 'building_a', 5, ['ceiling_b', 'wall_b']), + slab_a: slab('slab_a', 'level_a', SQUARE), + wall_tall: wall('wall_tall', 'level_a', [0, 0], [4, 0], 2.65), + wall_a: wall('wall_a', 'level_a', [4, 0], [4, 4]), + stair_a: stair('stair_a', 'level_a', { totalRise: 2.5 }), + stair_b: stair('stair_b', 'level_a', { totalRise: 3.1 }), + ceiling_b: ceiling('ceiling_b', 'level_b', SQUARE, 3.0), + wall_b: wall('wall_b', 'level_b', [0, 0], [4, 0]), + }) + + const second = loadScene(structuredClone(first) as unknown as Record) + + expect(second).toEqual(first) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index c4638887..a27991e4 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -32,6 +32,10 @@ import { type SceneMaterialId, } from '../schema/scene-material' import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema } from '../schema/types' +import { deriveLegacyLevelHeight } from '../services/level-height' +import { getCeilingClampBound } from '../services/storey' +import { computeWallSlabSupport } from '../systems/slab/slab-support' +import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint' import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { @@ -91,6 +95,7 @@ function getVector3(value: unknown, fallback: [number, number, number]): [number } function normalizeStairNode(node: Record) { + const hasTotalRise = 'totalRise' in node const sanitized = { ...node, position: getVector3(node.position, [0, 0, 0]), @@ -101,7 +106,7 @@ function normalizeStairNode(node: Record) { slabOpeningMode: getEnumValue(node.slabOpeningMode, ['none', 'destination'] as const, 'none'), openingOffset: getFiniteNumber(node.openingOffset, 0), width: getFiniteNumber(node.width, 1), - totalRise: getFiniteNumber(node.totalRise, 2.5), + totalRise: hasTotalRise ? getFiniteNumber(node.totalRise, 2.5) : undefined, stepCount: getFiniteNumber(node.stepCount, 10), thickness: getFiniteNumber(node.thickness, 0.25), fillToFloor: getBoolean(node.fillToFloor, true), @@ -117,7 +122,13 @@ function normalizeStairNode(node: Record) { } const parsed = StairNodeSchema.safeParse(sanitized) - return parsed.success ? parsed.data : null + if (!parsed.success) return null + if (hasTotalRise) return parsed.data + // Absent `totalRise` means "rise derives from the storey height" and must + // survive the load: safeParse echoes the sanitized explicit-undefined key, + // which would flip `'totalRise' in node` checks — strip it back off. + const { totalRise: _totalRise, ...rest } = parsed.data + return rest } function normalizeStairSegmentNode(node: Record) { @@ -559,6 +570,36 @@ function migrateRoofSurfaceMaterials(node: Record) { return next } +function migrateConstructionDimension(node: Record) { + const drawingOverrides = Array.isArray(node.drawingOverrides) ? node.drawingOverrides : [] + const hasLegacyDrawingOverride = drawingOverrides.some( + (entry) => + entry && + typeof entry === 'object' && + !Array.isArray(entry) && + entry.presentation === 'reference', + ) + if (!('reference' in node || 'referenceStyle' in node || hasLegacyDrawingOverride)) return node + + const { reference: _reference, referenceStyle: _referenceStyle, ...dimension } = node + return { + ...dimension, + drawingOverrides: drawingOverrides.map((entry) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry + return entry.presentation === 'reference' ? { ...entry, presentation: 'shown' } : entry + }), + } +} + +// Walls whose top lands within this of the storey plane become plane-bound; +// ceilings whose stored height lands within this of their clamp bound become +// follows-mode (step 3f) — same census-backed threshold for both. +// From a prod census: the 0.15-short "hole pattern" (default 2.5 walls next to +// a taller wall) must snap to the plane, while intentional 0.20-short walls +// (2.5 under a 2.7 plane, 2.3 under a 2.5 plane) must keep their explicit +// height — hence 0.20 with a strictly-less-than comparison. +const PLANE_BOUND_EPSILON = 0.2 + function migrateNodes(nodes: Record): { nodes: Record mintedMaterials: Record @@ -667,6 +708,10 @@ function migrateNodes(nodes: Record): { } } + if (node.type === 'construction-dimension') { + patchedNodes[id] = migrateConstructionDimension(node) + } + if (node.type === 'stair') { const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { @@ -886,6 +931,169 @@ function migrateNodes(nodes: Record): { } } + // Pass 3: vertical building model. + // A level without `height` marks a scene saved before the vertical model + // landed. Computed before this pass mutates anything: the stair-rise + // cleanup below must never run on already-migrated scenes. + const isLegacyScene = Object.values(patchedNodes).some( + (node) => node?.type === 'level' && !('height' in node), + ) + + // 3a. Ordinal renumber — always runs, per building (idempotent + // self-healing; MCP's create-level historically wrote its elevation PARAM + // into the ordinal, so fractional/duplicate ordinals exist in the wild). + const buildingNodes = Object.values(patchedNodes).filter((node) => node?.type === 'building') + const levelsByBuilding = new Map>() + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'level') continue + // Mirrors the building resolution in services/storey.ts: an explicit + // parentId pointing at a building wins, membership in a building's + // children array is the legacy fallback, and unresolvable levels share + // one orphan bucket. + const buildingId = + buildingNodes.find((building) => building.id === node.parentId)?.id ?? + buildingNodes.find((building) => getStringArray(building.children).includes(id))?.id ?? + null + const bucket = levelsByBuilding.get(buildingId) ?? [] + bucket.push({ id, ordinal: getFiniteNumber(node.level, 0) }) + levelsByBuilding.set(buildingId, bucket) + } + for (const bucket of levelsByBuilding.values()) { + // Anchored at zero on purpose: ordinals are semantic — `level < 0` + // renders "Basement N" and `level === 0` is the ground-floor default — + // so negatives compact upward toward −1 and non-negatives compact down + // to 0. A blind 0..n renumber would rename basements. + const sorted = [...bucket].sort((a, b) => a.ordinal - b.ordinal) + const negativeCount = sorted.filter((entry) => entry.ordinal < 0).length + sorted.forEach((entry, index) => { + const nextOrdinal = index - negativeCount + const current = patchedNodes[entry.id] + if (current.level !== nextOrdinal) { + patchedNodes[entry.id] = { ...current, level: nextOrdinal } + } + }) + } + + // 3b. Stored storey heights: materialize the legacy stacked height verbatim + // (never rounded or snapped — snapping would move existing buildings). + // All planes derive before any wall height below mutates. + const legacyLevelIds = Object.entries(patchedNodes) + .filter(([, node]) => node?.type === 'level' && !('height' in node)) + .map(([id]) => id) + const derivedHeights = new Map() + for (const levelId of legacyLevelIds) { + derivedHeights.set( + levelId, + deriveLegacyLevelHeight(levelId, patchedNodes as Record), + ) + } + + for (const levelId of legacyLevelIds) { + const plane = derivedHeights.get(levelId)! + const level = patchedNodes[levelId] + patchedNodes[levelId] = { ...level, height: plane } + + // 3c. Wall-top classification against the just-written plane, using the + // same slab-support election as deriveLegacyLevelHeight (call shape + // mirrored from services/level-height.ts). Walls whose top meets the + // plane drop their explicit height and follow the level from now on; + // walls ending short (or tall) keep an explicit height — materializing + // the 2.5 default onto absent-height walls that end short of the plane. + const children = getStringArray(level.children) + .map((childId) => patchedNodes[childId]) + .filter((child) => child !== undefined) + const slabs = children.filter((child) => child.type === 'slab') + const walls = children.filter((child) => child.type === 'wall') + for (const wall of walls) { + const electedBase = computeWallSlabSupport( + { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + }, + slabs, + walls, + ).elevation + const effectiveHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const top = Math.max(0, electedBase) + effectiveHeight + if (Math.abs(plane - top) < PLANE_BOUND_EPSILON) { + if ('height' in wall) { + const { height: _height, ...planeBound } = wall + patchedNodes[wall.id] = planeBound + } + } else { + patchedNodes[wall.id] = { ...wall, height: effectiveHeight } + } + } + } + + // 3d. Stair rise: on legacy scenes a totalRise of exactly 2.5 is the old + // schema default, not a user choice — drop it so the rise derives from the + // storey height. Gated on isLegacyScene because on a post-migration scene + // a stored 2.5 IS a deliberately typed value and must survive reloads. + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'stair') continue + if (node.totalRise !== 2.5) continue + const { totalRise: _totalRise, ...derivedRise } = node + patchedNodes[id] = derivedRise + } + } + + // 3e. Slab placement/thickness split. `elevation` stays the walking surface; + // the new `thickness` grows downward so the solid occupies + // [elevation − thickness, elevation]. Legacy solids extruded [0, elevation], + // so thickness = elevation EXACTLY (including degenerate 0 — MIN_SLAB_THICKNESS + // applies to edits only, never here) keeps the occupied interval identical. + // Legacy pools (elevation < 0) become explicit `recessed` intent with + // elevation unchanged. Gated per slab on a missing `thickness` — the + // migration output is cast, so schema defaults never materialize on load. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'slab' || 'thickness' in node) continue + const elevation = getFiniteNumber(node.elevation, 0.05) + patchedNodes[id] = + elevation < 0 + ? { ...node, thickness: 0.05, recessed: true } + : { ...node, thickness: elevation } + } + + // 3f. Ceiling follows-mode classification (the ceiling mirror of 3c; runs + // after 3b/3e so the clamp bound sees stored level heights and split slab + // thicknesses). A stored ceiling height within PLANE_BOUND_EPSILON of its + // clamp bound (min(storey plane, covering-slab underside) − margin, via + // getCeilingClampBound) is the legacy default tracking the level top, not + // a choice — drop it so the ceiling follows the level from now on. + // autoFromWalls ceilings always convert: their height was derived by the + // space-detection sync, never user intent. Gated on isLegacyScene, which + // is exact — nothing shipped between the level-height migration and this + // one — and makes the step idempotent. Known accepted edge: a + // post-migration user typing a custom height exactly equal to the bound + // keeps it (the gate prevents re-classification on later loads). + if (isLegacyScene) { + for (const [id, node] of Object.entries(patchedNodes)) { + if (node?.type !== 'ceiling' || !('height' in node)) continue + const dropHeight = () => { + const { height: _height, ...follows } = node + patchedNodes[id] = follows + } + if (node.autoFromWalls === true) { + dropHeight() + continue + } + if (typeof node.parentId !== 'string') continue + const bound = getCeilingClampBound( + node.parentId, + patchedNodes as Record, + Array.isArray(node.polygon) ? node.polygon : [], + ) + const stored = getFiniteNumber(node.height, Number.NaN) + if (Number.isFinite(bound) && Math.abs(stored - bound) < PLANE_BOUND_EPSILON) { + dropHeight() + } + } + } + return { nodes: patchedNodes as Record, mintedMaterials } } @@ -1163,6 +1371,7 @@ const useScene: UseSceneStore = create()( const level0 = LevelNode.parse({ level: 0, children: [], + height: 2.5, }) const building = BuildingNode.parse({ diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 736ffe1d..124746e9 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -1,13 +1,5 @@ -import type { - AnyNode, - AnyNodeId, - CeilingNode, - ElevatorNode, - LevelNode, - WallNode, -} from '../../schema' - -export const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 +import type { AnyNode, AnyNodeId, ElevatorNode, LevelNode } from '../../schema' +import { getStoredLevelHeight } from '../../services/storey' export type ElevatorLevelEntry = { id: LevelNode['id'] @@ -81,28 +73,6 @@ export function resolveElevatorServiceLevels( return levels.slice(minIndex, maxIndex + 1) } -export function getElevatorLevelHeight(levelId: string, nodes: Record): number { - const level = nodes[levelId as AnyNodeId] as LevelNode | undefined - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - const height = (child as CeilingNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } else if (child.type === 'wall') { - const height = (child as WallNode).height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT - if (height > maxTop) maxTop = height - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - export function resolveElevatorLevels( elevator: ElevatorNode, nodes: Record, @@ -119,7 +89,7 @@ export function resolveElevatorLevels( let cumulativeY = 0 for (const level of allLevels) { baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getElevatorLevelHeight(level.id, nodes) + cumulativeY += getStoredLevelHeight(level) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) diff --git a/packages/core/src/systems/slab/slab-support.test.ts b/packages/core/src/systems/slab/slab-support.test.ts new file mode 100644 index 00000000..758d31ab --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'bun:test' +import { SlabNode, WallNode } from '../../schema' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' +import { + clampSlabElevationForWalls, + computeWallSlabSupport, + getSlabElevationUpperBound, +} from './slab-support' + +// 4×3 room slab drawn on the wall centerlines, like an auto-slab. +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + +const STOREY_HEIGHT = 2.7 +const BOUND = STOREY_HEIGHT - MIN_WALL_HEIGHT + +function roomSlab(elevation: number) { + return SlabNode.parse({ polygon: SQUARE, elevation, autoFromWalls: true }) +} + +function roomWalls(height?: number) { + return [ + WallNode.parse({ start: [0, 0], end: [4, 0], height }), + WallNode.parse({ start: [4, 0], end: [4, 3], height }), + WallNode.parse({ start: [4, 3], end: [0, 3], height }), + WallNode.parse({ start: [0, 3], end: [0, 0], height }), + ] +} + +describe('clampSlabElevationForWalls', () => { + it('clamps a slab under plane-bound walls at the plane minus MIN_WALL_HEIGHT', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(true) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('leaves proposals at or below the bound untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(BOUND, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(BOUND) + }) + + it('passes negative (recessed-committing) proposals through untouched', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(-0.6, slab, roomWalls(), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(-0.6) + }) + + it('does not clamp when the walls all carry explicit heights', () => { + const slab = roomSlab(0.05) + const result = clampSlabElevationForWalls(2.5, slab, roomWalls(2.5), [slab], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) + + it('does not clamp a slab covering no walls', () => { + const island = SlabNode.parse({ + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.05, + }) + const result = clampSlabElevationForWalls(2.5, island, roomWalls(), [island], STOREY_HEIGHT) + + expect(result.clamped).toBe(false) + expect(result.elevation).toBeCloseTo(2.5) + }) +}) + +describe('getSlabElevationUpperBound', () => { + it('bounds a slab electable by plane-bound walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(), [slab], STOREY_HEIGHT)).toBeCloseTo(BOUND) + }) + + it('is unbounded under explicit-height walls', () => { + const slab = roomSlab(0.05) + expect(getSlabElevationUpperBound(slab, roomWalls(2.5), [slab], STOREY_HEIGHT)).toBe( + Number.POSITIVE_INFINITY, + ) + }) +}) + +describe('computeWallSlabSupport preferred host', () => { + const wallLike = { start: [0, 1.5] as [number, number], end: [4, 1.5] as [number, number] } + const low = SlabNode.parse({ + id: 'slab_low', + polygon: SQUARE, + elevation: 0.1, + autoFromWalls: true, + }) + const high = SlabNode.parse({ + id: 'slab_high', + polygon: SQUARE, + elevation: 0.6, + autoFromWalls: true, + }) + + it('elects the highest supporting elevation without a preference', () => { + const support = computeWallSlabSupport(wallLike, [low, high], []) + expect(support.elevation).toBeCloseTo(0.6) + }) + + it('pins the elected elevation to a still-supporting preferred slab', () => { + const support = computeWallSlabSupport(wallLike, [low, high], [], 'slab_low') + expect(support.elevation).toBeCloseTo(0.1) + // Fill-down machinery still derives from ALL supporting slabs. + expect(support.baseSegments).toHaveLength(1) + expect(support.baseSegments[0]!.elevation).toBeCloseTo(0.6) + }) + + it('ignores a preferred slab that no longer supports the wall', () => { + const island = SlabNode.parse({ + id: 'slab_island', + polygon: [ + [10, 10], + [12, 10], + [12, 12], + [10, 12], + ], + elevation: 0.9, + }) + const support = computeWallSlabSupport(wallLike, [low, high, island], [], 'slab_island') + expect(support.elevation).toBeCloseTo(0.6) + }) +}) diff --git a/packages/core/src/systems/slab/slab-support.ts b/packages/core/src/systems/slab/slab-support.ts new file mode 100644 index 00000000..fb308d57 --- /dev/null +++ b/packages/core/src/systems/slab/slab-support.ts @@ -0,0 +1,688 @@ +import { getRenderableSlabPolygon } from '../../lib/slab-polygon' +import type { SlabNode, WallNode } from '../../schema' +import { getWallCurveFrameAt, isCurvedWall } from '../wall/wall-curve' +import { DEFAULT_WALL_THICKNESS } from '../wall/wall-footprint' +import { MIN_WALL_HEIGHT } from '../wall/wall-top' + +export type SlabElevationClamp = { + elevation: number + clamped: boolean +} + +/** + * Clamp-never-ask upper bound for a slab's elevation. A plane-bound wall + * (no stored `height`) keeps its top at the storey plane, so a slab that + * rises past `storeyHeight - MIN_WALL_HEIGHT` while electing as that + * wall's base would squeeze the wall body below its minimum (and at the + * plane, to nothing). Walls with explicit heights don't constrain — their + * top rides the elected base, not the plane. Negative proposals (the + * drag-through-zero path that commits the `recessed` intent) pass + * through untouched: this is a purely numeric upper bound. + * + * The election runs against `levelSlabs` with `proposedElevation` + * substituted into `slab`, so a slab that would only WIN the election at + * the proposed elevation still clamps, and a slab out-elected by a + * sibling doesn't. Pure. + */ +export function clampSlabElevationForWalls( + proposedElevation: number, + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): SlabElevationClamp { + const bound = storeyHeight - MIN_WALL_HEIGHT + if (proposedElevation <= bound) return { elevation: proposedElevation, clamped: false } + if (slab.polygon.length < 3) return { elevation: proposedElevation, clamped: false } + + const substituted = levelSlabs.some((candidate) => candidate.id === slab.id) + ? levelSlabs.map((candidate) => + candidate.id === slab.id ? { ...candidate, elevation: proposedElevation } : candidate, + ) + : [...levelSlabs, { ...slab, elevation: proposedElevation }] + + for (const wall of levelWalls) { + if (wall.height != null) continue + const wallLike: WallOverlapInput = { + start: wall.start, + end: wall.end, + curveOffset: wall.curveOffset, + thickness: wall.thickness, + } + // Cheap pre-filter: a wall that never reaches the slab's footprint + // can't elect it, whatever the election says about sibling slabs. + if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue + const support = computeWallSlabSupport(wallLike, substituted, levelWalls) + if (Math.abs(support.elevation - proposedElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) { + return { elevation: bound, clamped: true } + } + } + + return { elevation: proposedElevation, clamped: false } +} + +/** + * Static upper bound for a slab-elevation drag: probe the election with + * the slab raised above every sibling and the storey plane. If any + * plane-bound wall would elect it there, the drag may not pass + * `storeyHeight - MIN_WALL_HEIGHT`; otherwise it is unbounded above. + */ +export function getSlabElevationUpperBound( + slab: SlabNode, + levelWalls: WallNode[], + levelSlabs: readonly SlabNode[], + storeyHeight: number, +): number { + const probe = + Math.max(storeyHeight, ...levelSlabs.map((candidate) => candidate.elevation ?? 0.05)) + 1 + return clampSlabElevationForWalls(probe, slab, levelWalls, levelSlabs, storeyHeight).clamped + ? storeyHeight - MIN_WALL_HEIGHT + : Number.POSITIVE_INFINITY +} + +/** + * Point-in-polygon test using ray casting algorithm. + */ +export function pointInPolygon(px: number, pz: number, polygon: Array<[number, number]>): boolean { + let inside = false + const n = polygon.length + for (let i = 0, j = n - 1; i < n; j = i++) { + const xi = polygon[i]![0], + zi = polygon[i]![1] + const xj = polygon[j]![0], + zj = polygon[j]![1] + + if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) { + inside = !inside + } + } + return inside +} + +function pointSegmentDistance( + px: number, + pz: number, + ax: number, + az: number, + bx: number, + bz: number, +): number { + const dx = bx - ax + const dz = bz - az + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-18) return Math.hypot(px - ax, pz - az) + const t = Math.max(0, Math.min(1, ((px - ax) * dx + (pz - az) * dz) / lengthSquared)) + return Math.hypot(px - (ax + dx * t), pz - (az + dz * t)) +} + +// Ray-cast pointInPolygon is unreliable for points exactly on the polygon +// boundary: the answer flips depending on which side of the polygon the edge +// is on. Interval classification below therefore treats "within this distance +// of the boundary" as inside explicitly, so walls sitting exactly on a slab +// edge (the common case — auto-slab polygons derive from wall centerlines) +// classify identically on every side of the slab. +const ON_BOUNDARY_EPSILON = 1e-4 + +export function pointOnPolygonBoundary( + px: number, + pz: number, + polygon: Array<[number, number]>, +): boolean { + const n = polygon.length + for (let i = 0; i < n; i++) { + const [ax, az] = polygon[i]! + const [bx, bz] = polygon[(i + 1) % n]! + if (pointSegmentDistance(px, pz, ax, az, bx, bz) <= ON_BOUNDARY_EPSILON) return true + } + return false +} + +/** Sub-interval along a segment or polyline: [start, end] in length units. */ +type LengthInterval = [number, number] + +function mergeIntervals(intervals: LengthInterval[]): LengthInterval[] { + if (intervals.length <= 1) return intervals + const sorted = [...intervals].sort((a, b) => a[0] - b[0]) + const merged: LengthInterval[] = [[sorted[0]![0], sorted[0]![1]]] + for (let i = 1; i < sorted.length; i++) { + const [intervalStart, intervalEnd] = sorted[i]! + const last = merged[merged.length - 1]! + if (intervalStart <= last[1] + 1e-9) { + last[1] = Math.max(last[1], intervalEnd) + } else { + merged.push([intervalStart, intervalEnd]) + } + } + return merged +} + +/** Total length of a merged (sorted, disjoint) interval list. */ +function intervalsLength(intervals: readonly LengthInterval[]): number { + let total = 0 + for (const [intervalStart, intervalEnd] of intervals) total += intervalEnd - intervalStart + return total +} + +/** `base` minus `cut`. Both inputs may be unsorted; the result is merged. */ +function subtractIntervals(base: LengthInterval[], cut: LengthInterval[]): LengthInterval[] { + if (base.length === 0 || cut.length === 0) return mergeIntervals(base) + const cuts = mergeIntervals(cut) + const result: LengthInterval[] = [] + for (const [baseStart, baseEnd] of mergeIntervals(base)) { + let cursor = baseStart + for (const [cutStart, cutEnd] of cuts) { + if (cutEnd <= cursor) continue + if (cutStart >= baseEnd) break + if (cutStart > cursor) result.push([cursor, cutStart]) + cursor = cutEnd + if (cursor >= baseEnd) break + } + if (cursor < baseEnd) result.push([cursor, baseEnd]) + } + return result +} + +/** + * Sub-intervals of segment (ax,az)→(bx,bz) that lie inside the polygon (and, + * when `includeBoundary`, on its boundary), as [t0, t1] fractions of the + * segment. The segment is split at every crossing with a polygon edge and + * each sub-interval is classified by its midpoint, so no test point ever + * sits on a crossing. + */ +function segmentInsideIntervals( + ax: number, + az: number, + bx: number, + bz: number, + polygon: Array<[number, number]>, + includeBoundary: boolean, +): LengthInterval[] { + const dx = bx - ax + const dz = bz - az + const length = Math.hypot(dx, dz) + if (length < 1e-9) return [] + + const ts = [0, 1] + const n = polygon.length + for (let i = 0; i < n; i++) { + const [px, pz] = polygon[i]! + const [qx, qz] = polygon[(i + 1) % n]! + const ex = qx - px + const ez = qz - pz + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-12) continue // parallel/collinear — nothing to split at + const t = ((px - ax) * ez - (pz - az) * ex) / denom + const s = ((px - ax) * dz - (pz - az) * dx) / denom + if (t > 0 && t < 1 && s >= -1e-9 && s <= 1 + 1e-9) ts.push(t) + } + ts.sort((a, b) => a - b) + + const inside: LengthInterval[] = [] + for (let i = 1; i < ts.length; i++) { + const t0 = ts[i - 1]! + const t1 = ts[i]! + if (t1 - t0 < 1e-9) continue + const tm = (t0 + t1) / 2 + const mx = ax + dx * tm + const mz = az + dz * tm + const midpointInside = pointOnPolygonBoundary(mx, mz, polygon) + ? includeBoundary + : pointInPolygon(mx, mz, polygon) + if (midpointInside) inside.push([t0, t1]) + } + return inside +} + +function polylineLength(points: Array<{ x: number; y: number }>): number { + let total = 0 + for (let i = 1; i < points.length; i++) { + total += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y) + } + return total +} + +/** + * Inside sub-intervals of a polyline against a polygon, in cumulative + * arc-length units from the polyline start (merged, disjoint). Boundary + * contact counts as inside for slab support (walls sit exactly on slab + * edges — see ON_BOUNDARY_EPSILON above); hole callers pass + * `includeBoundary: false` so a wall running along a stairwell hole's + * rim keeps the rim's support. + */ +function polylineInsideIntervals( + points: Array<{ x: number; y: number }>, + polygon: Array<[number, number]>, + includeBoundary = true, +): LengthInterval[] { + const intervals: LengthInterval[] = [] + let offset = 0 + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]! + const b = points[i]! + const segmentLength = Math.hypot(b.x - a.x, b.y - a.y) + if (segmentLength < 1e-9) continue + for (const [t0, t1] of segmentInsideIntervals(a.x, a.y, b.x, b.y, polygon, includeBoundary)) { + intervals.push([offset + t0 * segmentLength, offset + t1 * segmentLength]) + } + offset += segmentLength + } + return mergeIntervals(intervals) +} + +export type WallOverlapInput = { + start: [number, number] + end: [number, number] + curveOffset?: number + thickness?: number +} + +// Minimum length of wall that must lie on/inside a slab polygon before the +// wall counts as overlapping it. Point contact (a perpendicular wall butting +// into a room's edge) clips to ~zero length and never reaches this, so such +// walls don't follow the slab's elevation. +const WALL_SLAB_MIN_OVERLAP = 0.05 + +/** + * Centerline of the wall plus its two face lines (centerline offset by + * ±halfThickness). The face lines catch walls whose centerline sits on or + * just outside the slab boundary but whose body reaches onto the slab — + * e.g. slab polygons drawn to the room's interior faces. + */ +function wallTestPolylines( + start: [number, number], + end: [number, number], + curveOffset: number, + halfThickness: number, +): Array> { + const wallLike = { start, end, curveOffset } + if (curveOffset !== 0 && isCurvedWall(wallLike)) { + const count = 16 + const center: Array<{ x: number; y: number }> = [] + const left: Array<{ x: number; y: number }> = [] + const right: Array<{ x: number; y: number }> = [] + for (let i = 0; i <= count; i++) { + const frame = getWallCurveFrameAt(wallLike, i / count) + center.push(frame.point) + left.push({ + x: frame.point.x + frame.normal.x * halfThickness, + y: frame.point.y + frame.normal.y * halfThickness, + }) + right.push({ + x: frame.point.x - frame.normal.x * halfThickness, + y: frame.point.y - frame.normal.y * halfThickness, + }) + } + return halfThickness > 0 ? [center, left, right] : [center] + } + + const center = [ + { x: start[0], y: start[1] }, + { x: end[0], y: end[1] }, + ] + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const len = Math.hypot(dx, dz) + if (len < 1e-10 || halfThickness <= 0) return [center] + const nx = (-dz / len) * halfThickness + const nz = (dx / len) * halfThickness + return [ + center, + [ + { x: start[0] + nx, y: start[1] + nz }, + { x: end[0] + nx, y: end[1] + nz }, + ], + [ + { x: start[0] - nx, y: start[1] - nz }, + { x: end[0] - nx, y: end[1] - nz }, + ], + ] +} + +/** + * Test whether a wall overlaps a slab polygon along a segment of its length. + * + * The wall's centerline and both face lines are clipped against the polygon; + * the wall overlaps when the longest clipped inside-or-on-boundary length + * exceeds a threshold (5cm, halved for very short walls). Because interval + * midpoints classify "on the boundary" as inside explicitly (never by + * ray-cast tie-breaking), a wall sitting exactly on a slab edge resolves + * identically on every side of the slab. + * + * A wall that only touches the polygon at a point — a perpendicular wall + * butting into a room's edge, or a corner-to-corner touch — clips to ~zero + * length and does NOT overlap. + */ +export function wallOverlapsPolygon( + startOrWall: [number, number] | WallOverlapInput, + endOrPolygon: [number, number] | Array<[number, number]>, + polygonArg?: Array<[number, number]>, +): boolean { + // Two call shapes: + // wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware + // wallOverlapsPolygon(start, end, polygon) — legacy chord-only + let start: [number, number] + let end: [number, number] + let polygon: Array<[number, number]> + let curveOffset = 0 + let thickness = DEFAULT_WALL_THICKNESS + if (Array.isArray(startOrWall)) { + start = startOrWall as [number, number] + end = endOrPolygon as [number, number] + polygon = polygonArg as Array<[number, number]> + } else { + start = startOrWall.start + end = startOrWall.end + curveOffset = startOrWall.curveOffset ?? 0 + thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS + polygon = endOrPolygon as Array<[number, number]> + } + return wallOverlapsSlabFootprint({ start, end, curveOffset, thickness }, polygon) +} + +/** + * {@link wallOverlapsPolygon} with the slab's stored holes subtracted from + * the covered length: a wall whose band only reaches the polygon inside a + * hole does not overlap. Hole boundaries keep coverage (rim convention — + * see {@link computeWallSlabSupport}). Polygon boundary contact counts as + * covered, so a wall sitting exactly on a slab edge resolves identically + * on every side of the slab. Pure. + */ +export function wallOverlapsSlabFootprint( + wallLike: WallOverlapInput, + polygon: Array<[number, number]>, + holes?: ReadonlyArray>, +): boolean { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const centerLength = polylineLength(polylines[0]!) + if (centerLength < 1e-9) return false + + let overlap = 0 + for (const line of polylines) { + let intervals = polylineInsideIntervals(line, polygon) + for (const hole of holes ?? []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + overlap = Math.max(overlap, intervalsLength(intervals)) + } + const threshold = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, centerLength * 0.5)) + return overlap >= threshold +} + +/** + * Tolerance for the pointer-decided support cap: a slab still counts as + * "the surface you're pointing at (or below)" when its walking surface is + * within this many meters ABOVE the pointed elevation. Absorbs elevation + * noise between the ray hit and slab tops without letting a deck hanging + * clearly above the hit point capture the election. Defined here (rather + * than in the spatial-grid manager, which re-exports it) so the wall + * election below can honour the same cap without an import cycle. + */ +export const SUPPORT_ELEVATION_EPSILON = 0.05 + +// A slab elevation must support at least this fraction of the wall's +// length before it can dictate the wall's base. Below majority, a raised +// slab reaching one endpoint would hoist the whole wall off the floor +// that actually carries it. +const WALL_SLAB_SUPPORT_MAJORITY = 0.5 + +// Slabs whose elevations differ by less than this pool their support: +// a wall shared between two rooms' slabs is covered roughly half by +// each, and must still follow their common elevation. +const WALL_SLAB_ELEVATION_POOL_EPSILON = 1e-4 + +/** + * Base elevation for a wall, decided by which slabs actually SUPPORT it. + * + * Support is measured as covered length: the wall's centerline and face + * lines are clipped against each slab's RENDERED footprint + * (`getRenderableSlabPolygon` with the level walls + siblings, not the + * stored polygon — legacy polygons stored at wall faces or with old + * baked offsets fall short of the wall body, but their band-adopted + * rendered edge reaches the wall's outer face) minus the slab's stored + * holes (holes are data, never render-offset). A slab supporting less + * than `WALL_SLAB_MIN_OVERLAP` of the wall is ignored entirely (point + * contact, endpoint grazes). + * + * Same-elevation slabs pool their coverage. `elevation` preserves the + * existing wall-relative origin: the highest elevation covering at + * least `WALL_SLAB_SUPPORT_MAJORITY` of the wall, or the best-covered + * elevation when none reaches majority. `baseElevation` only fills down + * where a lower support remains exposed on a wall face after higher, + * overlapping support is accounted for. Coincident floor/platform slabs + * therefore keep the wall on the platform, while slabs on opposite wall + * sides bridge correctly. A slab touching only one endpoint never enters + * either result. Pure; + * exported for tests. + */ +export type WallSlabSupport = { + /** Existing wall-relative floor elevation used by hosted children and wall height. */ + elevation: number + /** Slab whose elevation won the election, or null when the wall has no support. */ + electedSlabId: string | null + /** Lowest exposed adjacent support; wall geometry fills down to this elevation. */ + baseElevation: number + /** Piecewise bottom elevation along the wall centerline, in normalized arc-length units. */ + baseSegments: WallSlabSupportSegment[] +} + +export type WallSlabSupportSegment = { + start: number + end: number + elevation: number +} + +/** + * `preferredSlabId` is a persisted support host (`wall.supportSlabId`): + * while that slab is still in the candidate set (still overlaps the wall + * band with enough covered length), the elected `elevation` is pinned to + * it instead of the majority/best-coverage election. `baseSegments` / + * `baseElevation` (fill-down) still derive from ALL supporting slabs + * unchanged. A preferred slab that no longer qualifies is silently + * ignored — deliberately never cleared here, so the host resumes if the + * slab's polygon returns (only slab deletion strips the stored field). + * + * `maxElevation` is the pointer-decided support cap (level-local Y, same + * semantics as the item election): when set, elevation groups whose + * walking surface sits above `maxElevation + SUPPORT_ELEVATION_EPSILON` + * are excluded from the majority/best election — a deck hanging above the + * surface the cursor ray actually hit never captures the elected base. + * `baseSegments` / `baseElevation` stay uncapped (geometry fill-down), and + * an explicit `preferredSlabId` still wins over the cap. + */ +export function computeWallSlabSupport( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], + preferredSlabId?: string | null, + maxElevation?: number | null, +): WallSlabSupport { + const { start, end, curveOffset = 0, thickness = DEFAULT_WALL_THICKNESS } = wallLike + const halfThickness = Math.max(thickness / 2, 0) + const polylines = wallTestPolylines(start, end, curveOffset, halfThickness) + const polylineLengths = polylines.map(polylineLength) + const wallLength = polylineLengths[0]! + if (wallLength < 1e-9) { + return { elevation: 0, electedSlabId: null, baseElevation: 0, baseSegments: [] } + } + + const minSupport = Math.max(1e-3, Math.min(WALL_SLAB_MIN_OVERLAP, wallLength * 0.5)) + + type ElevationGroup = { + elevation: number + slabIds: string[] + perPolyline: LengthInterval[][] + } + const groups: ElevationGroup[] = [] + let preferredElevation: number | null = null + let preferredElectedSlabId: string | null = null + + for (const slab of slabs) { + if (slab.polygon.length < 3) continue + const renderedPolygon = getRenderableSlabPolygon(slab, { + walls: levelWalls, + siblingSlabs: slabs.filter((other) => other.id !== slab.id), + }) + + let supported = 0 + const perPolyline = polylines.map((line) => { + let intervals = polylineInsideIntervals(line, renderedPolygon) + for (const hole of slab.holes || []) { + if (intervals.length === 0) break + if (hole.length < 3) continue + intervals = subtractIntervals(intervals, polylineInsideIntervals(line, hole, false)) + } + supported = Math.max(supported, intervalsLength(intervals)) + return intervals + }) + if (supported < minSupport) continue + + const elevation = slab.elevation ?? 0.05 + if (preferredSlabId != null && slab.id === preferredSlabId) { + preferredElevation = elevation + preferredElectedSlabId = slab.id + } + let group = groups.find( + (candidate) => Math.abs(candidate.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON, + ) + if (!group) { + group = { elevation, slabIds: [], perPolyline: polylines.map(() => []) } + groups.push(group) + } + group.slabIds.push(slab.id) + for (let i = 0; i < perPolyline.length; i++) { + group.perPolyline[i]!.push(...perPolyline[i]!) + } + } + + type EvaluatedGroup = ElevationGroup & { + coverage: number + mergedPerPolyline: LengthInterval[][] + } + const evaluatedGroups: EvaluatedGroup[] = groups.map((group) => { + let coverage = 0 + const mergedPerPolyline = group.perPolyline.map(mergeIntervals) + for (let i = 0; i < group.perPolyline.length; i++) { + const lineLength = polylineLengths[i]! + if (lineLength < 1e-9) continue + coverage = Math.max(coverage, intervalsLength(mergedPerPolyline[i]!) / lineLength) + } + return { ...group, coverage, mergedPerPolyline } + }) + + const electableGroups = + maxElevation == null + ? evaluatedGroups + : evaluatedGroups.filter( + (group) => group.elevation <= maxElevation + SUPPORT_ELEVATION_EPSILON, + ) + + let majorityElevation = Number.NEGATIVE_INFINITY + let bestElevation = Number.NEGATIVE_INFINITY + let bestCoverage = -1 + for (const group of electableGroups) { + if (group.coverage >= WALL_SLAB_SUPPORT_MAJORITY - 1e-6) { + majorityElevation = Math.max(majorityElevation, group.elevation) + } + if ( + group.coverage > bestCoverage + 1e-6 || + (Math.abs(group.coverage - bestCoverage) <= 1e-6 && group.elevation > bestElevation) + ) { + bestCoverage = group.coverage + bestElevation = group.elevation + } + } + + const elevation = + preferredElevation !== null + ? preferredElevation + : majorityElevation !== Number.NEGATIVE_INFINITY + ? majorityElevation + : bestElevation === Number.NEGATIVE_INFINITY + ? 0 + : bestElevation + const electedSlabId = + preferredElectedSlabId ?? + electableGroups + .find((group) => Math.abs(group.elevation - elevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON) + ?.slabIds.slice() + .sort()[0] ?? + null + const normalizedIntervals = (group: EvaluatedGroup, polylineIndex: number) => { + const lineLength = polylineLengths[polylineIndex]! + if (lineLength < 1e-9) return [] + return group.mergedPerPolyline[polylineIndex]!.map( + ([intervalStart, intervalEnd]) => + [intervalStart / lineLength, intervalEnd / lineLength] as LengthInterval, + ) + } + + const normalizedByGroup = evaluatedGroups.map((group) => ({ + elevation: group.elevation, + perPolyline: group.mergedPerPolyline.map((_, index) => normalizedIntervals(group, index)), + })) + const breakpoints = [0, 1] + for (const group of normalizedByGroup) { + for (const intervals of group.perPolyline) { + for (const [intervalStart, intervalEnd] of intervals) { + breakpoints.push(intervalStart, intervalEnd) + } + } + } + breakpoints.sort((left, right) => left - right) + const uniqueBreakpoints = breakpoints.filter( + (value, index) => index === 0 || value - breakpoints[index - 1]! > 1e-7, + ) + + const highestAt = (polylineIndex: number, t: number) => { + let highest = Number.NEGATIVE_INFINITY + for (const group of normalizedByGroup) { + if ( + group.perPolyline[polylineIndex]?.some( + ([intervalStart, intervalEnd]) => t >= intervalStart - 1e-7 && t <= intervalEnd + 1e-7, + ) + ) { + highest = Math.max(highest, group.elevation) + } + } + return highest + } + + const baseSegments: WallSlabSupportSegment[] = [] + for (let index = 1; index < uniqueBreakpoints.length; index++) { + const start = uniqueBreakpoints[index - 1]! + const end = uniqueBreakpoints[index]! + if (end - start < 1e-7) continue + const midpoint = (start + end) / 2 + const leftElevation = polylines.length >= 3 ? highestAt(1, midpoint) : Number.NEGATIVE_INFINITY + const rightElevation = polylines.length >= 3 ? highestAt(2, midpoint) : Number.NEGATIVE_INFINITY + const faceElevations = [leftElevation, rightElevation].filter(Number.isFinite) + const segmentElevation = + faceElevations.length > 0 ? Math.min(...faceElevations) : Math.max(highestAt(0, midpoint), 0) + const previous = baseSegments[baseSegments.length - 1] + if ( + previous && + Math.abs(previous.elevation - segmentElevation) <= WALL_SLAB_ELEVATION_POOL_EPSILON + ) { + previous.end = end + } else { + baseSegments.push({ start, end, elevation: segmentElevation }) + } + } + + if (baseSegments.length === 0) baseSegments.push({ start: 0, end: 1, elevation }) + const baseElevation = Math.min(...baseSegments.map((segment) => segment.elevation)) + return { elevation, electedSlabId, baseElevation, baseSegments } +} + +export function computeWallSlabElevation( + wallLike: WallOverlapInput, + slabs: readonly SlabNode[], + levelWalls: WallNode[], +): number { + return computeWallSlabSupport(wallLike, slabs, levelWalls).elevation +} diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 36b1c2ff..0cd760cf 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -9,8 +9,10 @@ import type { StairSegmentNode, SurfaceHoleMetadata, } from '../../schema' -import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' +import { resolveCeilingHeight } from '../../services/level-height' +import { getLevelElevations } from '../../services/storey' import { computeSegmentTransforms, rotateXZ } from './stair-footprint' +import { resolveStairTotalRise } from './stair-rise' type SegmentTransform = { position: [number, number, number] @@ -463,7 +465,7 @@ function getStraightOpeningPolygonsForSurface( const layouts = getStraightStairLayouts(stair, nodes) if (layouts.length === 0) return [] - const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1) + const riserHeight = resolveStairTotalRise(stair, nodes) / Math.max(stair.stepCount ?? 10, 1) const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) const openingOffset = Math.max(openingOffsetOverride ?? stair.openingOffset ?? 0, 0) const openingRects: AxisAlignedRect[] = [] @@ -605,17 +607,16 @@ function getTargetSlabElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const slabLevel = getLevelNumber(slabLevelId, nodes) + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const slabElevation = elevations.get(slabLevelId) - if (fromLevel === undefined || slabLevel === undefined) { + if (!fromElevation || !slabElevation || fromElevation.buildingId !== slabElevation.buildingId) { return slab.elevation ?? 0.05 } return ( - (slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (slab.elevation ?? 0.05) - - (stair.position[1] ?? 0) + slabElevation.baseY - fromElevation.baseY + (slab.elevation ?? 0.05) - (stair.position[1] ?? 0) ) } @@ -626,18 +627,21 @@ function getTargetCeilingElevationForStair( nodes: Record, ) { const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) - const fromLevel = getLevelNumber(fromLevelId, nodes) - const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) + const elevations = getLevelElevations(nodes as Record) + const fromElevation = fromLevelId ? elevations.get(fromLevelId) : undefined + const ceilingElevation = elevations.get(ceilingLevelId) - if (fromLevel === undefined || ceilingLevel === undefined) { - return ceiling.height ?? DEFAULT_WALL_HEIGHT + const ceilingHeight = resolveCeilingHeight(ceiling, nodes as Record) + + if ( + !fromElevation || + !ceilingElevation || + fromElevation.buildingId !== ceilingElevation.buildingId + ) { + return ceilingHeight } - return ( - (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + - (ceiling.height ?? DEFAULT_WALL_HEIGHT) - - (stair.position[1] ?? 0) - ) + return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0) } function shouldApplyStairToSlab( diff --git a/packages/core/src/systems/stair/stair-opening-system.tsx b/packages/core/src/systems/stair/stair-opening-system.tsx index b78224cf..357134e6 100644 --- a/packages/core/src/systems/stair/stair-opening-system.tsx +++ b/packages/core/src/systems/stair/stair-opening-system.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useRef } from 'react' -import type { AnyNode } from '../../schema' +import type { AnyNode, AnyNodeId } from '../../schema' import { pauseSceneHistory, resumeSceneHistory } from '../../store/history-control' import useLiveNodeOverrides from '../../store/use-live-node-overrides' import useLiveTransforms from '../../store/use-live-transforms' @@ -12,6 +12,7 @@ import { hasLiveStairOpeningInputs, } from './stair-opening-preview' import { syncAutoStairOpenings } from './stair-opening-sync' +import { syncStairRises } from './stair-rise' function isOpeningRelevantNode(node: AnyNode | undefined) { return ( @@ -47,7 +48,7 @@ export const StairOpeningSystem = () => { const previewControllerRef = useRef(createSurfaceOpeningPreviewController()) useEffect(() => { - const applyUpdates = (updates: ReturnType) => { + const applyUpdates = (updates: Array<{ id: AnyNodeId; data: Partial }>) => { if (updates.length === 0) return syncingAutoOpeningsRef.current = true pauseSceneHistory(useScene) @@ -103,14 +104,40 @@ export const StairOpeningSystem = () => { ) } - applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) - refreshLivePreview() + const runAutoSync = () => { + // Rise first: straight stairs converge their flight heights to the + // resolved rise (level height or deck elevation), and the opening pass + // reads those segment heights — so it must run against the post-rise + // nodes. + applyUpdates(syncStairRises(useScene.getState().nodes)) + applyUpdates(syncAutoStairOpenings(useScene.getState().nodes)) + } + + let disposed = false + let autoSyncQueued = false + const scheduleAutoSync = () => { + if (autoSyncQueued) return + autoSyncQueued = true + // One microtask later so every other scene-store listener for the + // triggering transition (and, at mount, the editor's spatial-grid + // init) runs first — the spatial-grid sync in particular. The + // deck-attached rise elects the stair's floor-stack base elevation + // through the spatial grid; syncing before the grid listener would + // rescale flights against the pre-transition slab state. + queueMicrotask(() => { + autoSyncQueued = false + if (disposed) return + runAutoSync() + refreshLivePreview() + }) + } + + scheduleAutoSync() const unsubscribeScene = useScene.subscribe((state, prevState) => { if (syncingAutoOpeningsRef.current) return if (!hasOpeningRelevantNodeChange(state.nodes, prevState.nodes)) return - applyUpdates(syncAutoStairOpenings(state.nodes)) - refreshLivePreview() + scheduleAutoSync() }) const unsubscribeLiveTransforms = useLiveTransforms.subscribe(() => { @@ -122,6 +149,7 @@ export const StairOpeningSystem = () => { }) return () => { + disposed = true unsubscribeScene() unsubscribeLiveTransforms() unsubscribeLiveOverrides() diff --git a/packages/core/src/systems/stair/stair-rise.test.ts b/packages/core/src/systems/stair/stair-rise.test.ts new file mode 100644 index 00000000..00309cbf --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.test.ts @@ -0,0 +1,465 @@ +import { beforeEach, describe, expect, it } from 'bun:test' +import { z } from 'zod' +import { + GROUND_SUPPORT_ID, + getFloorPlacedElevation, +} from '../../hooks/spatial-grid/floor-placed-elevation' +import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' +import { nodeRegistry, registerNode } from '../../registry' +import type { AnyNodeDefinition } from '../../registry/types' +import type { AnyNode, StairNode as StairNodeType } from '../../schema' +import { LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import { resolveStairTotalRise, syncStairRises } from './stair-rise' + +// The deck branch elects the stair's floor-stack base through the node +// registry + spatial grid singletons — reset them so tests are hermetic +// (base elects 0 unless a test registers a stair footprint and slabs). +beforeEach(() => { + nodeRegistry._reset() + spatialGridManager.clear() +}) + +function buildScene(levelHeight: number | undefined, totalRise: number | undefined) { + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + ...(totalRise !== undefined ? { totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + children: ['stair_1'], + ...(levelHeight !== undefined ? { height: levelHeight } : {}), + }) + return { stair, nodes: { level_1: level, stair_1: stair } } +} + +function makeDeck(elevation: number, polygon?: Array<[number, number]>) { + return SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: polygon ?? [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation, + thickness: 0.05, + }) +} + +function buildDeckScene(options: { + deckElevation: number + deckPolygon?: Array<[number, number]> + totalRise?: number + deckSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const deck = makeDeck(options.deckElevation, options.deckPolygon) + const segments = (options.segments ?? []).map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + deckSlabId: options.deckSlabId ?? deck.id, + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: 2.5, + children: ['stair_1', deck.id], + }) + const nodes: Record = { + level_1: level, + stair_1: stair, + [deck.id]: deck, + } + for (const segment of segments) nodes[segment.id] = segment + return { deck, stair, nodes } +} + +function buildLevelSceneWithSegments(options: { + levelHeight: number + totalRise?: number + segments: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> +}) { + const segments = options.segments.map((segment) => + StairSegmentNode.parse({ + id: segment.id, + type: 'stair-segment', + segmentType: segment.segmentType, + width: 1, + length: 2, + height: segment.height, + stepCount: 8, + parentId: 'stair_1', + }), + ) + const stair = StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + children: segments.map((segment) => segment.id), + ...(options.totalRise !== undefined ? { totalRise: options.totalRise } : {}), + }) + const level = LevelNode.parse({ + id: 'level_1', + type: 'level', + level: 0, + height: options.levelHeight, + children: ['stair_1'], + }) + const nodes: Record = { level_1: level, stair_1: stair } + for (const segment of segments) nodes[segment.id] = segment + return { level, stair, nodes } +} + +describe('resolveStairTotalRise', () => { + it('derives the rise from the containing level stored height when absent', () => { + const { stair, nodes } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(3.2) + }) + + it('tracks a storey height change without any stair write', () => { + const { stair, nodes } = buildScene(2.55, undefined) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.55) + const level = nodes.level_1 + if (level.type !== 'level') throw new Error('expected level') + const updated = { ...nodes, level_1: { ...level, height: 3.0 } } + expect(resolveStairTotalRise(stair, updated)).toBe(3.0) + }) + + it('prefers an explicit totalRise over the storey height', () => { + const { stair, nodes } = buildScene(3.2, 2.5) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) + + it('falls back to the default when the stair has no containing level', () => { + const { stair } = buildScene(3.2, undefined) + expect(resolveStairTotalRise(stair, {})).toBe(2.5) + }) + + it('derives the rise from the attached deck elevation', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(1.25) + }) + + it('tracks a deck elevation change without any stair write', () => { + const { deck, stair, nodes } = buildDeckScene({ deckElevation: 1.25 }) + const updated = { ...nodes, [deck.id]: { ...deck, elevation: 1.6 } } + expect(resolveStairTotalRise(stair, updated)).toBe(1.6) + }) + + it('prefers an explicit totalRise over the attached deck', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('falls through a stale deckSlabId to the storey height silently', () => { + const { stair, nodes } = buildDeckScene({ deckElevation: 1.25, deckSlabId: 'slab_gone' }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.5) + }) +}) + +describe('syncStairRises', () => { + it('writes the deck elevation into a single flight segment', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 1.6 } }]) + }) + + it('is a no-op when the flights already match the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('scales multiple flights proportionally and leaves landings alone', () => { + const { nodes } = buildDeckScene({ + deckElevation: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('distributes an explicit custom rise instead of the deck elevation', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.25, + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.0 } }]) + }) + + it('falls a stale deckSlabId back to the storey height', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a stale-deck stair with an explicit rise untouched', () => { + const { nodes } = buildDeckScene({ + deckElevation: 1.6, + deckSlabId: 'slab_gone', + totalRise: 2.0, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) + + it('converges a level-following straight stair to the storey height', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.0 }], + }) + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('converges a level-following stair after a storey height change', () => { + const scene = buildLevelSceneWithSegments({ + levelHeight: 2.5, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 2.5 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const nodes = { ...scene.nodes, level_1: { ...scene.level, height: 3.0 } as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 3.0 } }]) + }) + + it('rescales level-following flights proportionally, landings untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.1, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]).toEqual({ id: 'sseg_1' as never, data: { height: 1.0 } }) + expect(updates[1]).toEqual({ id: 'sseg_3' as never, data: { height: 1.0 } }) + }) + + it('converges back to the storey height after a deck detach', () => { + const scene = buildDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const { deckSlabId: _deckSlabId, ...detached } = scene.stair + const nodes = { ...scene.nodes, stair_1: detached as AnyNode } + expect(syncStairRises(nodes)).toEqual([{ id: 'sseg_1' as never, data: { height: 2.5 } }]) + }) + + it('leaves a detached explicit-rise stair with hand-set segments untouched', () => { + const { nodes } = buildLevelSceneWithSegments({ + levelHeight: 2.5, + totalRise: 2.0, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.9 }, + { id: 'sseg_2', segmentType: 'stair', height: 0.6 }, + ], + }) + expect(syncStairRises(nodes)).toEqual([]) + }) +}) + +// The stair stands on a floor slab (the default 0.05 one, or whatever the +// floor-stack elects) — the deck-derived rise must be measured from that +// lifted base so the last step lands flush with the deck's walking surface. +describe('deck-attached rise with a floor-lifted base', () => { + const FLOOR_POLYGON: Array<[number, number]> = [ + [-5, -5], + [5, -5], + [5, 5], + [-5, 5], + ] + // Away from the stair footprint at the origin so the base election never + // sees the deck itself. + const AWAY_DECK_POLYGON: Array<[number, number]> = [ + [8, 8], + [10, 8], + [10, 10], + [8, 10], + ] + + beforeEach(() => { + registerNode({ + kind: 'stair', + schemaVersion: 1, + schema: z.object({ type: z.literal('stair') }) as never, + category: 'structure', + defaults: () => ({}) as never, + capabilities: { + floorPlaced: { + footprints: (node) => [ + { + position: (node as StairNodeType).position, + dimensions: [1, 1, 2] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }, + ], + }, + }, + } as AnyNodeDefinition) + }) + + function makeFloorSlab(elevation: number) { + return SlabNode.parse({ + id: 'slab_floor', + type: 'slab', + polygon: FLOOR_POLYGON, + elevation, + thickness: 0.05, + }) + } + + function buildLiftedDeckScene(options: { + deckElevation: number + floorElevation?: number + totalRise?: number + supportSlabId?: string + segments?: Array<{ id: string; segmentType: 'stair' | 'landing'; height: number }> + }) { + const floor = makeFloorSlab(options.floorElevation ?? 0.05) + const scene = buildDeckScene({ + deckElevation: options.deckElevation, + deckPolygon: AWAY_DECK_POLYGON, + totalRise: options.totalRise, + segments: options.segments, + }) + const stair = options.supportSlabId + ? ({ ...scene.stair, supportSlabId: options.supportSlabId } as typeof scene.stair) + : scene.stair + const nodes: Record = { + ...scene.nodes, + stair_1: stair, + [floor.id]: floor, + } + spatialGridManager.handleNodeCreated(floor as AnyNode, 'level_1') + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + return { deck: scene.deck, floor, stair, nodes } + } + + it('lands the last step flush: rise = deck elevation − elected base', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25 }) + const base = getFloorPlacedElevation({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: 'level_1', + }) + expect(base).toBeCloseTo(0.05) + const rise = resolveStairTotalRise(stair, nodes) + expect(rise).toBeCloseTo(1.2) + // Top surface = visual base + rise = the deck's walking surface, not 1.30. + expect(base + rise).toBeCloseTo(1.25) + }) + + it('rescales a flight converged under the old rule down to the flush rise', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.25 }], + }) + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.2) + }) + + it('keeps the full deck elevation when the stair stands on bare ground', () => { + const scene = buildDeckScene({ deckElevation: 1.25, deckPolygon: AWAY_DECK_POLYGON }) + spatialGridManager.handleNodeCreated(scene.deck as AnyNode, 'level_1') + expect(resolveStairTotalRise(scene.stair, scene.nodes)).toBeCloseTo(1.25) + }) + + it('lets an explicit totalRise win over the base-adjusted deck rise', () => { + const { stair, nodes } = buildLiftedDeckScene({ deckElevation: 1.25, totalRise: 2.0 }) + expect(resolveStairTotalRise(stair, nodes)).toBe(2.0) + }) + + it('re-converges to flush after a deck elevation change', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + expect(syncStairRises(scene.nodes)).toEqual([]) + const movedDeck = { ...scene.deck, elevation: 1.6 } + const nodes = { ...scene.nodes, [scene.deck.id]: movedDeck as AnyNode } + spatialGridManager.handleNodeUpdated(movedDeck as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.55) + }) + + it('re-converges to flush after the base slab elevation changes', () => { + const scene = buildLiftedDeckScene({ + deckElevation: 1.25, + segments: [{ id: 'sseg_1', segmentType: 'stair', height: 1.2 }], + }) + const movedFloor = { ...scene.floor, elevation: 0.3 } + const nodes = { ...scene.nodes, [scene.floor.id]: movedFloor as AnyNode } + spatialGridManager.handleNodeUpdated(movedFloor as AnyNode, 'level_1') + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(1) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(0.95) + }) + + it('rescales flights proportionally from the lifted base, landings untouched', () => { + const { nodes } = buildLiftedDeckScene({ + deckElevation: 2.15, + segments: [ + { id: 'sseg_1', segmentType: 'stair', height: 0.5 }, + { id: 'sseg_2', segmentType: 'landing', height: 0.1 }, + { id: 'sseg_3', segmentType: 'stair', height: 0.5 }, + ], + }) + // Target flight rise = 2.15 − 0.05 (base) − 0.1 (landing) = 2.0 → 1.0 each. + const updates = syncStairRises(nodes) + expect(updates).toHaveLength(2) + expect(updates[0]?.id).toBe('sseg_1' as never) + expect((updates[0]?.data as { height?: number }).height).toBeCloseTo(1.0) + expect(updates[1]?.id).toBe('sseg_3' as never) + expect((updates[1]?.data as { height?: number }).height).toBeCloseTo(1.0) + }) + + it('honors a persisted ground host over the floor slab election', () => { + const { stair, nodes } = buildLiftedDeckScene({ + deckElevation: 1.25, + supportSlabId: GROUND_SUPPORT_ID, + }) + expect(resolveStairTotalRise(stair, nodes)).toBeCloseTo(1.25) + }) +}) diff --git a/packages/core/src/systems/stair/stair-rise.ts b/packages/core/src/systems/stair/stair-rise.ts new file mode 100644 index 00000000..12054831 --- /dev/null +++ b/packages/core/src/systems/stair/stair-rise.ts @@ -0,0 +1,90 @@ +import { getFloorStackedPosition } from '../../hooks/spatial-grid/floor-placed-elevation' +import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' +import { DEFAULT_LEVEL_HEIGHT } from '../../services/level-height' +import { getStoredLevelHeight } from '../../services/storey' + +export function resolveStairTotalRise(stair: StairNode, nodes: Record): number { + if (stair.totalRise !== undefined) return stair.totalRise + + const level = Object.values(nodes).find( + (node) => node.type === 'level' && node.children.includes(stair.id), + ) + + if (stair.deckSlabId) { + const deck = nodes[stair.deckSlabId] + // The deck's `elevation` IS its walking surface (level-local), but the + // stair's own base may be lifted onto a floor slab by the floor-stack + // (`FloorElevationSystem` / `syncStairGroupElevation` put the group at + // `position[1] + elected slab elevation`). The rise is measured from + // that base, so subtract it — electing the base exactly the way the + // visual systems do (persisted `supportSlabId` honored, uncapped + // election otherwise) keeps base + rise landing precisely on the deck's + // walking surface. A stale reference (deck gone) falls through to the + // level-derived rise. + if (deck?.type === 'slab') { + const baseElevation = getFloorStackedPosition({ + node: stair, + nodes, + position: stair.position, + rotation: stair.rotation, + levelId: level?.id ?? null, + })[1] + return (deck.elevation ?? 0.05) - baseElevation + } + } + + return level?.type === 'level' ? getStoredLevelHeight(level) : DEFAULT_LEVEL_HEIGHT +} + +const RISE_SYNC_EPSILON = 1e-4 + +/** + * Keeps straight stairs' flight segments in step with the resolved rise. + * Straight-stair geometry derives from per-segment heights (not from + * `resolveStairTotalRise`), so level-height and deck-elevation changes must + * write through to the flight segments — curved/spiral stairs read the + * resolved rise directly and need no sync. + * + * Scope: stairs whose total the system owns — follows-mode stairs (absent + * `totalRise`, tracking their level or their deck) and deck-attached stairs + * (an explicit rise converges to the typed value). A detached stair with an + * explicit `totalRise` is the one place hand-edited segment chains are + * legitimate, so it is never touched. Flight heights scale proportionally + * (landings keep theirs); returns `updateNodes` patches, empty when every + * stair is already in step. + */ +export function syncStairRises( + nodes: Record, +): Array<{ id: AnyNodeId; data: Partial }> { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + + for (const node of Object.values(nodes)) { + if (node.type !== 'stair' || node.stairType !== 'straight') continue + const deck = node.deckSlabId ? nodes[node.deckSlabId] : undefined + if (node.totalRise !== undefined && deck?.type !== 'slab') continue + + const segments = (node.children ?? []) + .map((childId) => nodes[childId]) + .filter((child): child is StairSegmentNode => child?.type === 'stair-segment') + const flights = segments.filter((segment) => segment.segmentType === 'stair') + if (flights.length === 0) continue + + const landingRise = segments + .filter((segment) => segment.segmentType !== 'stair') + .reduce((sum, segment) => sum + segment.height, 0) + const flightRise = flights.reduce((sum, segment) => sum + segment.height, 0) + const targetFlightRise = resolveStairTotalRise(node, nodes) - landingRise + if (targetFlightRise <= 0) continue + if (Math.abs(flightRise - targetFlightRise) <= RISE_SYNC_EPSILON) continue + + for (const flight of flights) { + const height = + flightRise > RISE_SYNC_EPSILON + ? flight.height * (targetFlightRise / flightRise) + : targetFlightRise / flights.length + updates.push({ id: flight.id as AnyNodeId, data: { height } }) + } + } + + return updates +} 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/systems/wall/wall-top.test.ts b/packages/core/src/systems/wall/wall-top.test.ts new file mode 100644 index 00000000..e452dbfe --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { resolveWallEffectiveHeight, resolveWallTop } from './wall-top' + +describe('resolveWallTop', () => { + test('explicit height on zero base keeps the stored top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('explicit height on raised base rides the base', () => { + expect(resolveWallTop({ height: 2.5 }, 3, 0.6)).toBeCloseTo(3.1) + }) + + test('explicit height on sunken base keeps the absolute top', () => { + expect(resolveWallTop({ height: 2.5 }, 3, -0.4)).toBe(2.5) + }) + + test('plane-bound wall tops out at the storey plane regardless of base', () => { + expect(resolveWallTop({}, 3, 0)).toBe(3) + expect(resolveWallTop({}, 3, 0.6)).toBe(3) + expect(resolveWallTop({}, 3, -0.4)).toBe(3) + }) +}) + +describe('resolveWallEffectiveHeight', () => { + test('explicit on raised base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0.6)).toBeCloseTo(2.5) + }) + + test('explicit on zero base extrudes the stored height', () => { + expect(resolveWallEffectiveHeight({ height: 2.5 }, 3, 0)).toBe(2.5) + }) + + test('plane-bound on raised base gets shorter, never taller', () => { + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeCloseTo(2.4) + expect(resolveWallEffectiveHeight({}, 3, 0.6)).toBeLessThan(3) + }) + + test('plane-bound on zero base spans the full storey', () => { + expect(resolveWallEffectiveHeight({}, 3, 0)).toBe(3) + }) + + test('plane-bound on sunken base fills down while the top stays at the plane', () => { + expect(resolveWallEffectiveHeight({}, 3, -0.4)).toBeCloseTo(3.4) + }) +}) diff --git a/packages/core/src/systems/wall/wall-top.ts b/packages/core/src/systems/wall/wall-top.ts new file mode 100644 index 00000000..208ed4bd --- /dev/null +++ b/packages/core/src/systems/wall/wall-top.ts @@ -0,0 +1,53 @@ +import type { WallNode } from '../../schema/nodes/wall' + +/** + * Minimum wall body height in meters. Governs both the wall height + * arrow's lower drag bound and the slab-elevation clamp: a slab may not + * rise past `storeyHeight - MIN_WALL_HEIGHT` while a plane-bound wall + * elects it as its base, or the wall's extrusion (plane minus base) + * would collapse below this minimum. + */ +export const MIN_WALL_HEIGHT = 0.5 + +/** + * Wall-top inversion (vertical building model): a wall with no stored + * `height` is plane-bound — its top sits at the storey plane (level-local + * Y = the level's stored height), so a slab lifting the wall's base makes + * the wall shorter, never taller, and no gap can open at the top of a + * level. A wall WITH `height` is an explicit exception (half wall, + * parapet) and keeps the legacy semantics: the top rides a raised elected + * base (`electedBase + height`), while a zero or sunken base leaves the + * top at `height` (the legacy negative-slab constraint). + * + * Returns the top in level-local Y (same frame as `electedBase`). + */ +export function resolveWallTop( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + if (wall.height == null) return storeyHeight + return electedBase > 0 ? electedBase + wall.height : wall.height +} + +/** + * Extruded height of the wall body: {@link resolveWallTop} minus the + * elected base. Base convention: the elected slab-support elevation itself + * — the viewer computes `effectiveBaseElevation = min(baseElevation, + * slabElevation)` and defaults `baseElevation` to the elected elevation, + * so with only the election in hand the two coincide. Fill-down below the + * elected base (`baseSegments`) is a geometry detail the extruder handles + * separately and never changes where the top sits. + * + * Equivalently: the wall-local Y of the wall's top, measured from the wall + * mesh origin (which sits at `electedBase`). May be non-positive when a + * slab reaches the storey plane; callers own the degenerate-geometry + * policy. + */ +export function resolveWallEffectiveHeight( + wall: Pick, + storeyHeight: number, + electedBase: number, +): number { + return resolveWallTop(wall, storeyHeight, electedBase) - electedBase +} diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 8b0b04a9..02aea9c5 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import type { CollectionId } from '../schema/collections' import type { AnyNode, AnyNodeId } from '../schema/types' -import { forkSceneGraph, type SceneGraph } from './clone-scene-graph' +import { + cloneLevelSubtree, + cloneSceneGraph, + forkSceneGraph, + type SceneGraph, +} from './clone-scene-graph' function makeNode(id: string, type: string, extra: Record = {}): AnyNode { return { @@ -71,3 +76,163 @@ describe('forkSceneGraph', () => { expect(forked.installedPlugins).toEqual(['pascal:trees']) }) }) + +describe('construction-dimension clone references', () => { + function sceneWithControlledDimensions(): SceneGraph { + const site = makeNode('site_1', 'site', { children: ['level_1'] }) + const level = makeNode('level_1', 'level', { + parentId: 'site_1', + children: ['construction-dimension_foundation', 'construction-dimension_floor'], + }) + const controller = makeNode('construction-dimension_foundation', 'construction-dimension', { + name: 'Foundation controller', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: null, + }) + const dependent = makeNode('construction-dimension_floor', 'construction-dimension', { + name: 'Floor dependent', + parentId: 'level_1', + anchors: [ + [0, 0, 0], + [4, 0, 0], + ], + controllingDimensionId: controller.id, + }) + return { + nodes: { + [site.id]: site, + [level.id]: level, + [controller.id]: controller, + [dependent.id]: dependent, + }, + rootNodeIds: [site.id], + } + } + + test('remaps controller IDs in whole-scene clones', () => { + const cloned = cloneSceneGraph(sceneWithControlledDimensions()) + const dimensions = Object.values(cloned.nodes).filter( + (node) => node.type === 'construction-dimension', + ) + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) + + test('remaps controller IDs in level-subtree clones', () => { + const scene = sceneWithControlledDimensions() + const cloned = cloneLevelSubtree(scene.nodes, 'level_1' as AnyNodeId) + const dimensions = cloned.clonedNodes.filter((node) => node.type === 'construction-dimension') + const controller = dimensions.find((node) => node.name === 'Foundation controller') + const dependent = dimensions.find((node) => node.name === 'Floor dependent') + + expect(controller?.type).toBe('construction-dimension') + expect(dependent?.type).toBe('construction-dimension') + if ( + controller?.type === 'construction-dimension' && + dependent?.type === 'construction-dimension' + ) { + expect(dependent.controllingDimensionId).toBe(controller.id) + } + }) +}) + +describe('drawing-sheet clone references', () => { + test('remaps placed levels and nested sheet identities in whole-scene clones', () => { + const level = makeNode('level_main', 'level') + const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', { + placedViews: [{ id: 'drawing-view_main', levelId: level.id }], + generalNoteSetIds: [], + generalNoteSets: [], + generalNotes: [], + keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_a', + definitionId: 'keyed-note_a', + placedViewId: 'drawing-view_main', + position: [1, 1], + }, + ], + keyedNoteLegend: [], + documentMarkers: [], + schedules: [], + }) + const cloned = cloneSceneGraph({ + nodes: { [level.id]: level, [sheet.id]: sheet }, + rootNodeIds: [level.id, sheet.id] as AnyNodeId[], + }) + const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level') + const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet') + + expect(clonedLevel).toBeDefined() + expect(clonedSheet?.type).toBe('drawing-sheet') + if (clonedLevel && clonedSheet?.type === 'drawing-sheet') { + expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id) + expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main') + expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe( + clonedSheet.keyedNoteDefinitions[0]?.id, + ) + expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id) + } + }) +}) + +describe('supportSlabId remap', () => { + test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const item = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + + const cloned = cloneSceneGraph({ + nodes: { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: item, + }, + rootNodeIds: ['level_1' as AnyNodeId], + }) + + const clonedSlab = Object.values(cloned.nodes).find((node) => node.type === 'slab')! + const clonedItem = Object.values(cloned.nodes).find((node) => node.type === 'item')! + expect(clonedSlab.id).not.toBe('slab_1') + expect((clonedItem as { supportSlabId?: string }).supportSlabId).toBe(clonedSlab.id) + }) + + test('cloneLevelSubtree remaps in-subtree hosts and preserves external references', () => { + const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1', 'item_2'] }) + const slab = makeNode('slab_1', 'slab', { parentId: 'level_1' }) + const hosted = makeNode('item_1', 'item', { parentId: 'level_1', supportSlabId: 'slab_1' }) + const external = makeNode('item_2', 'item', { + parentId: 'level_1', + supportSlabId: 'slab_external', + }) + + const { clonedNodes, idMap } = cloneLevelSubtree( + { + ['level_1' as AnyNodeId]: level, + ['slab_1' as AnyNodeId]: slab, + ['item_1' as AnyNodeId]: hosted, + ['item_2' as AnyNodeId]: external, + }, + 'level_1' as AnyNodeId, + ) + + const clonedHosted = clonedNodes.find((node) => node.id === idMap.get('item_1'))! + const clonedExternal = clonedNodes.find((node) => node.id === idMap.get('item_2'))! + expect((clonedHosted as { supportSlabId?: string }).supportSlabId).toBe(idMap.get('slab_1')!) + expect((clonedExternal as { supportSlabId?: string }).supportSlabId).toBe('slab_external') + }) +}) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index ad100cbe..5c6ec8bc 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -1,7 +1,12 @@ -import { remapMeasurementReferences } from '../lib/measurement-geometry' +import { GROUND_SUPPORT_ID } from '../hooks/spatial-grid/floor-placed-elevation' +import { + remapConstructionDimensionReferences, + remapMeasurementReferences, +} from '../lib/measurement-geometry' import type { AnyNode, AnyNodeId } from '../schema' import { generateId } from '../schema/base' import type { Collection, CollectionId } from '../schema/collections' +import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet' export type SceneGraph = { nodes: Record @@ -44,7 +49,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { for (const [oldId, node] of Object.entries(nodes)) { const newId = idMap.get(oldId)! as AnyNodeId - const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode + let clonedNode = structuredClone({ ...node, id: newId }) as AnyNode // Remap parentId if (clonedNode.parentId && typeof clonedNode.parentId === 'string') { @@ -85,9 +90,33 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { ) as string | undefined } + // Remap supportSlabId (persisted slab-support hosts). The 'ground' + // sentinel is not a node id — keep it as-is. + if ( + 'supportSlabId' in clonedNode && + typeof clonedNode.supportSlabId === 'string' && + clonedNode.supportSlabId !== GROUND_SUPPORT_ID + ) { + ;(clonedNode as Record).supportSlabId = idMap.get( + clonedNode.supportSlabId, + ) as string | undefined + } + + if ('deckSlabId' in clonedNode && typeof clonedNode.deckSlabId === 'string') { + ;(clonedNode as Record).deckSlabId = idMap.get(clonedNode.deckSlabId) as + | string + | undefined + } + if (clonedNode.type === 'measurement') { clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap) } + if (clonedNode.type === 'construction-dimension') { + clonedNode = remapConstructionDimensionReferences(clonedNode, idMap) + } + if (clonedNode.type === 'drawing-sheet') { + clonedNode = remapDrawingSheetReferences(clonedNode, idMap) + } clonedNodes[newId] = clonedNode } @@ -202,7 +231,7 @@ export function cloneLevelSubtree( const newId = idMap.get(oldId)! as AnyNodeId // JSON roundtrip: safely strips functions, Object3D, circular refs, etc. - const cloned = JSON.parse(JSON.stringify(node)) as AnyNode + let cloned = JSON.parse(JSON.stringify(node)) as AnyNode ;(cloned as Record).id = newId // Remap parentId — but only for descendants, not the level node itself @@ -240,9 +269,27 @@ export function cloneLevelSubtree( idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId } + // Remap supportSlabId when the host slab is inside the cloned subtree; + // preserve it otherwise (like wallId, the reference may point outside). + if ('supportSlabId' in cloned && typeof cloned.supportSlabId === 'string') { + ;(cloned as Record).supportSlabId = + idMap.get(cloned.supportSlabId) ?? cloned.supportSlabId + } + + if ('deckSlabId' in cloned && typeof cloned.deckSlabId === 'string') { + ;(cloned as Record).deckSlabId = + idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId + } + if (cloned.type === 'measurement') { cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap) } + if (cloned.type === 'construction-dimension') { + cloned = remapConstructionDimensionReferences(cloned, idMap) + } + if (cloned.type === 'drawing-sheet') { + cloned = remapDrawingSheetReferences(cloned, idMap) + } clonedNodes.push(cloned) } 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 animationFrames: FrameRequestCallback[] = [] + let disconnected = false + let observedOptions: MutationObserverInit | undefined + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(_target: Node, options?: MutationObserverInit): void { + observedOptions = options + } + + disconnect(): void { + disconnected = true + } + + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrames.push(callback) + return animationFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + + expect(layoutPasses).toBe(0) + flushAnimationFrame() + expect(layoutPasses).toBe(0) + flushAnimationFrame() + expect(layoutPasses).toBe(1) + expect(observedOptions).toMatchObject({ + attributes: true, + childList: true, + subtree: true, + attributeFilter: expect.any(Array), + }) + + notify?.( + [ + { + attributeName: 'style', + target: { closest: () => ({}) }, + type: 'attributes', + } as unknown as MutationRecord, + ], + {} as MutationObserver, + ) + expect(layoutPasses).toBe(1) + + stop() + expect(disconnected).toBe(true) + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) + + test('waits for a quiet frame instead of resolving on every mutation frame', () => { + const OriginalMutationObserver = globalThis.MutationObserver + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + let notify: MutationCallback | undefined + let animationFrames: FrameRequestCallback[] = [] + + class FakeMutationObserver { + constructor(callback: MutationCallback) { + notify = callback + } + + observe(): void {} + disconnect(): void {} + takeRecords(): MutationRecord[] { + return [] + } + } + + globalThis.MutationObserver = FakeMutationObserver as typeof MutationObserver + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + animationFrames.push(callback) + return animationFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + try { + const flushAnimationFrame = () => { + const callbacks = animationFrames + animationFrames = [] + for (const callback of callbacks) callback(0) + } + let layoutPasses = 0 + const stop = observeSvgAnnotationLayoutChanges({} as SVGSVGElement, () => { + layoutPasses += 1 + }) + + for (let frame = 0; frame < 30; frame += 1) { + notify?.([{ type: 'childList' } as MutationRecord], {} as MutationObserver) + flushAnimationFrame() + } + + expect(layoutPasses).toBe(0) + flushAnimationFrame() + expect(layoutPasses).toBe(1) + stop() + } finally { + globalThis.MutationObserver = OriginalMutationObserver + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) +}) 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..c6ab37ef --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts @@ -0,0 +1,695 @@ +import type { FloorplanGeometry } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension' + +export type AnnotationLabelRectangle = { + id: string + x: number + y: number + width: number + height: number + priority: number + text?: string + labelPlacement?: 'inside' | 'outside-end' + pinnedShift?: { dx: number; dy: number } + tangentX?: number + tangentY?: number + preferredShifts?: readonly { dx: number; dy: number }[] +} + +export type AnnotationObstacleRectangle = Pick< + AnnotationLabelRectangle, + 'x' | 'y' | 'width' | 'height' +> + +export type AnnotationLabelShift = { + id: string + dx: number + dy: number + resolved: boolean +} + +const LABEL_GAP_PX = 6 +const LABEL_PLACEMENT_GAP_PX = LABEL_GAP_PX + 0.5 +const OUTLINE_SAMPLE_SPACING_PX = 6 +const OUTLINE_OBSTACLE_PADDING_PX = 1 +const MAX_LABEL_SHIFT_CANDIDATES = 512 +const PREFERRED_SHIFT_COST_STEP = 1_000_000 +const COLLISION_GRID_CELL_SIZE_PX = 64 + +class AnnotationObstacleIndex { + private readonly cells = new Map>() + + 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(target: Node, onChange: () => void): () => void { + let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const requestFrame = globalThis.requestAnimationFrame ?? ((callback) => setTimeout(callback, 0)) + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestFrame(flushWhenSettled) + return + } + scheduledFrame = null + onChange() + } + const schedule = () => { + mutationVersion += 1 + if (scheduledFrame !== null) return + observedVersion = mutationVersion - 1 + scheduledFrame = requestFrame(flushWhenSettled) + } + const observer = new MutationObserver((mutations) => { + if (mutations.some(isAnnotationLayoutMutation)) schedule() + }) + observer.observe(target, { + attributes: true, + attributeFilter: [ + 'cx', + 'cy', + 'd', + 'dominant-baseline', + 'font-family', + 'font-size', + 'font-weight', + 'height', + 'points', + 'r', + 'rx', + 'ry', + 'stroke-width', + 'text-anchor', + 'transform', + 'visibility', + 'width', + 'x', + 'x1', + 'x2', + 'y', + 'y1', + 'y2', + ], + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame === null) return + if (globalThis.cancelAnimationFrame) globalThis.cancelAnimationFrame(scheduledFrame) + else clearTimeout(scheduledFrame) + } +} + +function isAnnotationLayoutMutation(mutation: MutationRecord): boolean { + if (mutation.type !== 'attributes') return true + const attribute = mutation.attributeName ?? '' + const target = mutation.target as Element + const closest = typeof target.closest === 'function' ? target.closest.bind(target) : null + + if ( + attribute === 'data-floorplan-annotation-id' || + attribute === 'data-floorplan-annotation-layout-dx' || + attribute === 'data-floorplan-annotation-layout-dy' || + attribute === 'data-floorplan-layout-unresolved' + ) { + return false + } + if ( + closest?.('[data-floorplan-annotation-label]') && + (attribute === 'style' || attribute === 'transform') + ) { + return false + } + if ( + closest?.('[data-floorplan-dimension-line], [data-floorplan-dimension-leader]') && + (attribute === 'x1' || + attribute === 'x2' || + attribute === 'y1' || + attribute === 'y2' || + attribute === 'visibility') + ) { + return false + } + return true +} + +export function collectAnnotationLayoutPreflightIssues( + rectangles: readonly AnnotationLabelRectangle[], + shifts: readonly AnnotationLabelShift[], + obstacles: readonly AnnotationObstacleRectangle[] = [], +): AnnotationPreflightIssue[] { + const shiftsById = new Map(shifts.map((shift) => [shift.id, shift])) + const finalRectangles = rectangles.map((rectangle) => { + const shift = shiftsById.get(rectangle.id) ?? { + id: rectangle.id, + dx: 0, + dy: 0, + resolved: false, + } + return { + source: rectangle, + shift, + bounds: { + x: rectangle.x + shift.dx, + y: rectangle.y + shift.dy, + width: rectangle.width, + height: rectangle.height, + }, + } + }) + const issues: AnnotationPreflightIssue[] = [] + const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => { + if (issues.some((issue) => issue.id === id && issue.kind === kind)) return + issues.push({ id, kind, severity: 'warning', message }) + } + + for (const entry of finalRectangles) { + const label = preflightLabel(entry.source) + if (entry.source.labelPlacement === 'outside-end') { + addIssue( + entry.source.id, + 'short-unreadable-segment', + `${label} is too short for inline text and uses an outside label or leader.`, + ) + } + if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) { + addIssue( + entry.source.id, + 'plan-geometry-conflict', + `${label} still conflicts with fixed plan geometry after automatic layout.`, + ) + } + if (!entry.shift.resolved) { + const collidesWithLabel = finalRectangles.some( + (candidate) => + candidate.source.id !== entry.source.id && + rectanglesOverlap(entry.bounds, candidate.bounds), + ) + if (collidesWithLabel) { + addIssue( + entry.source.id, + 'unresolved-collision', + `${label} still overlaps another annotation after automatic layout.`, + ) + } + } + } + return issues +} + +function preflightLabel(rectangle: AnnotationLabelRectangle): string { + const text = rectangle.text?.trim() + return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}` +} + +export function svgAnnotationLabelId(label: SVGGElement, index: number): string { + const explicit = label.dataset.floorplanAnnotationId?.trim() + if (explicit) return explicit + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + const text = label.textContent?.trim() ?? '' + return `annotation:${index}:${text}:${defaultTransform}` +} + +function resetDimensionConnectors(svg: SVGSVGElement): void { + for (const line of svg.querySelectorAll('[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..0bb25899 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, @@ -55,12 +66,16 @@ import { curveReshapeScope, endpointReshapeScope, holeEditScope, + isIdle, tangentReshapeScope, } from '../../../lib/interaction/scope' import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback' +import useDrawingView from '../../../store/use-drawing-view' import useEditor from '../../../store/use-editor' +import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility' +import useFloorplanPreflight from '../../../store/use-floorplan-preflight' import useInteractionScope, { useEndpointReshape, useMovingNode, @@ -74,6 +89,14 @@ import { startFloorplanGroupRotate, } from '../floorplan-group-move' import { useFloorplanRender } from '../floorplan-render-context' +import { + floorplanAnnotationObstacleMode, + isFloorplanAnnotationObstacleGeometry, + observeSvgAnnotationLayoutChanges, + resolveSvgAnnotationCollisions, + svgAnnotationLabelId, +} from './floorplan-annotation-layout' +import { FloorplanDimensionRenderer } from './floorplan-dimension-renderer' import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' import { resolveFloorplanLabelAngle } from './floorplan-label-angle' @@ -276,6 +299,8 @@ type NodeDeps = { node: AnyNode live: LiveTransform | undefined unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference selected: boolean highlighted: boolean hovered: boolean @@ -343,7 +368,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const selectedLevelId = useViewer((s) => s.selection.levelId) const selectedBuildingId = useViewer((s) => s.selection.buildingId) const unit = useViewer((s) => s.unit) - const showMeasurements = useViewer((s) => s.showMeasurements) + const metricNotation = useViewer((s) => s.metricNotation) const selectedIds = useViewer((s) => s.selection.selectedIds) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) @@ -423,6 +448,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // selectors freeze to `undefined` so drag publishes do not re-render the // hidden floor-plan tree. const floorplanVisible = useEditor((s) => s.viewMode !== '3d') + const drawingType = useDrawingView((s) => s.drawingType) + const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility) + const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference) // Elevator builders read runtime state imperatively, so entries include this // rare-changing ref in their cache deps. const interactiveElevators = useInteractive((s) => s.elevators) @@ -838,15 +866,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { if (!isNodeKindEnabled(node.type, installedPlugins)) return - const def = nodeRegistry.get(node.type) + const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) + if (!drawingNode) return + const def = nodeRegistry.get(drawingNode.type) if (!def?.floorplan) return - if (node.type === 'measurement' && !showMeasurements) return const dependsOnSiblingInputs = !!( def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides || def.floorplanAffectedIds ) - const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } + const descriptor: FloorplanEntryDescriptor = { id, node: drawingNode, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides out.push(descriptor) } @@ -863,6 +892,22 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { visit(levelId as AnyNodeId) + const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined + if (activeLevelNode) { + const collectedIds = new Set(out.map((entry) => entry.id)) + for (const linked of collectFloorplanLinkedLevelNodes( + nodes, + levelId as AnyNodeId, + collectedIds, + )) { + pushEntry(linked.id, linked.node, { + children: linked.children, + siblings: [], + parent: activeLevelNode, + }) + } + } + // Building-scoped kinds (`def.floorplanScope === 'building'`) live // as siblings of the level, not under it — the `visit(levelId)` DFS // above doesn't reach them. Walk every node of those kinds whose @@ -871,7 +916,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { // builders that gate on the current floor — e.g. elevator service // range — keep working). Pure registry-driven dispatch: no kind // name appears in this file. - const activeLevelNode = nodes[levelId as AnyNodeId] as AnyNode | undefined const activeBuildingId = activeLevelNode ? resolveBuildingForLevel(levelId as AnyNodeId, nodes) : null @@ -907,7 +951,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) } return { entries: out, levelNodeIdsByType } - }, [installedPlugins, levelId, nodes, showMeasurements]) + }, [drawingType, installedPlugins, levelId, nodes]) // ── Generic 2D affordance dispatch ───────────────────────────────── // @@ -1290,6 +1334,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ))} + {/* Dashed group bbox — shows what a group drag carries along while a multi-selection exists, rides the live delta mid-drag, and doubles as the group's whole-area drag handle. */} @@ -1403,9 +1454,198 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { ) }) +function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) { + const markerRef = useRef(null) + const [layoutEpoch, setLayoutEpoch] = useState(0) + const interactionIdle = useInteractionScope((state) => isIdle(state.scope)) + const annotationLayoutOverrides = useDrawingView((state) => state.annotationLayoutOverrides) + const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride) + const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues) + const resetPreflightIssues = useFloorplanPreflight((state) => state.reset) + const layoutEnabled = active && interactionIdle + useLayoutEffect(() => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + return observeSvgAnnotationLayoutChanges(registryLayer, () => { + setLayoutEpoch((epoch) => epoch + 1) + }) + }, [layoutEnabled]) + useLayoutEffect(() => { + // The epoch is only a trigger; collision inputs are measured from the live SVG below. + void layoutEpoch + if (!active) { + resetPreflightIssues() + return + } + if (!interactionIdle) return + const svg = markerRef.current?.ownerSVGElement + const registryLayer = markerRef.current?.parentElement + if (!(svg && registryLayer)) return + const preflightIssues = resolveSvgAnnotationCollisions(svg, { + layoutOverrides: annotationLayoutOverrides, + }) + setPreflightIssues(preflightIssues) + + const labels = Array.from( + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), + ) + for (const [index, label] of labels.entries()) { + const id = svgAnnotationLabelId(label, index) + label.dataset.floorplanAnnotationId = id + label.style.pointerEvents = 'all' + label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move' + } + }, [ + active, + annotationLayoutOverrides, + interactionIdle, + layoutEpoch, + resetPreflightIssues, + setPreflightIssues, + ]) + + useEffect(() => { + if (!layoutEnabled) return + const registryLayer = markerRef.current?.parentElement + if (!registryLayer) return + let cleanupPointerDrag: (() => void) | null = null + let cancelActivePointerDrag: (() => void) | null = null + + const findLabel = (target: EventTarget | null): SVGGElement | null => { + if (!(target instanceof Element)) return null + const label = target.closest('[data-floorplan-annotation-label]') + return label && registryLayer.contains(label) ? label : null + } + + const labelId = (label: SVGGElement): string => { + const labels = Array.from( + registryLayer.querySelectorAll('[data-floorplan-annotation-label]'), + ) + return svgAnnotationLabelId(label, Math.max(0, labels.indexOf(label))) + } + + const onPointerDown = (event: PointerEvent) => { + const label = findLabel(event.target) + if (!(label && event.button === 0)) return + const matrix = label.getScreenCTM() + if (!matrix) return + event.preventDefault() + event.stopPropagation() + cancelActivePointerDrag?.() + label.style.cursor = 'grabbing' + const id = labelId(label) + const start = { x: event.clientX, y: event.clientY } + const existing = useDrawingView.getState().annotationLayoutOverrides[id] ?? { + ...readFloorplanAnnotationLayoutOffset(label), + pinned: true, + } + const wasPinned = useDrawingView.getState().annotationLayoutOverrides[id]?.pinned === true + let latest = existing + let moved = false + + const onPointerMove = (moveEvent: PointerEvent) => { + if (moveEvent.pointerId !== event.pointerId) return + moved = true + const local = screenVectorToFloorplanAnnotationLocal( + matrix, + moveEvent.clientX - start.x, + moveEvent.clientY - start.y, + ) + latest = { + dx: existing.dx + local.x, + dy: existing.dy + local.y, + pinned: true, + } + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${latest.dx} ${latest.dy})`.trim(), + ) + } + + const finishPointerDrag = (endEvent: PointerEvent) => { + if (endEvent.pointerId !== event.pointerId) return + cleanupPointerDrag?.() + label.style.cursor = moved || wasPinned ? 'grab' : 'move' + if (moved) setAnnotationLayoutOverride(id, latest) + } + + const cancelPointerDrag = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== event.pointerId) return + cancelActivePointerDrag?.() + } + + cancelActivePointerDrag = () => { + cleanupPointerDrag?.() + label.style.cursor = wasPinned ? 'grab' : 'move' + const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform ?? '' + label.setAttribute( + 'transform', + `${defaultTransform} translate(${existing.dx} ${existing.dy})`.trim(), + ) + } + + cleanupPointerDrag = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', finishPointerDrag) + window.removeEventListener('pointercancel', cancelPointerDrag) + cleanupPointerDrag = null + cancelActivePointerDrag = null + } + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', finishPointerDrag) + window.addEventListener('pointercancel', cancelPointerDrag) + } + + const onDoubleClick = (event: MouseEvent) => { + const label = findLabel(event.target) + if (!label) return + event.preventDefault() + event.stopPropagation() + label.style.cursor = 'move' + setAnnotationLayoutOverride(labelId(label), null) + } + + registryLayer.addEventListener('pointerdown', onPointerDown) + registryLayer.addEventListener('dblclick', onDoubleClick) + return () => { + cancelActivePointerDrag?.() + registryLayer.removeEventListener('pointerdown', onPointerDown) + registryLayer.removeEventListener('dblclick', onDoubleClick) + for (const label of registryLayer.querySelectorAll( + '[data-floorplan-annotation-label]', + )) { + label.style.pointerEvents = '' + label.style.cursor = '' + } + } + }, [layoutEnabled, setAnnotationLayoutOverride]) + 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 +1693,8 @@ type FloorplanRegistryEntryProps = { setMovingNodeOrigin: ReturnType['setMovingNodeOrigin'] siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference unitsPerPixel: number visibilityRootId: AnyNodeId | undefined } @@ -1460,6 +1702,7 @@ type FloorplanRegistryEntryProps = { const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ activeDragId, activeRotateNodeId, + annotationVisibility, ctxOverrides, floorplanVisible, geometryCacheRef, @@ -1493,6 +1736,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ setMovingNodeOrigin, siblingEpoch, unit, + metricNotation, + wallDimensionReference, unitsPerPixel, visibilityRootId, }: FloorplanRegistryEntryProps): React.ReactElement | null { @@ -1599,16 +1844,21 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }) const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null + const visibleGeometry = rawGeometry + ? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility) + : null // Multi-selection shows highlight only: strip this member's edit handles / // dimension chrome (all of which live in the overlay pass) while keeping // its highlighted body geometry. const geometry = - rawGeometry && suppressHandles && pass === 'overlay' - ? stripHandleChrome(rawGeometry) - : rawGeometry + visibleGeometry && suppressHandles && pass === 'overlay' + ? stripHandleChrome(visibleGeometry) + : visibleGeometry if (!geometry) return null const entryClick = isOpeningPlacementActive || isMarqueeSelectionActive ? undefined : onClickStop @@ -1664,6 +1914,8 @@ type BuildFloorplanEntryGeometryArgs = { selected: boolean siblingEpoch: number unit: 'metric' | 'imperial' + metricNotation: 'meters' | 'millimeters' + wallDimensionReference: FloorplanWallDimensionReference visibilityRootId: AnyNodeId | undefined } @@ -1707,6 +1959,8 @@ function buildFloorplanEntryGeometry({ selected, siblingEpoch, unit, + metricNotation, + wallDimensionReference, visibilityRootId, }: BuildFloorplanEntryGeometryArgs): CacheEntry | null { const def = nodeRegistry.get(node.type) @@ -1731,6 +1985,8 @@ function buildFloorplanEntryGeometry({ node, live, unit, + metricNotation, + wallDimensionReference, selected, highlighted, hovered, @@ -1808,6 +2064,8 @@ function buildFloorplanEntryGeometry({ const viewState = { selected, unit, + metricNotation, + wallDimensionReference, highlighted, hovered, moving, @@ -1826,6 +2084,11 @@ function buildFloorplanEntryGeometry({ siblings: ctxOverrides.siblings, parent: ctxOverrides.parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation, + purpose: 'edit', + wallDimensionReference, + }), viewState: palette ? { selected, @@ -1925,7 +2188,7 @@ type InteractiveGeometryProps = { onMoveHandlePointerDown: (event: ReactPointerEvent) => void } -const InteractiveGeometry = memo(function InteractiveGeometry({ +export const InteractiveGeometry = memo(function InteractiveGeometry({ geometry, unitsPerPixel, palette, @@ -1948,7 +2211,14 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ case 'group': { const transform = formatGroupTransform(g.transform) return ( - + {g.children.map((child, i) => renderInteractive(child, i))} ) @@ -2499,11 +2769,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ const textWidth = g.text.length * labelUnitsPerPixel * 6.2 const plateW = textWidth + padX * 2 const plateH = fontSize + padY * 2 + const labelTransform = `translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})` return ( {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 +2887,11 @@ const InteractiveGeometry = memo(function InteractiveGeometry({ // horizontally on screen even when the floor-plan view is // rotated (default `sceneRotationDeg` is 90°). return ( - + ) } @@ -2853,6 +2998,9 @@ export function buildContext( viewState: { selected: boolean unit: 'metric' | 'imperial' + metricNotation?: 'meters' | 'millimeters' + purpose?: 'edit' | 'document' + wallDimensionReference?: FloorplanWallDimensionReference highlighted: boolean hovered: boolean moving: boolean @@ -2892,6 +3040,11 @@ export function buildContext( siblings, parent, levelData, + extensions: createFloorplanContextExtensions({ + metricNotation: viewState.metricNotation ?? 'meters', + purpose: viewState.purpose ?? 'edit', + wallDimensionReference: viewState.wallDimensionReference, + }), viewState: viewState.palette ? { selected: viewState.selected, @@ -2905,6 +3058,29 @@ export function buildContext( } } +export function collectFloorplanLinkedLevelNodes( + nodes: Record, + 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 +3127,7 @@ const OVERLAY_KINDS = new Set([ 'move-arrow', 'rotate-arrow', 'dimension', + 'dimension-string', 'dimension-label', 'equal-spacing-badge', ]) @@ -2969,6 +3146,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): { base: FloorplanGeometry | null overlay: FloorplanGeometry | null } { + if (isFloorplanAnnotationObstacleGeometry(g)) { + return { base: null, overlay: g } + } if (OVERLAY_KINDS.has(g.kind)) { return { base: null, overlay: g } } @@ -3130,6 +3310,8 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean { 'node', 'live', 'unit', + 'metricNotation', + 'wallDimensionReference', 'selected', 'highlighted', 'hovered', 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/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index d58c4cbe..e84302cf 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -29,7 +29,9 @@ export function BakeExporter({ await nextFrames() const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) throw new Error('scene-renderer group not found') - const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, { + textures: 'reference', + }) onComplete(buffer) } catch (err) { // The bake worker relays page console output into the job's error diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts new file mode 100644 index 00000000..da60a433 --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' + +describe('camera dragging lifecycle', () => { + test('releases wheel interactions even when camera controls never report rest', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + expect(dragging).toEqual([true]) + + const release = scheduled as (() => void) | null + release?.() + expect(dragging).toEqual([true, false]) + }) + + test('cancels a pending wheel release when another interaction begins', () => { + const dragging: boolean[] = [] + let scheduled: (() => void) | null = null + const lifecycle = createCameraDraggingLifecycle({ + setDragging: (value) => dragging.push(value), + schedule: (callback) => { + scheduled = callback + return 1 as unknown as ReturnType + }, + cancel: () => { + scheduled = null + }, + }) + + lifecycle.begin() + lifecycle.scheduleEnd() + lifecycle.begin() + + expect(scheduled).toBeNull() + expect(dragging).toEqual([true, true]) + }) +}) diff --git a/packages/editor/src/components/editor/camera-dragging-lifecycle.ts b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts new file mode 100644 index 00000000..1eb371fa --- /dev/null +++ b/packages/editor/src/components/editor/camera-dragging-lifecycle.ts @@ -0,0 +1,41 @@ +type TimerHandle = ReturnType + +export function createCameraDraggingLifecycle({ + setDragging, + fallbackMs = 500, + schedule = globalThis.setTimeout, + cancel = globalThis.clearTimeout, +}: { + setDragging: (dragging: boolean) => void + fallbackMs?: number + schedule?: (callback: () => void, delay: number) => TimerHandle + cancel?: (timer: TimerHandle) => void +}) { + let releaseTimer: TimerHandle | null = null + + const clearScheduledEnd = () => { + if (releaseTimer === null) return + cancel(releaseTimer) + releaseTimer = null + } + + const begin = () => { + clearScheduledEnd() + setDragging(true) + } + + const end = () => { + clearScheduledEnd() + setDragging(false) + } + + const scheduleEnd = () => { + clearScheduledEnd() + releaseTimer = schedule(() => { + releaseTimer = null + setDragging(false) + }, fallbackMs) + } + + return { begin, end, scheduleEnd } +} diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 1772e199..1b4ce7e3 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -35,6 +35,7 @@ import { useEndpointReshape, useMovingNode, } from '../../store/use-interaction-scope' +import { createCameraDraggingLifecycle } from './camera-dragging-lifecycle' const currentTarget = new Vector3() const tempBox = new Box3() @@ -166,6 +167,10 @@ function isKeyboardPanKey(code: string): boolean { return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD' } +function hasKeyboardPanInput(state: KeyboardPanState): boolean { + return state.forward || state.backward || state.left || state.right +} + type CameraViewportSize = { width: number height: number @@ -420,6 +425,14 @@ export const CustomCameraControls = () => { const gl = useThree((state) => state.gl) const raycaster = useThree((state) => state.raycaster) const viewportSize = useThree((state) => state.size) + const cameraDraggingLifecycle = useMemo( + () => + createCameraDraggingLifecycle({ + setDragging: (dragging) => useViewer.getState().setCameraDragging(dragging), + }), + [], + ) + useEffect(() => () => cameraDraggingLifecycle.end(), [cameraDraggingLifecycle]) useEffect(() => { camera.layers.enable(EDITOR_LAYER) camera.layers.enable(GRID_LAYER) @@ -444,10 +457,14 @@ export const CustomCameraControls = () => { } }, [freezeActivePoseInterpolation]) - const beginLocalCameraInteraction = useCallback(() => { - cancelPoseApplication() - emitter.emit('camera-controls:interaction-start', undefined) - }, [cancelPoseApplication]) + const beginLocalCameraInteraction = useCallback( + ({ dragging = true }: { dragging?: boolean } = {}) => { + cancelPoseApplication() + if (dragging) cameraDraggingLifecycle.begin() + emitter.emit('camera-controls:interaction-start', undefined) + }, + [cameraDraggingLifecycle, cancelPoseApplication], + ) const applyPendingPose = useCallback(() => { if (isFirstPersonMode) { @@ -1007,6 +1024,9 @@ export const CustomCameraControls = () => { if (isKeyboardPanKey(event.code)) { const changed = setKeyboardPanKey(keyboardPanKeys.current, event.code, false) if (changed) { + if (!hasKeyboardPanInput(keyboardPanKeys.current)) { + cameraDraggingLifecycle.end() + } event.preventDefault() event.stopPropagation() } @@ -1048,6 +1068,7 @@ export const CustomCameraControls = () => { const onWheel = () => { beginLocalCameraInteraction() + cameraDraggingLifecycle.scheduleEnd() clearPendingFloorplanNavigationPose() } @@ -1067,6 +1088,7 @@ export const CustomCameraControls = () => { panPointerId = null panPointerButton = null clearNavigationCursor() + cameraDraggingLifecycle.end() updateConfig() } @@ -1089,9 +1111,11 @@ export const CustomCameraControls = () => { gl.domElement.removeEventListener('wheel', onWheel, true) clearKeyboardPanKeys() clearNavigationCursor() + cameraDraggingLifecycle.end() } }, [ beginLocalCameraInteraction, + cameraDraggingLifecycle, cameraMode, gl, isPreviewMode, @@ -1102,10 +1126,18 @@ export const CustomCameraControls = () => { // Cancel any in-progress 2D-origin navigation pose when the user starts // dragging (right-click orbit, middle-click pan, touch). `controlstart` // fires only for user pointer interactions — not for programmatic - // moveTo/rotateTo which emit `transitionstart` instead. + // moveTo/rotateTo which emit `transitionstart` instead. It also fires for + // pointerdowns whose button is mapped to ACTION.NONE (plain left click in + // edit mode); those must not flag the camera as dragging — no rest/sleep + // ever follows to clear the flag, which would leave canvas clicks + // (selection, placement) suppressed until the next real camera move. const handleControlStart = useCallback(() => { clearPendingFloorplanNavigationPose() - beginLocalCameraInteraction() + beginLocalCameraInteraction({ + dragging: controls.current + ? controls.current.currentAction !== CameraControlsImpl.ACTION.NONE + : false, + }) }, [beginLocalCameraInteraction, clearPendingFloorplanNavigationPose]) // Preview mode: auto-navigate camera to selected node (viewer behavior) @@ -1407,12 +1439,22 @@ export const CustomCameraControls = () => { }, [clearPendingFloorplanNavigationPose, focusNode, isPreviewMode, isFirstPersonMode]) const onTransitionStart = useCallback(() => { - useViewer.getState().setCameraDragging(true) - }, []) + cameraDraggingLifecycle.begin() + }, [cameraDraggingLifecycle]) const onRest = useCallback(() => { - useViewer.getState().setCameraDragging(false) - }, []) + cameraDraggingLifecycle.end() + }, [cameraDraggingLifecycle]) + + const onControlEnd = useCallback(() => { + // A mapped-button tap with zero camera movement never wakes the + // controls, so no rest/sleep follows — clear the dragging flag on + // release. While damping is still settling (`active`), rest/sleep + // clears it instead. + if (!controls.current?.active) { + cameraDraggingLifecycle.end() + } + }, [cameraDraggingLifecycle]) // Preset capture mode frames a single subtree (often a 0.3–2m preset), // so the default 2m minDistance prevents the user from getting close @@ -1434,6 +1476,7 @@ export const CustomCameraControls = () => { minDistance={minDistance} minPolarAngle={0} mouseButtons={mouseButtons} + onControlEnd={onControlEnd} onControlStart={handleControlStart} onUpdate={handleCameraUpdate} onRest={onRest} diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 88820d37..9f4e43e8 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -15,8 +15,11 @@ import { getElevatorShaftDepth, getElevatorShaftWallThickness, getElevatorShaftWidth, + getLevelDisplayName, + getLevelElevations, getResolvedElevatorDoorStyle, openElevatorDoor, + pointInPolygon2D, requestElevatorLevel, resolveElevatorBuildingLevels, resolveElevatorDispatchTarget, @@ -25,7 +28,22 @@ import { useInteractive, useScene, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { + BVHEcctrl, + type BVHEcctrlApi, + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + type MovementInput, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + useViewer, + WALKTHROUGH_FOV, +} from '@pascal-app/viewer' import { KeyboardControls } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -38,6 +56,7 @@ import { Mesh, MeshBasicMaterial, type Object3D, + type PerspectiveCamera, Ray, Raycaster, Vector2, @@ -45,19 +64,22 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' -import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, + getDisplayedDoorValue, isOperationDoorType, toggleDoorOpenState, } from '../../lib/door-interaction' import { closeWindowOpenState, + getDisplayedWindowValue, isOperableWindowType, toggleWindowOpenState, } from '../../lib/window-interaction' import useEditor from '../../store/use-editor' +import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud' +import { WalkthroughHud } from '../walkthrough-hud' import { buildFirstPersonColliderWorldFromRegistry, deriveFirstPersonSpawn, @@ -76,8 +98,8 @@ const ELEVATOR_COLLIDER_HORIZONTAL_PADDING = 0.14 const ELEVATOR_COLLIDER_FLOOR_THICKNESS = 0.08 const ELEVATOR_COLLIDER_DOOR_DEPTH = 0.12 const ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD = 0.72 -const DEFAULT_ELEVATOR_LEVEL_HEIGHT = 2.5 const VOID_FALL_RESPAWN_DEPTH = 12 +const HUD_LABEL_SAMPLE_FRAMES = 10 type MovementKeyName = Exclude @@ -120,8 +142,10 @@ function focusFirstPersonCanvas(canvas: HTMLCanvasElement) { canvas.focus({ preventScroll: true }) } -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const standClearanceRaycaster = new Raycaster() +const standClearanceUp = new Vector3(0, 1, 0) const centerScreenPoint = new Vector2(0, 0) const doorInteractionRaycaster = new Raycaster() const doorLeafBox = new Box3() @@ -146,6 +170,9 @@ const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false }) const spawnWorldPosition = new Vector3() const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const windowInteractionRaycaster = new Raycaster() +const hudBuildingLocalEyePosition = new Vector3() +const hudWorldEyePosition = new Vector3() +const hudLevelBounds = new Box3() type ElevatorColliderKind = | 'cab-back' @@ -202,6 +229,128 @@ type ElevatorButtonTarget = { levelId?: AnyNodeId } +function getLevelChildren( + level: Extract, + nodes: Record, +) { + const childIds = new Set(level.children) + return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id)) +} + +function pointIsInLevelFootprint( + point: [number, number], + worldPoint: Vector3, + level: Extract, + nodes: Record, +) { + const children = getLevelChildren(level, nodes) + const slabs = children.filter( + (node): node is Extract => + node.type === 'slab' && node.polygon.length >= 3, + ) + const zones = children.filter( + (node): node is Extract => + node.type === 'zone' && node.polygon.length >= 3, + ) + + if (slabs.length > 0) { + return slabs.some( + (slab) => + pointInPolygon2D(point, slab.polygon) && + !slab.holes.some((hole) => pointInPolygon2D(point, hole)), + ) + } + + if (zones.length > 0) { + if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true + } + + const levelObject = sceneRegistry.nodes.get(level.id) + if (!levelObject) return false + hudLevelBounds.setFromObject(levelObject) + return ( + !hudLevelBounds.isEmpty() && + worldPoint.x >= hudLevelBounds.min.x && + worldPoint.x <= hudLevelBounds.max.x && + worldPoint.z >= hudLevelBounds.min.z && + worldPoint.z <= hudLevelBounds.max.z + ) +} + +function resolveFirstPersonHudLabels(worldPoint: Vector3) { + const nodes = useScene.getState().nodes + const levelElevations = getLevelElevations(nodes as Record) + + for (const building of Object.values(nodes)) { + if (building.type !== 'building') continue + const buildingObject = sceneRegistry.nodes.get(building.id) + if (!buildingObject) continue + + buildingObject.updateWorldMatrix(true, true) + hudBuildingLocalEyePosition.copy(worldPoint) + buildingObject.worldToLocal(hudBuildingLocalEyePosition) + + const levels = Object.values(nodes) + .filter((node) => node.type === 'level') + .filter((level) => levelElevations.get(level.id)?.buildingId === building.id) + .sort( + (left, right) => + (levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0), + ) + + let activeLevel: (typeof levels)[number] | null = null + for (const level of levels) { + const elevation = levelElevations.get(level.id) + if (!elevation) continue + if ( + hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 && + hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5 + ) { + activeLevel = level + } + } + if (!activeLevel) continue + + const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z] + if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue + + const zone = getLevelChildren(activeLevel, nodes).find( + (node) => + node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon), + ) + + return { + floorLabel: getLevelDisplayName(activeLevel), + zoneLabel: zone?.type === 'zone' ? zone.name : null, + } + } + + return { floorLabel: null, zoneLabel: null } +} + +function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract { + if (!target) return null + if (target.type === 'elevator') { + return { + label: target.action === 'open-door' ? 'door button' : 'elevator button', + verb: 'press', + } + } + + const node = useScene.getState().nodes[target.id] + if (target.type === 'window') { + if (node?.type !== 'window') return null + const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0 + return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' } + } + + if (node?.type !== 'door') return null + const isOpen = isOperationDoorType(node.doorType) + ? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0 + : getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0 + return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' } +} + function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null { let current: Object3D | null = object @@ -281,37 +430,17 @@ function isInsideElevatorCab( ) } -function getFirstPersonLevelHeight(levelId: string, nodes: Record) { - const level = nodes[levelId as AnyNodeId] - if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT - - let maxTop = 0 - for (const childId of level.children) { - const child = nodes[childId as AnyNodeId] - if (!child) continue - - if (child.type === 'ceiling') { - maxTop = Math.max(maxTop, child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT) - continue - } - - if (child.type === 'wall') { - const meshY = Math.max(sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0, 0) - maxTop = Math.max(maxTop, meshY + (child.height ?? DEFAULT_ELEVATOR_LEVEL_HEIGHT)) - } - } - - return maxTop > 0 ? maxTop : DEFAULT_ELEVATOR_LEVEL_HEIGHT -} - function resolveElevatorColliderLevels(elevator: ElevatorNode, nodes: Record) { const allLevels = resolveElevatorBuildingLevels(elevator, nodes) + const levelElevations = getLevelElevations(nodes as Record) const baseYByLevelId = new Map() let cumulativeY = 0 for (const level of allLevels) { - baseYByLevelId.set(level.id, cumulativeY) - cumulativeY += getFirstPersonLevelHeight(level.id, nodes) + const elevation = levelElevations.get(level.id) + const baseY = elevation?.baseY ?? 0 + baseYByLevelId.set(level.id, baseY) + cumulativeY = Math.max(cumulativeY, baseY + (elevation?.height ?? 0)) } const serviceLevels = resolveElevatorServiceLevels(elevator, nodes) @@ -573,6 +702,11 @@ export const FirstPersonControls = () => { const yawRef = useRef(0) const pitchRef = useRef(0) const interactableTargetRef = useRef(null) + const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1) + const crouchKeyRef = useRef(false) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) const ridingElevatorRef = useRef<{ elevatorId: AnyNodeId @@ -589,6 +723,35 @@ export const FirstPersonControls = () => { yaw: number } | null>(null) + useEffect(() => { + const previousCameraMode = useViewer.getState().cameraMode + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('perspective') + } + return () => { + if (previousCameraMode === 'orthographic') { + useViewer.getState().setCameraMode('orthographic') + } + } + }, []) + + useEffect(() => { + const perspectiveCamera = camera as PerspectiveCamera + if (!perspectiveCamera.isPerspectiveCamera) return + const previousFov = perspectiveCamera.fov + perspectiveCamera.fov = WALKTHROUGH_FOV + perspectiveCamera.updateProjectionMatrix() + return () => { + perspectiveCamera.fov = previousFov + perspectiveCamera.updateProjectionMatrix() + } + }, [camera]) + + useEffect(() => { + useFirstPersonHud.getState().reset() + return () => useFirstPersonHud.getState().reset() + }, []) + const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { worldRef.current?.dispose() worldRef.current = nextWorld @@ -999,9 +1162,15 @@ export const FirstPersonControls = () => { const isLocked = document.pointerLockElement === canvas if (isLocked) { hadPointerLockRef.current = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) return } + // Deliberately released (screenshot pause) — stay in first person; + // clicking the canvas re-locks. + if (suspendRef.current) return + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { useEditor.getState().setFirstPersonMode(false) } @@ -1018,6 +1187,7 @@ export const FirstPersonControls = () => { document.removeEventListener('click', handleClick) document.removeEventListener('mousedown', handleMouseDown, true) document.removeEventListener('pointerlockchange', handlePointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -1049,7 +1219,11 @@ export const FirstPersonControls = () => { return } - if (event.code === 'Escape') { + if (event.code === 'ControlLeft' || event.code === 'ControlRight') { + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard + // screenshot) must not toggle it under the user. + if (!suspendRef.current) crouchKeyRef.current = true + } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() if (document.pointerLockElement === canvas) { @@ -1064,18 +1238,41 @@ export const FirstPersonControls = () => { event.preventDefault() event.stopPropagation() closeInteractableTarget() + } else if (event.code === 'KeyP') { + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving first person — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + event.preventDefault() + event.stopPropagation() + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } } } const handleKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } applyMovementKey(event, false) } + const handleBlur = () => { + if (!suspendRef.current) crouchKeyRef.current = false + } + document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keyup', handleKeyUp, true) + window.addEventListener('blur', handleBlur) return () => { document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keyup', handleKeyUp, true) + window.removeEventListener('blur', handleBlur) } }, [closeInteractableTarget, gl, toggleInteractableTarget]) @@ -1301,11 +1498,33 @@ export const FirstPersonControls = () => { [camera, setElevatorRideLocked], ) - useFrame(() => { + const hasStandingClearance = useCallback((position: Vector3) => { + standClearanceRaycaster.set(position, standClearanceUp) + standClearanceRaycaster.far = STAND_CLEARANCE + const meshes: Mesh[] = [] + if (worldRef.current) meshes.push(worldRef.current.mesh) + for (const mesh of elevatorColliderMeshesRef.current) { + if (mesh.visible) meshes.push(mesh) + } + return standClearanceRaycaster.intersectObjects(meshes, false).length === 0 + }, []) + + useFrame((_, delta) => { if (!controllerRef.current?.group) return const group = controllerRef.current.group + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + cameraOffset.set(0, eyeOffsetRef.current, 0) + // The site ground collider is effectively unbounded, but scenes without a // site node only have finite fallback floors — if the controller still ends // up below every collider it can never land, so put it back at the spawn. @@ -1346,6 +1565,17 @@ export const FirstPersonControls = () => { interactableTargetRef.current = nextInteractableTarget useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null) } + + useFirstPersonHud.getState().setHud({ + interact: resolveHudInteract(nextInteractableTarget), + }) + + hudLabelFrameRef.current += 1 + if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) { + hudLabelFrameRef.current = 0 + camera.getWorldPosition(hudWorldEyePosition) + useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition)) + } }, 2.5) useEffect(() => { @@ -1372,7 +1602,7 @@ export const FirstPersonControls = () => { { fallGravityFactor={4} floatCheckType="BOTH" floatDampingC={36} - floatHeight={0.5} + floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT} floatPullBackHeight={0.35} floatSensorRadius={0.15} floatSpringK={1200} gravity={9.81} jumpVel={5} key="first-person-controller" - maxRunSpeed={5} + maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5} maxSlope={1.2} - maxWalkSpeed={2} + maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2} paused={isElevatorRideLocked} position={controllerStart.position} ref={setControllerApi} @@ -1403,27 +1633,14 @@ export const FirstPersonControls = () => { ) } -/** - * Overlay UI for first-person mode: crosshair, controls hint, exit button. - * Rendered as a regular DOM overlay (not inside the Canvas). - */ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { - const [isLocked, setIsLocked] = useState(false) const hasPlacedSpawn = useScene((state) => Object.values(state.nodes).some((node) => node.type === 'spawn'), ) - - useEffect(() => { - const handlePointerLockChange = () => { - setIsLocked(document.pointerLockElement != null) - } - - handlePointerLockChange() - document.addEventListener('pointerlockchange', handlePointerLockChange) - return () => { - document.removeEventListener('pointerlockchange', handlePointerLockChange) - } - }, []) + const floorLabel = useFirstPersonHud((state) => state.floorLabel) + const zoneLabel = useFirstPersonHud((state) => state.zoneLabel) + const interact = useFirstPersonHud((state) => state.interact) + const suspended = useViewer((state) => state.walkthroughSuspended) const handleExit = useCallback(() => { if (document.pointerLockElement) { @@ -1433,86 +1650,18 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { }, [onExit]) return ( - <> - {isLocked && ( -
-
-
-
-
-
- )} - -
- -
- + {!hasPlacedSpawn && ( -
-
- Place a Spawn Point from the Build tab to control where walkthrough starts. -
+
+ Place a spawn point from the Build tab to control where walkthrough starts.
)} - - {isLocked && ( -
-
- -
- - - - -
- - Click to look around - -
-
- )} - - ) -} - -function ControlHint({ label, keys }: { label: string; keys: string[] }) { - return ( -
- - {label} - -
- {keys.map((key) => ( - - {key} - - ))} -
-
- ) -} - -function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { - return ( -
- - {label} - - - {keyLabel} - -
+ ) } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index fa526574..5d2f9a62 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -6,7 +6,6 @@ import { type CeilingNode, ColumnNode, createSceneApi, - DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, emitter, @@ -15,6 +14,7 @@ import { getActiveRoofHeight, getEffectiveNode, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallThickness, ItemNode, isCurvedWall, @@ -271,7 +271,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } { if (node.type === 'wall') { return { - height: node.height ?? DEFAULT_WALL_HEIGHT, + height: getWallEffectiveHeightForNodes(node, useScene.getState().nodes), length: getWallCurveLength(node), thickness: getWallThickness(node), } @@ -413,7 +413,10 @@ export function FloatingActionMenu() { const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as | { height?: number } | undefined - const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT + const fallbackHeight = + node.type === 'wall' + ? getWallEffectiveHeightForNodes(node, useScene.getState().nodes) + : FENCE_DEFAULT_HEIGHT const liveHeight = override?.height ?? node.height ?? fallbackHeight pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}` } diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts new file mode 100644 index 00000000..b114d9ea --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' + +describe('floorplan navigation presentation', () => { + test('keeps the imperative viewBox authoritative during navigation', () => { + const reactViewBox = { minX: 0, minY: 0, width: 100, height: 50 } + const imperativeViewBox = { minX: 25, minY: 10, width: 40, height: 20 } + + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, true)).toBe( + imperativeViewBox, + ) + expect(resolveFloorplanPresentationViewBox(reactViewBox, imperativeViewBox, false)).toBe( + reactViewBox, + ) + }) + + test('does not mix wheel zoom with a compositor rotation preview', () => { + expect(canZoomFloorplanDuringNavigation(true)).toBe(false) + expect(canZoomFloorplanDuringNavigation(false)).toBe(true) + }) + + test('does not apply synchronized camera poses over local navigation', () => { + expect(canApplyFloorplanNavigationSync(true)).toBe(false) + expect(canApplyFloorplanNavigationSync(false)).toBe(true) + }) + + test('commits every active navigation channel before teardown', () => { + const calls: string[] = [] + const rotationState = { angle: 42 } + + finalizeFloorplanNavigation({ + zoomPending: true, + panActive: true, + rotationState, + commitZoom: () => calls.push('zoom'), + commitPan: () => calls.push('pan'), + commitRotation: (state) => calls.push(`rotation:${state.angle}`), + }) + + expect(calls).toEqual(['zoom', 'pan', 'rotation:42']) + }) +}) diff --git a/packages/editor/src/components/editor/floorplan-navigation-presentation.ts b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts new file mode 100644 index 00000000..30295809 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-navigation-presentation.ts @@ -0,0 +1,42 @@ +export type FloorplanPresentationViewBox = { + minX: number + minY: number + width: number + height: number +} + +export function resolveFloorplanPresentationViewBox( + reactViewBox: FloorplanPresentationViewBox, + imperativeViewBox: FloorplanPresentationViewBox | null, + interactionInProgress: boolean, +): FloorplanPresentationViewBox { + return interactionInProgress && imperativeViewBox ? imperativeViewBox : reactViewBox +} + +export function canZoomFloorplanDuringNavigation(rotationInProgress: boolean): boolean { + return !rotationInProgress +} + +export function canApplyFloorplanNavigationSync(interactionInProgress: boolean): boolean { + return !interactionInProgress +} + +export function finalizeFloorplanNavigation({ + zoomPending, + panActive, + rotationState, + commitZoom, + commitPan, + commitRotation, +}: { + zoomPending: boolean + panActive: boolean + rotationState: RotationState | null + commitZoom: () => void + commitPan: () => void + commitRotation: (rotationState: RotationState) => void +}): void { + if (zoomPending) commitZoom() + if (panActive) commitPan() + if (rotationState) commitRotation(rotationState) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 9baf8848..fae830cc 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 { @@ -190,6 +191,13 @@ import { import { PALETTE_COLORS } from '../ui/primitives/color-dot' import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { + canApplyFloorplanNavigationSync, + canZoomFloorplanDuringNavigation, + type FloorplanPresentationViewBox, + finalizeFloorplanNavigation, + resolveFloorplanPresentationViewBox, +} from './floorplan-navigation-presentation' import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' import { useFloorplanHitTesting } from './use-floorplan-hit-testing' import { useFloorplanSceneData } from './use-floorplan-scene-data' @@ -319,6 +327,20 @@ type FloorplanRotationState = { startClientX: number initialUserRotationDeg: number viewportCenterLocal: SvgPoint + svg: SVGSVGElement + svgStyle: { + transform: string + transformOrigin: string + willChange: string + } + latestUserRotationDeg: number + latestViewport: FloorplanViewport +} + +function restoreFloorplanRotationPresentation(rotationState: FloorplanRotationState) { + rotationState.svg.style.transform = rotationState.svgStyle.transform + rotationState.svg.style.transformOrigin = rotationState.svgStyle.transformOrigin + rotationState.svg.style.willChange = rotationState.svgStyle.willChange } type FloorplanScreenSelectionState = { @@ -2416,6 +2438,7 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, frontSide: 'unknown', @@ -2719,9 +2742,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 +3638,7 @@ function FloorplanReferenceScaleDraftLine({ unitsPerPixel: number }) { const cursor = useFloorplanDraftPreview((s) => s.cursorPoint) + const metricNotation = useViewer((state) => state.metricNotation) if (!cursor) { return null } @@ -3625,6 +3650,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 +4033,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 +4082,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 +5057,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 +5176,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,10 +5184,84 @@ function FloorplanLinearDraftLayer({ direction: [dx / length, dy / length] as WallPlanPoint, angleLabels, } - }, [isWallBuildActive, unit, wallDraftEnd, wallDraftStart, walls]) + }, [isWallBuildActive, metricNotation, unit, wallDraftEnd, wallDraftStart, walls]) + + // Axis guides for wall and fence drafts — parity with the 3D tools' + // `DraftAxisGuides`: an X/Z cross through the draft start, and a single + // long line PERPENDICULAR to the segment through the moving endpoint (a + // second cross would collide with the start cross on axis-aligned + // segments). Long solid lines in world space — cheap for the SVG renderer, + // unlike dashed strokes which tessellate per dash over 2000 m. Stroke + // width is budgeted in screen pixels via `unitsPerPixel`, matching the + // alignment-guide layer. + const draftAxisGuideLines = useMemo(() => { + const lines: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + const pushCross = (point: WallPlanPoint) => { + lines.push( + { + x1: point[0] - DRAFT_AXIS_GUIDE_EXTENT, + y1: point[1], + x2: point[0] + DRAFT_AXIS_GUIDE_EXTENT, + y2: point[1], + }, + { + x1: point[0], + y1: point[1] - DRAFT_AXIS_GUIDE_EXTENT, + x2: point[0], + y2: point[1] + DRAFT_AXIS_GUIDE_EXTENT, + }, + ) + } + const pushDraft = (start: WallPlanPoint, end: WallPlanPoint) => { + pushCross(start) + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) return + const nx = -dy / length + const ny = dx / length + lines.push({ + x1: end[0] - nx * DRAFT_AXIS_GUIDE_EXTENT, + y1: end[1] - ny * DRAFT_AXIS_GUIDE_EXTENT, + x2: end[0] + nx * DRAFT_AXIS_GUIDE_EXTENT, + y2: end[1] + ny * DRAFT_AXIS_GUIDE_EXTENT, + }) + } + if (isWallBuildActive && wallDraftStart && wallDraftEnd) { + pushDraft(wallDraftStart, wallDraftEnd) + } + if (isFenceBuildActive && fenceDraftStart && fenceDraftEnd) { + pushDraft(fenceDraftStart, fenceDraftEnd) + } + return lines.length > 0 ? lines : null + }, [ + isFenceBuildActive, + isWallBuildActive, + fenceDraftEnd, + fenceDraftStart, + wallDraftEnd, + wallDraftStart, + ]) return ( <> + {draftAxisGuideLines && ( + + {draftAxisGuideLines.map((line, index) => ( + + ))} + + )} + = [] +/** World-space half-length of the 2D draft axis guide lines (matches the 3D tools' 2000 m guides). */ +const DRAFT_AXIS_GUIDE_EXTENT = 1000 export function FloorplanPanel({ /** @@ -5203,6 +5308,7 @@ export function FloorplanPanel({ }) { const viewportHostRef = useRef(null) const svgRef = useRef(null) + const floorplanBackgroundRef = useRef(null) const floorplanSceneRef = useRef(null) const floorplanContentRef = useRef(null) const panStateRef = useRef(null) @@ -5230,6 +5336,21 @@ export function FloorplanPanel({ const latestFittedViewportRef = useRef(null) const floorplanViewAnimationFrameRef = useRef(null) const floorplanViewAnimationTargetRef = useRef(null) + const floorplanZoomCommitTimerRef = useRef(null) + const floorplanRenderScaleCommitTimerRef = useRef(null) + const floorplanViewportInteractionInProgressRef = useRef(false) + const floorplanImperativeViewBoxRef = useRef(null) + const latestFloorplanRenderUnitsPerPixelRef = useRef(1) + const floorplanZoomPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) + const floorplanPanPoseRef = useRef<{ + localCenter: SvgPoint + userRotationDeg: number + viewWidth: number + } | null>(null) const latestNavigationSyncPoseRef = useRef( useEditor.getState().navigationSyncPose, ) @@ -5244,6 +5365,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) @@ -5336,7 +5458,7 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + floorplanUserRotationDeg - buildingRotationDeg // Only sync ref from state when floorplan is open (state is source of truth). // When hidden, the imperative 3D path owns the ref and must not be clobbered. - if (isFloorplanOpenRef.current) { + if (isFloorplanOpenRef.current && !floorplanViewportInteractionInProgressRef.current) { latestFloorplanUserRotationDegRef.current = floorplanUserRotationDeg } @@ -5545,9 +5667,14 @@ export function FloorplanPanel({ const [isPanelReady, setIsPanelReady] = useState(false) const [surfaceSize, setSurfaceSize] = useState({ width: 1, height: 1 }) const [viewport, setViewport] = useState(null) - latestViewportRef.current = viewport + const [floorplanRenderUnitsPerPixel, setFloorplanRenderUnitsPerPixel] = useState( + null, + ) + if (!floorplanViewportInteractionInProgressRef.current) { + latestViewportRef.current = viewport + } // Tight bbox of the painted floor-plan scene (the rotation ``'s - // children), read via SVG `getBBox()` after each render. The legacy + // children), read via SVG `getBBox()` after content changes settle. The legacy // polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now // empty stubs because rendering moved to the registry layer, so // measuring the DOM is how `fittedViewport` learns where content lives. @@ -6525,42 +6652,71 @@ export function FloorplanPanel({ ]) latestFittedViewportRef.current = fittedViewport - // Measure the painted floor-plan scene after each render. `getBBox()` - // gives us the tight bounds of whatever the registry layer emitted, - // even for kinds whose legacy entry arrays are empty stubs. Bail out - // when nothing has painted (empty group throws in some browsers). - // We measure the content-only sub-group (not the full scene group) to - // exclude the grid layer, whose extent tracks the viewBox and would - // otherwise create a measure→fit→measure update loop. + // Measure the content-only subtree after its geometry settles. ViewBox-only + // navigation does not change these bounds and must not force `getBBox()` on + // every animation frame. + // biome-ignore lint/correctness/useExhaustiveDependencies: visibility remounts the observed SVG subtree. useLayoutEffect(() => { const el = floorplanContentRef.current if (!el) return - let bbox: { x: number; y: number; width: number; height: number } - try { - const measured = el.getBBox() - bbox = { - x: measured.x, - y: measured.y, - width: measured.width, - height: measured.height, + let scheduledFrame: number | null = null + let mutationVersion = 0 + let observedVersion = 0 + const measure = () => { + let bbox: { x: number; y: number; width: number; height: number } + try { + const measured = el.getBBox() + bbox = { + x: measured.x, + y: measured.y, + width: measured.width, + height: measured.height, + } + } catch { + return } - } catch { - return + if (bbox.width <= 0 && bbox.height <= 0) return + setMeasuredSceneBBox((prev) => { + if ( + prev && + prev.x === bbox.x && + prev.y === bbox.y && + prev.width === bbox.width && + prev.height === bbox.height + ) { + return prev + } + return bbox + }) } - if (bbox.width <= 0 && bbox.height <= 0) return - setMeasuredSceneBBox((prev) => { - if ( - prev && - prev.x === bbox.x && - prev.y === bbox.y && - prev.width === bbox.width && - prev.height === bbox.height - ) { - return prev + const flushWhenSettled = () => { + if (observedVersion !== mutationVersion) { + observedVersion = mutationVersion + scheduledFrame = requestAnimationFrame(flushWhenSettled) + return } - return bbox + scheduledFrame = null + measure() + } + const observer = new MutationObserver(() => { + mutationVersion += 1 + if (scheduledFrame !== null) return + observedVersion = mutationVersion - 1 + scheduledFrame = requestAnimationFrame(flushWhenSettled) }) - }) + + measure() + observer.observe(el, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }) + return () => { + observer.disconnect() + if (scheduledFrame !== null) cancelAnimationFrame(scheduledFrame) + } + }, [isFloorplanOpen]) const applyFloorplanNavigationState = useCallback( (nextViewport: FloorplanViewport, userRotationDeg: number) => { @@ -6713,7 +6869,7 @@ export function FloorplanPanel({ if (!isFloorplanOpenRef.current) { return } - if (floorplanRotationStateRef.current) { + if (!canApplyFloorplanNavigationSync(floorplanViewportInteractionInProgressRef.current)) { return } @@ -6892,35 +7048,6 @@ export function FloorplanPanel({ } }, []) - // Reset to auto-fit each time the 2D editor re-opens. The panel stays - // mounted across close/open (hidden via `display: none`), so without - // this the user's last pan/zoom — and any stale `measuredSceneBBox` - // captured before they closed it — would survive and the reopened - // editor would show the same off-screen viewport instead of fitting - // to the current scene. - useEffect(() => { - if (!isFloorplanOpen) { - stopFloorplanViewAnimation() - floorplanSpacePanPressedRef.current = false - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsSpacePanPressed(false) - setIsPanning(false) - setIsRotatingFloorplan(false) - return - } - setMeasuredSceneBBox(null) - - if (!latestNavigationSyncPoseRef.current) { - stopFloorplanViewAnimation() - hasUserAdjustedViewportRef.current = false - latestFloorplanUserRotationDegRef.current = 0 - latestViewportRef.current = null - setFloorplanUserRotationDeg(0) - setViewport(null) - } - }, [isFloorplanOpen, stopFloorplanViewAnimation]) - useEffect(() => { const levelChanged = previousLevelIdRef.current !== (levelId ?? null) @@ -6979,19 +7106,31 @@ export function FloorplanPanel({ height, } }, [fittedViewport, svgAspectRatio, viewport]) + const presentationViewBox = resolveFloorplanPresentationViewBox( + viewBox, + floorplanImperativeViewBoxRef.current, + floorplanViewportInteractionInProgressRef.current, + ) const floorplanWorldUnitsPerPixel = useMemo(() => { const widthUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) const heightUnitsPerPixel = viewBox.height / Math.max(surfaceSize.height, 1) return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 }, [surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width]) - const floorplanWallHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getLiveFloorplanWorldUnitsPerPixel = useCallback(() => { + const width = latestViewportRef.current?.width ?? viewBox.width + const height = width / svgAspectRatio + const widthUnitsPerPixel = width / Math.max(surfaceSize.width, 1) + const heightUnitsPerPixel = height / Math.max(surfaceSize.height, 1) + return (widthUnitsPerPixel + heightUnitsPerPixel) / 2 + }, [surfaceSize.height, surfaceSize.width, svgAspectRatio, viewBox.width]) + const getFloorplanWallHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_WALL_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) - const floorplanOpeningHitTolerance = useMemo( - () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), - [floorplanWorldUnitsPerPixel], + const getFloorplanOpeningHitTolerance = useCallback( + () => getLiveFloorplanWorldUnitsPerPixel() * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), + [getLiveFloorplanWorldUnitsPerPixel], ) const wallSelectionHatchSpacing = useMemo( () => Math.max(floorplanWorldUnitsPerPixel * 12, 0.0001), @@ -7276,7 +7415,9 @@ export function FloorplanPanel({ ), [gridBounds, gridSteps.majorStep], ) - const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const liveFloorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1) + const floorplanUnitsPerPixel = floorplanRenderUnitsPerPixel ?? liveFloorplanUnitsPerPixel + latestFloorplanRenderUnitsPerPixelRef.current = floorplanUnitsPerPixel useEffect(() => { setReferenceScaleUnit(unit === 'imperial' ? 'feet' : 'meters') @@ -7804,8 +7945,104 @@ export function FloorplanPanel({ [beginPanelInteraction, panelRect], ) + const applyFloorplanViewportImperatively = useCallback( + (nextViewport: FloorplanViewport) => { + const nextHeight = nextViewport.width / svgAspectRatio + const nextMinX = nextViewport.centerX - nextViewport.width / 2 + const nextMinY = nextViewport.centerY - nextHeight / 2 + floorplanImperativeViewBoxRef.current = { + minX: nextMinX, + minY: nextMinY, + width: nextViewport.width, + height: nextHeight, + } + hasUserAdjustedViewportRef.current = true + latestViewportRef.current = nextViewport + svgRef.current?.setAttribute( + 'viewBox', + `${nextMinX} ${nextMinY} ${nextViewport.width} ${nextHeight}`, + ) + const background = floorplanBackgroundRef.current + if (background) { + background.setAttribute('x', String(nextMinX)) + background.setAttribute('y', String(nextMinY)) + background.setAttribute('width', String(nextViewport.width)) + background.setAttribute('height', String(nextHeight)) + } + }, + [svgAspectRatio], + ) + + const applyFloorplanRotationImperatively = useCallback( + (rotationState: FloorplanRotationState, nextUserRotationDeg: number) => { + const currentViewport = latestViewportRef.current ?? rotationState.latestViewport + const nextSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + nextUserRotationDeg - buildingRotationDeg + const nextCenterSvg = rotateSvgPoint(rotationState.viewportCenterLocal, nextSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + + hasUserAdjustedViewportRef.current = true + latestFloorplanUserRotationDegRef.current = nextUserRotationDeg + latestViewportRef.current = nextViewport + // Transform the already-painted SVG as one compositor layer. Mutating the + // scene rotation/viewBox here forces the heavy vector plan to rerasterize. + rotationState.svg.style.transform = `rotate(${nextUserRotationDeg - rotationState.initialUserRotationDeg}deg)` + + rotationState.latestUserRotationDeg = nextUserRotationDeg + rotationState.latestViewport = nextViewport + }, + [buildingRotationDeg], + ) + + const commitFloorplanZoom = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + floorplanZoomCommitTimerRef.current = null + } + const nextViewport = latestViewportRef.current + const pendingPose = floorplanZoomPoseRef.current + floorplanZoomPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (!nextViewport) return + setFloorplanRenderUnitsPerPixel( + (current) => current ?? latestFloorplanRenderUnitsPerPixelRef.current, + ) + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanRenderScaleCommitTimerRef.current = window.setTimeout(() => { + floorplanRenderScaleCommitTimerRef.current = null + setFloorplanRenderUnitsPerPixel(null) + }, 350) + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const scheduleFloorplanZoomCommit = useCallback(() => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + floorplanZoomCommitTimerRef.current = window.setTimeout(commitFloorplanZoom, 300) + }, [commitFloorplanZoom]) + const zoomViewportAtClientPoint = useCallback( (clientX: number, clientY: number, widthFactor: number) => { + if (!canZoomFloorplanDuringNavigation(floorplanRotationStateRef.current !== null)) { + return + } if (!Number.isFinite(widthFactor) || widthFactor <= 0) { return } @@ -7821,12 +8058,21 @@ export function FloorplanPanel({ } const svgPoint = rotateSvgPoint(localPoint, floorplanSceneRotationDeg) - const currentViewport = viewport ?? fittedViewport - const currentViewBox = viewBox + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) { + return + } + const currentViewBox = { + minX: currentViewport.centerX - currentViewport.width / 2, + minY: currentViewport.centerY - currentViewport.width / svgAspectRatio / 2, + width: currentViewport.width, + height: currentViewport.width / svgAspectRatio, + } + const fitted = latestFittedViewportRef.current const nextWidth = resolveFloorplanViewWidth( currentViewport.width * widthFactor, currentViewport.width, - fittedViewport, + fitted, true, ) const nextHeight = nextWidth / svgAspectRatio @@ -7840,30 +8086,140 @@ export function FloorplanPanel({ y: nextMinY + nextHeight / 2, } const localCenter = rotateSvgPoint(nextCenterSvg, -floorplanSceneRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: nextWidth, + } - smoothFloorplanNavigationView( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) - publishFloorplanNavigationPose( - localCenter, - latestFloorplanUserRotationDegRef.current, - nextWidth, - ) + stopFloorplanViewAnimation() + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + floorplanRenderScaleCommitTimerRef.current = null + } + floorplanViewportInteractionInProgressRef.current = true + applyFloorplanViewportImperatively(nextViewport) + scheduleFloorplanZoomCommit() + const userRotationDeg = latestFloorplanUserRotationDegRef.current + floorplanZoomPoseRef.current = { localCenter, userRotationDeg, viewWidth: nextWidth } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, userRotationDeg, nextWidth) + } }, [ - fittedViewport, + applyFloorplanViewportImperatively, floorplanSceneRotationDeg, getSvgPointFromClientPoint, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, + scheduleFloorplanZoomCommit, + stopFloorplanViewAnimation, svgAspectRatio, - viewBox, - viewport, ], ) + useEffect( + () => () => { + if (floorplanZoomCommitTimerRef.current !== null) { + window.clearTimeout(floorplanZoomCommitTimerRef.current) + } + if (floorplanRenderScaleCommitTimerRef.current !== null) { + window.clearTimeout(floorplanRenderScaleCommitTimerRef.current) + } + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + }, + [], + ) + + const commitFloorplanPan = useCallback(() => { + const nextViewport = latestViewportRef.current + const pendingPose = floorplanPanPoseRef.current + floorplanPanPoseRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + if (nextViewport) { + setViewport((current) => + floorplanViewportEquals(current, nextViewport) ? current : nextViewport, + ) + } + if (pendingPose) { + publishFloorplanNavigationPose( + pendingPose.localCenter, + pendingPose.userRotationDeg, + pendingPose.viewWidth, + ) + } + }, [publishFloorplanNavigationPose]) + + const commitFloorplanRotation = useCallback( + (rotationState: FloorplanRotationState) => { + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + restoreFloorplanRotationPresentation(rotationState) + setFloorplanUserRotationDeg((current) => + current === rotationState.latestUserRotationDeg + ? current + : rotationState.latestUserRotationDeg, + ) + setViewport((current) => + floorplanViewportEquals(current, rotationState.latestViewport) + ? current + : rotationState.latestViewport, + ) + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + rotationState.latestUserRotationDeg, + rotationState.latestViewport.width, + ) + }, + [publishFloorplanNavigationPose], + ) + + // Finalize imperative navigation when the floorplan closes so reopening + // restores the last visible pose instead of stale React state. + useEffect(() => { + if (isFloorplanOpen) return + stopFloorplanViewAnimation() + const rotationState = floorplanRotationStateRef.current + finalizeFloorplanNavigation({ + zoomPending: + floorplanZoomCommitTimerRef.current !== null || floorplanZoomPoseRef.current !== null, + panActive: panStateRef.current !== null, + rotationState, + commitZoom: commitFloorplanZoom, + commitPan: commitFloorplanPan, + commitRotation: commitFloorplanRotation, + }) + floorplanSpacePanPressedRef.current = false + panStateRef.current = null + floorplanRotationStateRef.current = null + floorplanViewportInteractionInProgressRef.current = false + floorplanImperativeViewBoxRef.current = null + setIsSpacePanPressed(false) + setIsPanning(false) + setIsRotatingFloorplan(false) + }, [ + commitFloorplanPan, + commitFloorplanRotation, + commitFloorplanZoom, + isFloorplanOpen, + stopFloorplanViewAnimation, + ]) + + useEffect(() => { + if (!isFloorplanOpen) return + setMeasuredSceneBBox(null) + + if (!latestNavigationSyncPoseRef.current) { + stopFloorplanViewAnimation() + hasUserAdjustedViewportRef.current = false + latestFloorplanUserRotationDegRef.current = 0 + latestViewportRef.current = null + setFloorplanUserRotationDeg(0) + setViewport(null) + } + }, [isFloorplanOpen, stopFloorplanViewAnimation]) + const clearWallPlacementDraft = useCallback(() => { setDraftStart(null) setWallChainFirstVertex(null) @@ -8821,8 +9177,12 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() floorplanNavigationClickSuppressedRef.current = true - const currentViewport = viewport ?? fittedViewport + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + floorplanViewportInteractionInProgressRef.current = true panStateRef.current = { pointerId: event.pointerId, clientX: event.clientX, @@ -8847,17 +9207,35 @@ export function FloorplanPanel({ event.preventDefault() event.stopPropagation() - const currentViewport = viewport ?? fittedViewport + if (floorplanZoomCommitTimerRef.current !== null) commitFloorplanZoom() + stopFloorplanViewAnimation() + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + const svg = svgRef.current + if (!(currentViewport && svg)) return + const currentUserRotationDeg = latestFloorplanUserRotationDegRef.current + const currentSceneRotationDeg = + FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const viewportCenterLocal = rotateSvgPoint( { x: currentViewport.centerX, y: currentViewport.centerY }, - -floorplanSceneRotationDeg, + -currentSceneRotationDeg, ) - + const svgStyle = { + transform: svg.style.transform, + transformOrigin: svg.style.transformOrigin, + willChange: svg.style.willChange, + } + floorplanViewportInteractionInProgressRef.current = true + svg.style.transformOrigin = 'center' + svg.style.willChange = 'transform' floorplanRotationStateRef.current = { pointerId: event.pointerId, startClientX: event.clientX, - initialUserRotationDeg: floorplanUserRotationDeg, + initialUserRotationDeg: currentUserRotationDeg, viewportCenterLocal, + svg, + svgStyle, + latestUserRotationDeg: currentUserRotationDeg, + latestViewport: currentViewport, } setIsRotatingFloorplan(true) setCursorPoint(null) @@ -8866,12 +9244,11 @@ export function FloorplanPanel({ event.currentTarget.setPointerCapture(event.pointerId) }, [ - fittedViewport, - floorplanSceneRotationDeg, - floorplanUserRotationDeg, - viewport, + commitFloorplanZoom, + buildingRotationDeg, setFloorplanCursorPosition, setCursorPoint, + stopFloorplanViewAnimation, ], ) @@ -8911,24 +9288,31 @@ export function FloorplanPanel({ [isScreenSelectionToolActive, setPreviewSelectedIds], ) - const endFloorplanNavigation = useCallback((event?: ReactPointerEvent) => { - if ( - event && - (panStateRef.current || floorplanRotationStateRef.current) && - event.currentTarget.hasPointerCapture(event.pointerId) - ) { - event.currentTarget.releasePointerCapture(event.pointerId) - } + const endFloorplanNavigation = useCallback( + (event?: ReactPointerEvent) => { + const wasPanning = panStateRef.current !== null + const rotationState = floorplanRotationStateRef.current + if ( + event && + (panStateRef.current || floorplanRotationStateRef.current) && + event.currentTarget.hasPointerCapture(event.pointerId) + ) { + event.currentTarget.releasePointerCapture(event.pointerId) + } - panStateRef.current = null - floorplanRotationStateRef.current = null - setIsPanning(false) - setIsRotatingFloorplan(false) + panStateRef.current = null + floorplanRotationStateRef.current = null + if (wasPanning) commitFloorplanPan() + if (rotationState) commitFloorplanRotation(rotationState) + setIsPanning(false) + setIsRotatingFloorplan(false) - window.setTimeout(() => { - floorplanNavigationClickSuppressedRef.current = false - }, 0) - }, []) + window.setTimeout(() => { + floorplanNavigationClickSuppressedRef.current = false + }, 0) + }, + [commitFloorplanPan, commitFloorplanRotation], + ) const hoveredWallIdRef = useRef(null) const hoveredCeilingIdRef = useRef(null) @@ -9128,9 +9512,14 @@ export function FloorplanPanel({ (rotationState.startClientX - event.clientX) * FLOORPLAN_ROTATION_DEGREES_PER_PIXEL const nextUserRotationDeg = rotationState.initialUserRotationDeg + angleDeltaDeg - smoothFloorplanNavigationView(rotationState.viewportCenterLocal, nextUserRotationDeg) - publishFloorplanNavigationPose(rotationState.viewportCenterLocal, nextUserRotationDeg) - setCursorPoint(null) + applyFloorplanRotationImperatively(rotationState, nextUserRotationDeg) + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose( + rotationState.viewportCenterLocal, + nextUserRotationDeg, + rotationState.latestViewport.width, + ) + } return } @@ -9140,8 +9529,11 @@ export function FloorplanPanel({ const deltaX = event.clientX - panStateRef.current.clientX const deltaY = event.clientY - panStateRef.current.clientY - const worldPerPixelX = viewBox.width / surfaceSize.width - const worldPerPixelY = viewBox.height / surfaceSize.height + const currentViewport = latestViewportRef.current ?? latestFittedViewportRef.current + if (!currentViewport) return + const currentHeight = currentViewport.width / svgAspectRatio + const worldPerPixelX = currentViewport.width / surfaceSize.width + const worldPerPixelY = currentHeight / surfaceSize.height const nextCenterSvg = { x: panStateRef.current.centerSvg.x - deltaX * worldPerPixelX, @@ -9152,8 +9544,20 @@ export function FloorplanPanel({ FLOORPLAN_VIEW_ROTATION_DEG + currentUserRotationDeg - buildingRotationDeg const localCenter = rotateSvgPoint(nextCenterSvg, -currentSceneRotationDeg) - smoothFloorplanNavigationView(localCenter, currentUserRotationDeg) - publishFloorplanNavigationPose(localCenter, currentUserRotationDeg) + const nextViewport = { + centerX: nextCenterSvg.x, + centerY: nextCenterSvg.y, + width: currentViewport.width, + } + applyFloorplanViewportImperatively(nextViewport) + floorplanPanPoseRef.current = { + localCenter, + userRotationDeg: currentUserRotationDeg, + viewWidth: currentViewport.width, + } + if (useEditor.getState().viewMode === 'split') { + publishFloorplanNavigationPose(localCenter, currentUserRotationDeg, currentViewport.width) + } panStateRef.current = { pointerId: event.pointerId, @@ -9493,6 +9897,8 @@ export function FloorplanPanel({ }, [ buildingRotationDeg, + applyFloorplanViewportImperatively, + applyFloorplanRotationImperatively, draftStart, ceilingDraftPoints, emitFloorplanWallLeave, @@ -9521,15 +9927,13 @@ export function FloorplanPanel({ isWallBuildActive, levelId, publishFloorplanNavigationPose, - smoothFloorplanNavigationView, referenceScaleDraft, roofDraftStart, elevatorResizeDragState, siteVertexDragState, surfaceSize.height, surfaceSize.width, - viewBox.height, - viewBox.width, + svgAspectRatio, walls, setCursorPoint, setDraftEnd, @@ -9794,10 +10198,10 @@ export function FloorplanPanel({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -10945,6 +11349,7 @@ export function FloorplanPanel({ const handleGestureEnd = (event: Event) => { gestureScaleRef.current = 1 + commitFloorplanZoom() event.preventDefault() event.stopPropagation() } @@ -10964,7 +11369,7 @@ export function FloorplanPanel({ svg.removeEventListener('gesturechange', handleGestureChange) svg.removeEventListener('gestureend', handleGestureEnd) } - }, [zoomViewportAtClientPoint]) + }, [commitFloorplanZoom, zoomViewportAtClientPoint]) const restoreGroundLevelStructureSelection = useCallback(() => { const sceneNodes = useScene.getState().nodes @@ -11082,7 +11487,7 @@ export function FloorplanPanel({ ref={containerRef} > -
+
- {formatMeasurement(pendingReferenceScale.measuredLengthUnits, unit)} + {formatMeasurement( + pendingReferenceScale.measuredLengthUnits, + unit, + null, + metricNotation, + )}
@@ -11270,7 +11680,7 @@ export function FloorplanPanel({ cursor: floorplanNavigationCursor ?? (referenceScaleDraft ? 'crosshair' : EDITOR_CURSOR), }} - viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`} + viewBox={`${presentationViewBox.minX} ${presentationViewBox.minY} ${presentationViewBox.width} ${presentationViewBox.height}`} > { event.preventDefault() event.stopPropagation() @@ -11387,9 +11798,9 @@ export function FloorplanPanel({ onPointerMove={handleMarqueePointerMove} onPointerUp={handleMarqueePointerUp} style={{ cursor: EDITOR_CURSOR }} - width={viewBox.width} - x={viewBox.minX} - y={viewBox.minY} + width={presentationViewBox.width} + x={presentationViewBox.minX} + y={presentationViewBox.minY} /> )} @@ -11432,6 +11843,7 @@ export function FloorplanPanel({ + {floorplanSceneSlot} {/* Cursor-driven placement ghost for movingNode when the @@ -11601,12 +12013,12 @@ export function FloorplanPanel({ {isFloorplanNavigationOverlayVisible && ( )} diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 531f9f82..78c0f25d 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -153,6 +153,12 @@ export interface EditorProps { * only while a node is selected. */ inspectorFooter?: ReactNode + /** + * Docked below the multi-selection panel (v2). Hosts mount whole-selection + * affordances here (e.g. "Save to my catalog"); shows only while more than + * one node is selected. + */ + multiSelectionFooter?: ReactNode /** Host-owned content mounted inside the editor's React Three Fiber scene. */ viewerSceneSlot?: ReactNode @@ -1108,6 +1114,7 @@ export default function Editor({ viewerToolbarRight, stageOverlay, inspectorFooter, + multiSelectionFooter, viewerSceneSlot, floorplanSceneSlot, projectId, @@ -1412,7 +1419,10 @@ export default function Editor({ )} {!(isVersionPreviewMode || isCaptureMode || isStudioMode) && (
- +
)} {!isCaptureMode && ( 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)}
))} diff --git a/packages/editor/src/components/editor/quick-measurement-hud.tsx b/packages/editor/src/components/editor/quick-measurement-hud.tsx index dbd96fbb..df49068d 100644 --- a/packages/editor/src/components/editor/quick-measurement-hud.tsx +++ b/packages/editor/src/components/editor/quick-measurement-hud.tsx @@ -12,6 +12,7 @@ export function QuickMeasurementHud() { const viewMode = useEditor((state) => state.viewMode) const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode)) const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) if (!entry) return null @@ -21,7 +22,12 @@ export function QuickMeasurementHud() { data-quick-measure-hud >
- +
) diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts index 6894e7f4..4e71512f 100644 --- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts +++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts @@ -89,10 +89,10 @@ type UseFloorplanHitTestingArgs = { displayWallPolygons: WallPolygonEntry[] floorplanElevatorEntries: ElevatorPolygonEntry[] floorplanItemEntries: FloorplanItemEntry[] - floorplanOpeningHitTolerance: number + getFloorplanOpeningHitTolerance: () => number floorplanRoofEntries: FloorplanRoofEntry[] floorplanStairEntries: FloorplanStairEntry[] - floorplanWallHitTolerance: number + getFloorplanWallHitTolerance: () => number getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null isFloorplanItemContextActive: boolean openingsPolygons: OpeningPolygonEntry[] @@ -107,10 +107,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanElevatorEntries, floorplanItemEntries, - floorplanOpeningHitTolerance, + getFloorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, @@ -132,8 +132,8 @@ export function useFloorplanHitTesting({ elevators: floorplanElevatorEntries, walls: displayWallPolygons, slabs: displaySlabPolygons, - openingHitTolerance: floorplanOpeningHitTolerance, - wallHitTolerance: floorplanWallHitTolerance, + openingHitTolerance: getFloorplanOpeningHitTolerance(), + wallHitTolerance: getFloorplanWallHitTolerance(), columns: columnPolygons, getOpeningCenterLine, }) @@ -145,10 +145,10 @@ export function useFloorplanHitTesting({ displayWallPolygons, floorplanItemEntries, floorplanElevatorEntries, - floorplanOpeningHitTolerance, floorplanRoofEntries, floorplanStairEntries, - floorplanWallHitTolerance, + getFloorplanOpeningHitTolerance, + getFloorplanWallHitTolerance, getOpeningCenterLine, isFloorplanItemContextActive, openingsPolygons, diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx index 285b9629..92851a09 100644 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -1,10 +1,11 @@ 'use client' import { + type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, getWallCurveLength, + getWallEffectiveHeightForNodes, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, @@ -91,10 +92,7 @@ export function WallMeasurementLabel() { return createPortal(, selectedObject) } -function getLevelWalls( - wall: WallNode, - nodes: Record, -): WallNode[] { +function getLevelWalls(wall: WallNode, nodes: Record): WallNode[] { if (!wall.parentId) return [wall] const levelNode = nodes[wall.parentId as AnyNodeId] @@ -308,7 +306,7 @@ function getCurvedWallMeasurementPath( function buildMeasurementGuide( wall: WallNode, - nodes: Record, + nodes: Record, ): MeasurementGuide | null { const levelWalls = getLevelWalls(wall, nodes) const miterData = calculateLevelMiters(levelWalls) @@ -317,7 +315,7 @@ function buildMeasurementGuide( const measurementPoints = measurementLine ?? fallbackMiddlePoints if (!measurementPoints) return null - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = getWallEffectiveHeightForNodes(wall, nodes) const startLocal = worldPointToWallLocal(wall, measurementPoints.start) const endLocal = worldPointToWallLocal(wall, measurementPoints.end) const curvedMeasurementPath = isCurvedWall(wall) @@ -516,14 +514,7 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { const color = isNight ? '#ffffff' : '#111111' const shadowColor = isNight ? '#111111' : '#ffffff' - const guide = useMemo( - () => - buildMeasurementGuide( - wall, - nodes as Record, - ), - [nodes, wall], - ) + const guide = useMemo(() => buildMeasurementGuide(wall, nodes), [nodes, wall]) const length = useMemo(() => { if (!guide?.guidePath?.length || guide.guidePath.length < 2) { return getWallCurveLength(wall) @@ -538,7 +529,8 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) { return total }, [guide, wall]) const label = formatLinearMeasurement(length, unit) - const heightLabel = `H ${formatLinearMeasurement(wall.height ?? DEFAULT_WALL_HEIGHT, unit)}` + const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall]) + const heightLabel = `H ${formatLinearMeasurement(height, unit)}` if (!(guide && Number.isFinite(length) && length >= 0.01)) return null diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 735f1763..3ae9b8a1 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -2,11 +2,12 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, type FenceNode, getWallCurveFrameAt, + getWallEffectiveHeightForNodes, getWallThickness, isCurvedWall, + MIN_WALL_HEIGHT, sceneRegistry, useLiveNodeOverrides, useScene, @@ -55,7 +56,6 @@ const HANDLE_MIN_OFFSET = 0.33 const HANDLE_MIN_HEIGHT = 0.4 const HANDLE_TOP_INSET = 0.08 const HEIGHT_HANDLE_OFFSET = 0.26 -const MIN_WALL_HEIGHT = 0.5 const ARROW_COLOR = '#8381ed' const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds @@ -244,7 +244,7 @@ function WallCornerLeaderHandle({ wall, endpoint }: { wall: WallNode; endpoint: const corner = endpoint === 'start' ? wall.start : wall.end const x = corner[0] const z = corner[1] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(wallHeight), [wallHeight]) const hitGeometry = useMemo(() => createEndpointHitAreaGeometry(CORNER_HEX_RADIUS), []) @@ -433,7 +433,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const wallAngle = Math.atan2(-dirZ, dirX) // `wall` is the override-merged effective wall (see // WallMoveSideHandlesForWall), so this height is already live during a drag. - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleY = wallHeight + HEIGHT_HANDLE_OFFSET const activateHeightResize = (event: ThreeEvent) => { @@ -466,7 +466,9 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const hit = new Vector3() if (!raycaster.ray.intersectPlane(plane, hit)) return - const initialHeight = wall.height ?? DEFAULT_WALL_HEIGHT + // Dragging the top makes the wall custom-height; seed from the resolved + // effective height so a plane-bound wall's drag starts at its real top. + const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight @@ -774,7 +776,7 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] { const midpoint: [number, number] = frame ? [frame.point.x, frame.point.y] : [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2] - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) const handleHeight = Math.max(wallHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT) const offset = Math.max(getWallThickness(wall) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET) diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index e3fa0855..7ef0ee9b 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -3,12 +3,13 @@ import { type AnyNode, type AnyNodeId, - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, + getWallPlaneTop, getWallThickness, isCurvedWall, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useScene, @@ -147,15 +148,16 @@ type WallTopHighlightSegment = { function getWallTopY(wall: WallNode, nodes: Readonly>) { const levelId = resolveLevelId(wall, nodes as Record) - const slabElevation = spatialGridManager.getSlabElevationForWall( + const support = spatialGridManager.getSlabSupportForWall( levelId, wall.start, wall.end, wall.curveOffset ?? 0, wall.thickness, + wall.supportSlabId, ) - const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT - return (slabElevation > 0 ? slabElevation + wallHeight : wallHeight) + WALL_TOP_HIGHLIGHT_LIFT + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallTop(wall, planeTop, support.elevation) + WALL_TOP_HIGHLIGHT_LIFT } function buildHighlightSegment(start: [number, number], end: [number, number]) { diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 71e787ab..e60c88e9 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, emitter, + resolveCeilingHeight, resolveLevelId, sceneRegistry, snapPointToGrid, @@ -151,6 +152,9 @@ const CeilingSelectionAffordance = ({ () => (liveOverride ? ({ ...ceiling, ...liveOverride } as CeilingNode) : ceiling), [ceiling, liveOverride], ) + // Explicit height when stored, else the live level-top bound the ceiling + // follows (primitive selector — re-render-safe). + const resolvedHeight = useScene((s) => resolveCeilingHeight(effectiveCeiling, s.nodes)) const [levelObject, setLevelObject] = useState( () => sceneRegistry.nodes.get(levelId) ?? null, ) @@ -221,7 +225,7 @@ const CeilingSelectionAffordance = ({ ) raycasterRef.current.setFromCamera(ndcRef.current, camera) - planePointRef.current.set(0, (effectiveCeiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0) + planePointRef.current.set(0, resolvedHeight + BRACKET_Y_OFFSET, 0) levelObject.localToWorld(planePointRef.current) planeOriginRef.current.set(0, 0, 0) @@ -238,7 +242,7 @@ const CeilingSelectionAffordance = ({ levelObject.worldToLocal(localIntersectionRef.current) return [localIntersectionRef.current.x, localIntersectionRef.current.z] }, - [camera, effectiveCeiling.height, gl.domElement, levelObject], + [camera, resolvedHeight, gl.domElement, levelObject], ) const handleCornerPointerDown = useCallback( @@ -438,10 +442,7 @@ const CeilingSelectionAffordance = ({ if (!levelObject || corners.length === 0) return null return createPortal( - + {corners.map((corner, index) => ( e.stopPropagation(), viaHandle: true, }) diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 0d76ef27..fec5ab6a 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -5,6 +5,7 @@ import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall, + resolveFenceSupportSlabPatch, snapPointAlongAngleRay, useScene, type WallNode, @@ -187,9 +188,22 @@ export function snapFenceDraftPoint(args: { return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint } +export type FenceCommitOptions = { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so a fence drawn on a + * deck top persists the deck as its lift host while one drawn at the + * floor underneath stays grounded. Omitted by 2D floor-plan commits (no + * camera ray): those keep the uncapped max election. + */ + supportCap?: number | null +} + export function createFenceOnCurrentLevel( start: FencePlanPoint, end: FencePlanPoint, + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -209,6 +223,13 @@ export function createFenceOnCurrentLevel( start, end, }) + // Fences run no per-frame support election — the persisted host IS the + // lift (absent = level floor), so elect it at commit, pointer-capped. + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') @@ -225,6 +246,7 @@ export function createFenceOnCurrentLevel( export function createSplineFenceOnCurrentLevel( path: FencePlanPoint[], tangents = getTwoPointFenceCurveTangents(path), + options?: FenceCommitOptions, ): FenceNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() @@ -250,6 +272,11 @@ export function createSplineFenceOnCurrentLevel( path, tangents, }) + fence.supportSlabId = resolveFenceSupportSlabPatch( + { ...fence, parentId: currentLevelId }, + nodes, + { maxElevation: options?.supportCap ?? null }, + ).supportSlabId createNode(fence, currentLevelId) sfxEmitter.emit('sfx:structure-build') diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index f402148c..297d6ebf 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -2,6 +2,7 @@ import { type AnyNodeId, type AssetInput, ItemNode, + resolveSupportSlabPatch, sceneRegistry, useScene, } from '@pascal-app/core' @@ -39,8 +40,14 @@ export interface DraftNodeHandle { ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void - /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. */ - commit: (finalUpdate: Partial) => string | null + /** Commit the current draft. Create mode: delete+recreate. Move mode: update in place. + * `supportElevationCap` (floor commits) is the pointer-decided surface + * elevation — it caps the persisted `supportSlabId` election so the + * commit lands on the surface the cursor pointed at. */ + commit: ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ) => string | null /** Destroy the current draft. Create mode: delete node. Move mode: restore original state. */ destroy: () => void } @@ -124,100 +131,128 @@ export function useDraftNode(): DraftNodeHandle { ) }, []) - const commit = useCallback((finalUpdate: Partial): string | null => { - const draft = draftRef.current - if (!draft) return null + const commit = useCallback( + ( + finalUpdate: Partial, + options?: { supportElevationCap?: number | null }, + ): string | null => { + const draft = draftRef.current + if (!draft) return null - if (adoptedRef.current) { - // Move mode: update in place (single undoable action) + if (adoptedRef.current) { + // Move mode: update in place (single undoable action) + const { parentId: newParentId, ...updateProps } = finalUpdate + const parentId = + newParentId ?? + originalStateRef.current?.parentId ?? + useViewer.getState().selection.levelId + const original = originalStateRef.current! + + // Restore original state while paused — so the undo baseline is clean + useScene.getState().updateNode(draft.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + roofSegmentId: original.roofSegmentId, + roofFace: original.roofFace, + metadata: original.metadata, + }) + + // Resume → tracked update (undo reverts to original) + useScene.temporal.getState().resume() + + const effectiveNode = ItemNode.parse({ + ...draft, + ...updateProps, + parentId, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + }) + + useScene.getState().updateNode(draft.id, { + position: updateProps.position ?? draft.position, + rotation: updateProps.rotation ?? draft.rotation, + side: updateProps.side ?? draft.side, + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId: parentId as string, + // Forward the roof host explicitly: strategies set it on every + // commit (segment id on a roof face, undefined elsewhere), and + // dropping it here strands the item in the roof frame without + // the segment transform. + roofSegmentId: updateProps.roofSegmentId, + roofFace: updateProps.roofFace, + // Only when the strategy decided about wallId (roof commits clear + // it) — floor/ceiling commits never managed the field. + ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + ...resolveSupportSlabPatch(effectiveNode, useScene.getState().nodes, { + maxElevation: options?.supportElevationCap, + }), + }) + + useScene.temporal.getState().pause() + + const id = draft.id + if (usePlacementPreview.getState().node?.id === id) { + usePlacementPreview.getState().clear() + } + draftRef.current = null + adoptedRef.current = false + originalStateRef.current = null + return id + } + + // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = - newParentId ?? originalStateRef.current?.parentId ?? useViewer.getState().selection.levelId - const original = originalStateRef.current! + const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId + if (!parentId) return null - // Restore original state while paused — so the undo baseline is clean - useScene.getState().updateNode(draft.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - metadata: original.metadata, - }) + // Delete draft while paused (invisible to undo) + useScene.getState().deleteNode(draft.id) + draftRef.current = null - // Resume → tracked update (undo reverts to original) + // Briefly resume → create fresh node (the single undoable action) useScene.temporal.getState().resume() - useScene.getState().updateNode(draft.id, { + const finalNode = ItemNode.parse({ + name: draft.name, + asset: draft.asset, position: updateProps.position ?? draft.position, rotation: updateProps.rotation ?? draft.rotation, + scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - parentId: parentId as string, - // Forward the roof host explicitly: strategies set it on every - // commit (segment id on a roof face, undefined elsewhere), and - // dropping it here strands the item in the roof frame without - // the segment transform. + // Carry painted slot overrides so a duplicated item keeps its materials. + ...(draft.slots ? { slots: draft.slots } : {}), + // Roof host — see the move-mode commit above for why this must be + // forwarded explicitly. roofSegmentId: updateProps.roofSegmentId, roofFace: updateProps.roofFace, - // Only when the strategy decided about wallId (roof commits clear - // it) — floor/ceiling commits never managed the field. ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), + metadata: updateProps.metadata ?? stripTransient(draft.metadata), + parentId, }) - - useScene.temporal.getState().pause() - - const id = draft.id - if (usePlacementPreview.getState().node?.id === id) { + const nodes = useScene.getState().nodes + const committedNode = ItemNode.parse({ + ...finalNode, + ...resolveSupportSlabPatch( + finalNode, + { ...nodes, [finalNode.id]: finalNode }, + { maxElevation: options?.supportElevationCap }, + ), + }) + useScene.getState().createNode(committedNode, parentId) + if (usePlacementPreview.getState().node?.id === draft.id) { usePlacementPreview.getState().clear() } - draftRef.current = null + + // Re-pause for next draft cycle + useScene.temporal.getState().pause() + adoptedRef.current = false originalStateRef.current = null - return id - } - - // Create mode: delete draft (paused), resume, create fresh node (tracked), re-pause - const { parentId: newParentId, ...updateProps } = finalUpdate - const parentId = (newParentId ?? useViewer.getState().selection.levelId) as AnyNodeId - if (!parentId) return null - - // Delete draft while paused (invisible to undo) - useScene.getState().deleteNode(draft.id) - draftRef.current = null - - // Briefly resume → create fresh node (the single undoable action) - useScene.temporal.getState().resume() - - const finalNode = ItemNode.parse({ - name: draft.name, - asset: draft.asset, - position: updateProps.position ?? draft.position, - rotation: updateProps.rotation ?? draft.rotation, - scale: updateProps.scale ?? draft.scale, - side: updateProps.side ?? draft.side, - // Carry painted slot overrides so a duplicated item keeps its materials. - ...(draft.slots ? { slots: draft.slots } : {}), - // Roof host — see the move-mode commit above for why this must be - // forwarded explicitly. - roofSegmentId: updateProps.roofSegmentId, - roofFace: updateProps.roofFace, - ...('wallId' in updateProps ? { wallId: updateProps.wallId } : {}), - metadata: updateProps.metadata ?? stripTransient(draft.metadata), - }) - useScene.getState().createNode(finalNode, parentId) - if (usePlacementPreview.getState().node?.id === draft.id) { - usePlacementPreview.getState().clear() - } - - // Re-pause for next draft cycle - useScene.temporal.getState().pause() - - adoptedRef.current = false - originalStateRef.current = null - return finalNode.id - }, []) + return committedNode.id + }, + [], + ) const destroy = useCallback(() => { if (!draftRef.current) return diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 8e542340..26624958 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -62,6 +62,10 @@ import { type PreviewBounds, updateLineGeometry, } from '../shared/placement-box-geometry' +import { + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from '../shared/pointer-support-cap' import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, @@ -269,6 +273,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const raycastDisabledMeshRef = useRef(null) const restoreRaycastsRef = useRef void>>([]) const raycastDisabledChildrenRef = useRef(new WeakSet()) + // Level-local elevation of the surface the pointer ray actually points + // at (deck top, floor slab, or ground), refreshed on every grid move. + // Threaded into the floor-support election as its cap so the POINTER + // decides the target surface — without it the election lifts the ghost + // by the MAX overlapping slab and a deck above the aimed-at floor + // captures the item (and the grid-plane feedback makes it blink). + const pointerSupportCapRef = useRef(null) const [dimensionBounds, setDimensionBounds] = useState(null) // Live camera ref — the shelf-stickiness test reconstructs the cursor world @@ -420,6 +431,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea node: previewNode, position, rotation: previewNode.rotation, + maxElevation: pointerSupportCapRef.current, }) }, [asset?.attachTo, draftNode], @@ -449,6 +461,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, } + // No pointer surface known yet — fall back to the uncapped election + // (an adopted move draft keeps its persisted host until the first move). + pointerSupportCapRef.current = null if (!asset.attachTo && placementState.current.surface === 'floor') { gridPosition.current.y = 0 if (cursorGroupRef.current) { @@ -848,6 +863,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea has3DPointerDrivenMoveRef.current = true + // The pointer decides the target surface AND the floor point: cap + // the floor-support election at the elevation of the surface the + // camera ray actually hits, and re-aim the event at the ray's + // crossing of that surface's plane. The grid event's own hit can't + // be used directly — its plane rides at the ghost's last height, so + // its Y is a feedback loop (the under-deck blink) and its XZ is + // perspective-skewed along the ray whenever the plane sits on a + // different storey than the pointed surface (the skew is what made + // a drag over a deck-above-a-floor hop between the two surfaces). + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + pointerSupportCapRef.current = pointed?.elevation ?? null + const surfaceEvent: GridEvent = + pointed?.worldPoint && pointed.localPoint + ? { ...event, position: pointed.worldPoint, localPosition: pointed.localPoint } + : event + // Shelf stickiness: while hosting on a shelf, ignore floor events while // the cursor ray still points at the shelf volume (the ray merely slipped // off a board / through a gap and hit the floor behind). Detach to the @@ -855,10 +886,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // item oscillates between the shelf row and the floor on every micro-move. if (placementState.current.surface === 'shelf-surface') { if (cursorRayIntersectsActiveShelf(event.position)) return - detachItemSurfaceToFloor(event as unknown as ItemEvent) + // Land at the pointed surface's plan point — the raw grid hit is + // still skewed by the plane riding at the shelf-surface height. + detachItemSurfaceToFloor(surfaceEvent as unknown as ItemEvent) } - const floorEvent = applyFloorGrabOffset(event) + const floorEvent = applyFloorGrabOffset(surfaceEvent) lastRawPos.current.set( floorEvent.localPosition[0], @@ -941,11 +974,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (draft) draft.position = gridPos - // Publish live transform for 2D floorplan + // Publish live transform for 2D floorplan (and the pointer surface + // cap, so FloorElevationSystem's per-frame Y agrees with the ghost). if (draft) { useLiveTransforms.getState().set(draft.id, { position: gridPos, rotation: cursorGroupRef.current.rotation.y, + supportElevationCap: pointerSupportCapRef.current ?? undefined, }) } @@ -973,7 +1008,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const committedId = draftNode.current?.id ?? null const wasAdopted = draftNode.isAdopted - const finalId = draftNode.commit(result.nodeUpdate) + // Carry the pointer surface cap into the commit so the persisted + // supportSlabId reproduces the capped election (elects the aimed-at + // lower slab — or the ground — instead of a deck hanging above). + const finalId = draftNode.commit(result.nodeUpdate, { + supportElevationCap: pointerSupportCapRef.current, + }) finishCommittedPlacement(finalId ?? committedId, wasAdopted, () => { draftNode.create( gridPosition.current, @@ -1392,6 +1432,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const detachItemSurfaceToFloor = (event: ItemEvent) => { hostSurfaceDragAnchor = null + // Landing back on the floor: refresh the pointer surface cap from + // this event's world hit so the first floor position already targets + // the aimed-at surface (not a deck above it). + pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [ + event.position[0], + event.position[1], + event.position[2], + ]) // Coming back from a host: forget the floor grab too, so the item // centers under the cursor instead of restoring the pre-drag offset — // and landing on the floor is "anchoring elsewhere", so a later return diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 0d0360f9..ccd37b35 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -23,6 +23,7 @@ import { resolveAlignment, resolveConnectivityUpdates, resolveFacingIndicator, + resolveSupportSlabPatch, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -30,6 +31,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' +import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' @@ -54,6 +56,7 @@ import { DragBoundingBox } from '../shared/drag-bounding-box' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' import { useFreshPlacementVisibility } from '../shared/fresh-placement-visibility' import { PlacementBox } from '../shared/placement-box' +import { resolvePointerSupportSurface } from '../shared/pointer-support-cap' /** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25 * / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */ @@ -231,6 +234,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + // Live camera ref — the pointer-surface cap reconstructs the cursor world + // ray (camera → grid hit) to find which walking surface is aimed at. + const camera = useThree((s) => s.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Level-local elevation of the surface the pointer ray points at, + // refreshed per grid move. Caps the floor-support election so a deck + // hanging above the aimed-at floor never lifts the dragged node. + const supportCapRef = useRef(null) // Kinds whose `position` lives in a host parent's local frame declare // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the // plan-frame cursor through the capability's hooks and previews via @@ -356,6 +368,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] : rotationY })(), + maxElevation: supportCapRef.current, }) }, [parentFrame, frameParent, node], @@ -404,6 +417,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { rotationRef.current = originalRotationY altRef.current = false validRef.current = true + // No pointer surface known yet — uncapped election (the node keeps its + // persisted host / committed elevation until the first grid move). + supportCapRef.current = null // Re-sync the box transform to the (possibly new) node. `node` changes // without this component remounting whenever a positioned preset re-arms a // fresh clone after a drop, or the user picks a different catalog tile — @@ -591,8 +607,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } const onGridMove = (event: GridEvent) => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] + // The pointer decides the target surface AND the cursor plan point, + // both resolved from the true camera ray in one place. The event's + // own hit can't be used directly: its plane rides at the ghost's + // last height, so whenever that plane sits on a different storey + // than the aimed-at surface the hit XZ is perspective-skewed along + // the ray — electing at that skewed point is what made a drag over + // a deck-above-a-floor hop between the two surfaces (each hop moved + // the plane, which re-skewed the next hit, which flipped the + // election back). Cap and XZ from the same ray ∩ pointed-surface + // test are plane-height independent, so the elected surface is a + // single fixed point per pointer ray. + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + supportCapRef.current = pointed?.elevation ?? null + const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] + const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] revealFreshPlacement() const resolved = resolvePlanarCursorPosition({ @@ -721,9 +750,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // the absolute world plan position here. Polygon-based kinds // (slab / ceiling / fence) follow a different delta contract — // their floor-plan move-targets handle the override themselves. + // The pointer surface cap rides along so FloorElevationSystem's + // per-frame Y agrees with this tool's preview. useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() @@ -781,9 +813,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { + const effectiveNode = { + ...(node as Record), + position, + rotation, + } as AnyNode const data = { position, rotation, + // The pointer cap makes the persisted host reproduce the capped + // election — a drop under a deck stores the aimed-at lower slab + // (or the ground), not the deck hanging above. + ...resolveSupportSlabPatch( + effectiveNode, + { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }, + { maxElevation: supportCapRef.current }, + ), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -819,6 +867,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] if (liveNode && liveParent) { parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + const committedNodes = useScene.getState().nodes + const committedParent = committedNodes[frameParent.id as AnyNodeId] + if (committedParent) { + useScene + .getState() + .updateNode( + committedParent.id, + resolveSupportSlabPatch(committedParent, committedNodes), + ) + } } } useScene.temporal.getState().pause() @@ -834,9 +892,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { metadata: {}, position, rotation, - }) + }) as AnyNode + const committedNode = def.schema.parse({ + ...reparsed, + parentId: node.parentId, + ...resolveSupportSlabPatch( + { ...reparsed, parentId: node.parentId } as AnyNode, + { + ...useScene.getState().nodes, + [reparsed.id]: reparsed, + }, + { maxElevation: supportCapRef.current }, + ), + }) as AnyNode useScene.temporal.getState().resume() - useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId) + useScene.getState().createNode(committedNode, node.parentId as AnyNodeId) useScene.temporal.getState().pause() committed = true } @@ -901,6 +971,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, + supportElevationCap: supportCapRef.current ?? undefined, }) syncParentFramePreview(position) markMovedNodeDirty() diff --git a/packages/editor/src/components/tools/shared/floor-stack-preview.ts b/packages/editor/src/components/tools/shared/floor-stack-preview.ts index f29b608b..08ecfb6d 100644 --- a/packages/editor/src/components/tools/shared/floor-stack-preview.ts +++ b/packages/editor/src/components/tools/shared/floor-stack-preview.ts @@ -6,6 +6,8 @@ type FloorStackPreviewArgs = { rotation?: unknown levelId?: string | null nodes?: Record + /** Pointer-decided support cap — see `FloorPlacedElevationArgs.maxElevation`. */ + maxElevation?: number | null } export function getFloorStackPreviewPosition({ @@ -14,6 +16,7 @@ export function getFloorStackPreviewPosition({ rotation, levelId, nodes, + maxElevation, }: FloorStackPreviewArgs): [number, number, number] { return getFloorStackedPosition({ node, @@ -21,5 +24,6 @@ export function getFloorStackPreviewPosition({ position, rotation, levelId, + maxElevation, }) } diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts new file mode 100644 index 00000000..b0bc8398 --- /dev/null +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -0,0 +1,99 @@ +import { type AnyNodeId, sceneRegistry, spatialGridManager } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type Camera, Vector3 } from 'three' + +const originScratch = new Vector3() +const hitScratch = new Vector3() +const worldScratch = new Vector3() +const pointScratch = new Vector3() + +export type PointerSupportSurface = { + /** Level-local elevation of the pointed surface — the election cap. */ + elevation: number + /** World-space Y of the same surface, for grid-plane / preview placement. */ + worldY: number + /** + * World-space point where the pointer ray meets the pointed surface's + * plane, or null when the ray never reaches it. Unlike the grid event's + * own hit — whose XZ is perspective-skewed whenever the event plane + * rides at a different storey than the aimed-at surface — this point + * depends only on the ray and the pointed surface, so a preview / + * election fed from it cannot flip with the event plane's height. + */ + worldPoint: [number, number, number] | null + /** {@link PointerSupportSurface.worldPoint} in the grid event's + * `localPosition` (building-local) frame — a drop-in replacement for + * the event's plane-hit XZ. */ + localPoint: [number, number, number] | null +} + +/** + * The walking surface the pointer actually points at: its level-local + * elevation (for use as the slab-support election cap, `maxElevation`), + * its world-space Y (for riding the grid event plane / draw preview on + * it), and the ray's crossing of that surface's plane (the plan point the + * cursor indicates). + * + * The grid event plane rides at the ghost's last height, so its hit point + * alone can't be trusted (that feedback loop is what made a ghost under an + * elevated deck blink between the deck top and the ground). But camera → + * hit reconstructs the true pointer ray regardless of the plane height, + * and the nearest slab plane that ray crosses inside its rendered polygon + * IS the surface under the cursor — the deck top when aiming at the deck, + * the floor/ground when aiming underneath it. The same reasoning applies + * to the cursor XZ: the event plane's hit is skewed along the ray whenever + * the plane sits on a different storey than the pointed surface, so + * callers should place at `localPoint` / `worldPoint`, not the event hit. + * + * Returns null when no level is active (callers fall back to the + * uncapped max election and the raw event hit). + */ +export function resolvePointerSupportSurface( + camera: Camera, + worldHit: readonly [number, number, number], +): PointerSupportSurface | null { + const levelId = useViewer.getState().selection.levelId + if (!levelId) return null + + camera.getWorldPosition(originScratch) + hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) + // Slab polygons/elevations live in the level frame; the level mesh + // carries the storey Y offset and any building rotation. + const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) + if (levelMesh) { + levelMesh.worldToLocal(originScratch) + levelMesh.worldToLocal(hitScratch) + } + hitScratch.sub(originScratch) + if (hitScratch.lengthSq() < 1e-12) return null + + const { elevation, point } = spatialGridManager.getPointedSupportSurface( + levelId, + [originScratch.x, originScratch.y, originScratch.z], + [hitScratch.x, hitScratch.y, hitScratch.z], + ) + const worldY = levelMesh ? levelMesh.localToWorld(worldScratch.set(0, elevation, 0)).y : elevation + + let worldPoint: [number, number, number] | null = null + let localPoint: [number, number, number] | null = null + if (point) { + pointScratch.set(point[0], elevation, point[1]) + if (levelMesh) levelMesh.localToWorld(pointScratch) + worldPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + // Same frame the grid events report `localPosition` in (use-grid-events). + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + if (buildingMesh) buildingMesh.worldToLocal(pointScratch) + localPoint = [pointScratch.x, pointScratch.y, pointScratch.z] + } + + return { elevation, worldY, worldPoint, localPoint } +} + +/** {@link resolvePointerSupportSurface}, elevation only — the election cap. */ +export function resolvePointerSupportElevation( + camera: Camera, + worldHit: readonly [number, number, number], +): number | null { + return resolvePointerSupportSurface(camera, worldHit)?.elevation ?? null +} diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.test.ts b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts new file mode 100644 index 00000000..9339860f --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' + +describe('createStairCommitGate', () => { + test('allows commits until the session exits', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + // Repeat continuation: consecutive commits stay allowed. + expect(gate.shouldCommit()).toBe(true) + }) + + test('refuses every trigger after a single-continuation exit', () => { + const gate = createStairCommitGate() + expect(gate.shouldCommit()).toBe(true) + gate.markExited() + // The native follow-up click / stray node click of the same gesture. + expect(gate.shouldCommit()).toBe(false) + expect(gate.shouldCommit()).toBe(false) + }) +}) + +describe('swallowFollowUpBrowserClick', () => { + test('stops exactly one follow-up click', () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target) + + let firstStopped = 0 + const first = new Event('click', { cancelable: true }) + Object.defineProperty(first, 'stopPropagation', { value: () => firstStopped++ }) + target.dispatchEvent(first) + expect(firstStopped).toBe(1) + expect(first.defaultPrevented).toBe(true) + + // `once: true` — the next click of the next gesture passes through. + let secondStopped = 0 + const second = new Event('click', { cancelable: true }) + Object.defineProperty(second, 'stopPropagation', { value: () => secondStopped++ }) + target.dispatchEvent(second) + expect(secondStopped).toBe(0) + expect(second.defaultPrevented).toBe(false) + }) + + test('disarms after the timeout when no click follows', async () => { + const target = new EventTarget() + swallowFollowUpBrowserClick(target, 5) + await new Promise((resolve) => setTimeout(resolve, 15)) + + let stopped = 0 + const late = new Event('click', { cancelable: true }) + Object.defineProperty(late, 'stopPropagation', { value: () => stopped++ }) + target.dispatchEvent(late) + expect(stopped).toBe(0) + }) + + test('no-ops without a window-like target', () => { + expect(() => swallowFollowUpBrowserClick(undefined)).not.toThrow() + }) +}) diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.ts b/packages/editor/src/components/tools/stair/stair-click-guard.ts new file mode 100644 index 00000000..59100848 --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-click-guard.ts @@ -0,0 +1,72 @@ +/** + * Guards for the stair tool's commit triggers. + * + * One physical validation click can reach the stair tool's commit handler + * twice: node-surface clicks (`slab:click`, `wall:click`, …) are synthesized + * on *pointerup* by the viewer (`use-node-events`), while `grid:click` rides + * the browser's native *click* event from a canvas-level DOM listener + * (`use-grid-events`) that deliberately ignores R3F stopPropagation — and + * after a single-continuation commit the tool's emitter subscriptions survive + * until React unmounts it, which lands only after that native click. Without + * these guards a validation click over any node surface (a deck or floor + * slab, a wall, another stair) created TWO stairs from one click. + * + * Same hazard and same countermeasures as + * `packages/nodes/src/shared/floor-placement.ts` (`stopPlacementCommitPropagation`) + * and the `committed` flag in `move-registry-node-tool.tsx` — reimplemented + * here because `@pascal-app/editor` cannot depend on `@pascal-app/nodes`. + */ + +export type StairCommitGate = { + /** True while the armed session may still commit. */ + shouldCommit: () => boolean + /** + * Mark the session exited (single continuation): every further click + * trigger reaching the still-subscribed handler — the native follow-up + * click, a stray second node click — is refused. + */ + markExited: () => void +} + +export function createStairCommitGate(): StairCommitGate { + let exited = false + return { + shouldCommit: () => !exited, + markExited: () => { + exited = true + }, + } +} + +type ClickSwallowTarget = { + addEventListener: ( + type: string, + listener: (event: Event) => void, + options?: AddEventListenerOptions, + ) => void + removeEventListener: ( + type: string, + listener: (event: Event) => void, + options?: EventListenerOptions, + ) => void +} + +/** + * Eat the one native browser `click` that follows a pointerup-synthesized + * node click, before it reaches the canvas `grid:click` listener (capture + * phase on window runs first). Needed in repeat continuation too, where the + * tool stays armed and the gate above must keep allowing one commit per + * gesture. Self-disarms after the click or `timeoutMs`, whichever first. + */ +export function swallowFollowUpBrowserClick( + target: ClickSwallowTarget | undefined = typeof window === 'undefined' ? undefined : window, + timeoutMs = 300, +): void { + if (!target) return + const swallow = (event: Event) => { + event.stopPropagation() + event.preventDefault() + } + target.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => target.removeEventListener('click', swallow, { capture: true }), timeoutMs) +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8711909e..3b1b8068 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -34,6 +34,7 @@ import useFacingPose from '../../../store/use-facing-pose' import { useStairBuildPreview } from '../../../store/use-stair-build-preview' import { CursorSphere } from '../shared/cursor-sphere' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' +import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, DEFAULT_CURVED_STAIR_SWEEP_ANGLE, @@ -150,7 +151,6 @@ function createDefaultStairNode({ slabOpeningMode: 'destination', openingOffset: DEFAULT_STAIR_OPENING_OFFSET, width: DEFAULT_STAIR_WIDTH, - totalRise: DEFAULT_STAIR_HEIGHT, stepCount: DEFAULT_STAIR_STEP_COUNT, thickness: DEFAULT_STAIR_THICKNESS, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, @@ -231,6 +231,9 @@ export const StairTool: React.FC = () => { if (!currentLevelId) return const openingPreview = createSurfaceOpeningPreviewController() + // Refuses the duplicate commit triggers a single physical click produces + // — see `stair-click-guard.ts`. Fresh per armed session. + const commitGate = createStairCommitGate() // Reset rotation when tool activates rotationRef.current = 0 @@ -441,10 +444,20 @@ export const StairTool: React.FC = () => { const commitAtCursor = (event: ClickTriggerEvent) => { if (!currentLevelId) return + // One physical click can reach here twice (node click synthesized on + // pointerup + the native browser click driving `grid:click`) — see + // `stair-click-guard.ts`. The gate refuses anything after a single- + // continuation commit; the swallow below eats the same gesture's + // follow-up click while the tool stays armed (repeat continuation). + if (!commitGate.shouldCommit()) return const nodeEvent = 'node' in event ? (event as NodeEvent) : null if (nodeEvent) { nodeEvent.stopPropagation() nodeEvent.nativeEvent.stopPropagation() + // The canvas-level `grid:click` listener is out of stopPropagation's + // reach — without this, the browser click that follows this + // pointerup-synthesized node click commits a second stair. + swallowFollowUpBrowserClick() } const position = nodeEvent @@ -465,8 +478,14 @@ export const StairTool: React.FC = () => { if (useEditor.getState().getContinuation('point') === 'repeat') { alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) } else { + commitGate.markExited() useFacingPose.getState().clear() useEditor.getState().setTool(null) + // Return to select mode explicitly (matches the spawn tool's exit). + // The selection managers route node clicks only while + // `mode === 'select'`; exiting with `mode: 'build'` + a null tool + // left every click dead until the user pressed Escape. + useEditor.getState().setMode('select') } } diff --git a/packages/editor/src/components/tools/wall/wall-drafting.test.ts b/packages/editor/src/components/tools/wall/wall-drafting.test.ts index b9a22d71..611d3c8f 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.test.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.test.ts @@ -327,4 +327,35 @@ describe('snapWallDraftPointDetailed', () => { expect(result.point).toEqual([3.99, 0.03]) expect(result.snap).toBeNull() }) + + // Endpoint-move regression: walls attached to the moving corner keep their + // pre-drag coordinates in the scene during the drag, so their stale corner + // recreates the old junction inside the connect radius. The move tools must + // pass those walls in `ignoreWallIds` (attached mode) or a sub-5cm corner + // correction — e.g. squaring a scan-imported 91° junction — can never land. + test('a stale linked-wall corner swallows a sub-connect-radius correction unless ignored', () => { + // `wall_d` shares the dragged corner of `wall_c` at [2, 0.03]; the user + // drops 3cm away at [2, 0] to square the junction. + const linked = makeWall([2, 0.03], [2, 2], 'wall_d') + + const captured = snapWallDraftPointDetailed({ + point: [2, 0], + walls: [linked], + ignoreWallIds: ['wall_c'], + magnetic: false, + step: 0, + }) + expect(captured.point).toEqual([2, 0.03]) + expect(captured.snap).toBe('endpoint') + + const freed = snapWallDraftPointDetailed({ + point: [2, 0], + walls: [linked], + ignoreWallIds: ['wall_c', 'wall_d'], + magnetic: false, + step: 0, + }) + expect(freed.point).toEqual([2, 0]) + expect(freed.snap).toBeNull() + }) }) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts index 20edaeed..e588ce5a 100644 --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -5,6 +5,7 @@ import { type DoorNode, getScaledDimensions, type ItemNode, + resolveWallSupportSlabPatch, runAsSingleSceneHistoryStep, snapPointAlongAngleRay, useScene, @@ -456,6 +457,17 @@ export function isSegmentLongEnough(start: WallPlanPoint, end: WallPlanPoint): b export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, + options?: { + /** + * Pointer-decided support cap (level-local Y) from + * `resolvePointerSupportSurface` — the 3D tool passes the elevation of + * the surface the commit click actually aimed at, so the persisted + * host reproduces what the preview showed (floor under a deck vs deck + * top). Omitted by 2D floor-plan commits (no camera ray): those keep + * the uncapped max election. + */ + supportCap?: number | null + }, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, createNodes, deleteNode, nodes } = useScene.getState() @@ -541,8 +553,18 @@ export function createWallOnCurrentLevel( }) createNode(wall, currentLevelId) + const createdWall = useScene.getState().nodes[wall.id] + if (createdWall?.type === 'wall') { + useScene.getState().updateNode( + createdWall.id, + resolveWallSupportSlabPatch(createdWall, useScene.getState().nodes, { + maxElevation: options?.supportCap ?? null, + }), + ) + } sfxEmitter.emit('sfx:structure-build') - return wall + const committedWall = useScene.getState().nodes[wall.id] + return committedWall?.type === 'wall' ? committedWall : wall }) } diff --git a/packages/editor/src/components/ui/action-menu/measurement-control.tsx b/packages/editor/src/components/ui/action-menu/measurement-control.tsx index 0a203d4f..d383868e 100644 --- a/packages/editor/src/components/ui/action-menu/measurement-control.tsx +++ b/packages/editor/src/components/ui/action-menu/measurement-control.tsx @@ -1,12 +1,17 @@ 'use client' -import { useViewer } from '@pascal-app/viewer' +import type { + ConstructionDimensionChainMode, + ConstructionDimensionMode, +} from '@pascal-app/core' import { Box, Check, ChevronDown, - Eye, - EyeOff, + CircleIcon, + Crosshair, + Grid2X2, + Minus, Ruler, ScanSearch, Square, @@ -37,27 +42,64 @@ const measurementMenuOptions = [ ...measurementOptions, ] as const +const constructionDimensionOptions = [ + { mode: 'linear', chainMode: 'point-to-point', label: 'Linear dimension', icon: Ruler }, + { mode: 'linear', chainMode: 'continuous', label: 'Continuous dimension', icon: Waypoints }, + { mode: 'radius', chainMode: 'point-to-point', label: 'Radius dimension', icon: CircleIcon }, + { mode: 'diameter', chainMode: 'point-to-point', label: 'Diameter dimension', icon: CircleIcon }, + { mode: 'center-mark', chainMode: 'point-to-point', label: 'Center mark', icon: Crosshair }, + { mode: 'chord', chainMode: 'point-to-point', label: 'Chord dimension', icon: Minus }, + { mode: 'arc-length', chainMode: 'point-to-point', label: 'Arc length', icon: CircleIcon }, + { mode: 'angular', chainMode: 'point-to-point', label: 'Angular dimension', icon: Triangle }, + { mode: 'coordinate', chainMode: 'continuous', label: 'Coordinate dimensions', icon: Grid2X2 }, +] as const satisfies readonly { + mode: ConstructionDimensionMode + chainMode: ConstructionDimensionChainMode + label: string + icon: typeof Ruler +}[] + export function MeasurementControl() { const [isOpen, setIsOpen] = useState(false) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const selectedKind = useEditor((state) => state.lastMeasurementKind) const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind) + const constructionDimensionChainMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.chainMode, + ) + const constructionDimensionMode = useEditor( + (state) => state.toolDefaults['construction-dimension']?.mode, + ) const setMode = useEditor((state) => state.setMode) const setPhase = useEditor((state) => state.setPhase) const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setTool = useEditor((state) => state.setTool) const setToolDefaults = useEditor((state) => state.setToolDefaults) - const showMeasurements = useViewer((state) => state.showMeasurements) - const setShowMeasurements = useViewer((state) => state.setShowMeasurements) + const setViewMode = useEditor((state) => state.setViewMode) const selectedOption = measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0] const isActive = mode === 'build' && tool === 'measurement' + const isConstructionDimensionActive = mode === 'build' && tool === 'construction-dimension' + const activeConstructionDimensionOption = constructionDimensionOptions.find( + (option) => + option.mode === (constructionDimensionMode ?? 'linear') && + option.chainMode === (constructionDimensionChainMode ?? 'point-to-point'), + ) + const isControlActive = isActive || isConstructionDimensionActive const isSmartActive = isActive && activeToolKind === 'smart' - const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon - const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label + const SelectedIcon = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.icon ?? Ruler) + : isSmartActive + ? ScanSearch + : selectedOption.icon + const selectedLabel = isConstructionDimensionActive + ? (activeConstructionDimensionOption?.label ?? 'Linear dimension') + : isSmartActive + ? 'Smart' + : selectedOption.label const activateMeasurement = (kind: CreatableMeasurementKind) => { setPhase('structure') @@ -69,7 +111,7 @@ export function MeasurementControl() { } const handlePrimaryClick = () => { - if (isActive) { + if (isControlActive) { setMode('select') return } @@ -84,15 +126,27 @@ export function MeasurementControl() { setTool('measurement') } + const activateConstructionDimension = ( + dimensionMode: ConstructionDimensionMode, + chainMode: ConstructionDimensionChainMode, + ) => { + setPhase('structure') + setStructureLayer('elements') + setViewMode('2d') + setToolDefaults('construction-dimension', { chainMode, mode: dimensionMode }) + setMode('build') + setTool('construction-dimension') + } + return (
@@ -138,7 +192,7 @@ export function MeasurementControl() { const isSmart = option.kind === 'smart' const isSelected = isSmart ? isSmartActive - : !isSmartActive && option.kind === selectedKind + : !isConstructionDimensionActive && !isSmartActive && option.kind === selectedKind return ( + {constructionDimensionOptions.map((option) => { + const OptionIcon = option.icon + const isSelected = + isConstructionDimensionActive && activeConstructionDimensionOption === option + return ( + + ) + })}
diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 967602bf..300cb429 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -1,7 +1,7 @@ 'use client' import type { AnyNodeId } from '@pascal-app/core' -import { LevelNode, useScene } from '@pascal-app/core' +import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { AppWindow, @@ -196,6 +196,7 @@ export function EditorCommands() { ).length const newLevel = LevelNode.parse({ level: levelCount, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: building.id, }) diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index 4abae9b6..922a69a8 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -27,7 +27,12 @@ import { SceneMaterialList } from './scene-material-list' * fixed control/category header, a single scrolling catalog grid, and a fixed * scene-material footer (always visible, with a `+` to add a custom material). */ -export function MaterialPaintPanel() { +export type MaterialPaintPanelProps = { + /** When provided, the catalog grid leads with a "New material" tile that invokes it. */ + onCreateMaterialRequest?: () => void +} + +export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) { const activePaintMaterial = useEditor((state) => state.activePaintMaterial) const activePaintTarget = useEditor((state) => state.activePaintTarget) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) @@ -109,6 +114,7 @@ export function MaterialPaintPanel() { {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
{ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) }} diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e0284a1a..328a63d2 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -2,44 +2,82 @@ import { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialsForCategory, MATERIAL_CATEGORIES, + type MaterialCatalogItem, + type MaterialSource, type MaterialTarget, + subscribeLibraryMaterials, toLibraryMaterialRef, } from '@pascal-app/core' -import { useEffect, useState } from 'react' +import { Plus } from 'lucide-react' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { triggerSFX } from '../../../lib/sfx-bus' -type MaterialPickerProps = { +export type MaterialSourceFilter = 'all' | MaterialSource + +export type MaterialPickerProps = { selectedMaterialPreset?: string onSelectMaterialPreset?: (materialPreset: string) => void disabled?: boolean nodeType?: MaterialTarget hideSideControl?: boolean + onCreateMaterialRequest?: () => void } +const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'pascal', label: 'Pascal' }, + { id: 'mine', label: 'Mine' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'community', label: 'Community' }, +] + function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { return category.charAt(0).toUpperCase() + category.slice(1) } +function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) { + if (filter === 'all') return items + return items.filter((item) => (item.source ?? 'pascal') === filter) +} + /** - * Catalog material picker: a fixed row of category tabs over a scrollable grid - * of swatches. Custom-material creation lives in the scene-material section - * (the host's `+` action), not here, so it's available from any category. + * Catalog material picker: a fixed row of category tabs and a source filter row + * over a scrollable grid of swatches. Scene-material creation lives in the + * scene-material section (the host's `+` action); `onCreateMaterialRequest` is + * the host's entry point for authoring a new *library* material. */ export function MaterialPicker({ selectedMaterialPreset, onSelectMaterialPreset, disabled = false, + onCreateMaterialRequest, }: MaterialPickerProps) { const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( MATERIAL_CATEGORIES[0], ) + const [sourceFilter, setSourceFilter] = useState('all') + // Version counter so host registrations/unregistrations re-render the picker. + const libraryVersion = useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + const hasWorkspaceMaterials = useMemo( + () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'), + [libraryVersion], + ) + const visibleSourceFilters = SOURCE_FILTERS.filter( + (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials, + ) const availableCategories = MATERIAL_CATEGORIES.filter( (category) => getMaterialsForCategory(category).length > 0, ) - const catalogItems = getMaterialsForCategory(selectedCategory) + const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) // Keep the visible category in sync with the externally-selected catalog // material (a `scene:` ref matches no catalog entry, so the tab stays put). @@ -72,7 +110,7 @@ export function MaterialPicker({ setSelectedCategory(category) // Auto-select the first material in the category so the brush is // immediately ready (and the swatch shows as selected). - const first = getMaterialsForCategory(category)[0] + const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0] if (first) handleCatalogSelect(first.id) }} type="button" @@ -81,11 +119,52 @@ export function MaterialPicker({ ))}
+ {/* Fixed source filter tabs — underline style, matching the catalog + browse surfaces (Items / Rooms / Build / Search) rather than the + pill-button category row above. */} +
+ {visibleSourceFilters.map((filter) => ( + + ))} +
{/* The only scrolling region. */}
+ {onCreateMaterialRequest ? ( + + ) : null} {catalogItems.map((item) => { const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) return ( diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index ef922dfa..591902c4 100644 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -22,6 +22,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, LevelNode, useScene, } from '@pascal-app/core' @@ -49,7 +51,10 @@ import { subscribeEditorClipboard, } from '../../lib/scene-clipboard' import { sfxEmitter } from '../../lib/sfx-bus' +import { useLinearDisplay } from '../../lib/use-linear-display' import { cn } from '../../lib/utils' +import { ActionButton } from './controls/action-button' +import { SliderControl } from './controls/slider-control' import { LevelDuplicateDialog } from './level-duplicate-dialog' import { Dialog, @@ -145,6 +150,26 @@ function LevelRow({ }) { const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const updateNode = useScene((s) => s.updateNode) + const { isImperial, toDisplay, displayUnit } = useLinearDisplay('m', 2) + + const storeyHeight = getStoredLevelHeight(level) + // toFixed(2) + strip one trailing zero: "2.50" → "2.5", "2.75" stays. + const storeyHeightLabel = `${toDisplay(storeyHeight).toFixed(2).replace(/0$/, '')} ${displayUnit}` + + // Clean preset values per display system; imperial stores exact meters + // for whole-foot storey heights. + const heightPresets = isImperial + ? [ + { label: '8 ft', height: 2.4384 }, + { label: '9 ft', height: 2.7432 }, + { label: '10 ft', height: 3.048 }, + ] + : [ + { label: '2.5 m', height: 2.5 }, + { label: '3.0 m', height: 3.0 }, + { label: '3.5 m', height: 3.5 }, + ] return (
@@ -195,6 +220,48 @@ function LevelRow({ {getLevelDisplayName(level)} + {/* Storey height badge — opens the height popover */} + + + + + e.stopPropagation()} + side="right" + sideOffset={8} + > + updateNode(level.id, { height: v })} + precision={3} + step={0.1} + unit="m" + value={Math.round(storeyHeight * 1000) / 1000} + /> +
+ {heightPresets.map((preset) => ( + updateNode(level.id, { height: preset.height })} + /> + ))} +
+
+
+ {/* Vertical three-dot menu — inside the pill */} @@ -371,6 +438,7 @@ export function FloatingLevelSelector() { const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 const newLevel = LevelNode.parse({ level: maxLevel + 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -383,6 +451,7 @@ export function FloatingLevelSelector() { const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1 const newLevel = LevelNode.parse({ level: minLevel - 1, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) @@ -409,6 +478,7 @@ export function FloatingLevelSelector() { const newLevel = LevelNode.parse({ level: newLevelNumber, + height: DEFAULT_LEVEL_HEIGHT, children: [], parentId: resolvedBuildingId, }) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 4ea8f6fd..83b1c2e3 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { export function HelperManager() { const mode = useEditor((s) => s.mode) const tool = useEditor((s) => s.tool) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind) const workspaceMode = useEditor((s) => s.workspaceMode) const scope = useInteractionScope((s) => s.scope) @@ -148,6 +149,10 @@ export function HelperManager() { // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null + // First-person walkthrough has its own HUD; editor shortcut hints (e.g. the + // Ctrl multi-select hint — Ctrl is crouch there) don't apply while walking. + if (isFirstPersonMode) return null + // The studio workspace (compose panel / gallery) has no scene selection or // tools — editor shortcut hints would only mislead there. if (workspaceMode === 'studio') return null diff --git a/packages/editor/src/components/ui/panels/multi-selection-panel.tsx b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx new file mode 100644 index 00000000..09cac7ff --- /dev/null +++ b/packages/editor/src/components/ui/panels/multi-selection-panel.tsx @@ -0,0 +1,57 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Copy, Trash2 } from 'lucide-react' +import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelWrapper } from './panel-wrapper' +import { formatSelectionBreakdown } from './selection-breakdown' + +/** + * Docked right-side panel for a MULTI-selection — the compact sibling of the + * single-node inspector, rendered by `PanelManager` when more than one node + * is selected. Same collapsed-by-default `PanelWrapper` shell (header always + * visible; the shared desktop collapse state carries across single ↔ multi + * swaps). Actions mirror the floating group pill: Duplicate clones the + * selection and picks the clones up, Delete removes the whole selection + * (including its bulk-delete confirm). Unlike the pill, the docked panel + * stays visible during interactions. `footer` is the host-injected slot + * (e.g. community's "Save to my catalog"). + */ +export function MultiSelectionPanel({ footer }: { footer?: React.ReactNode }) { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + // String selector — recomputed on scene ticks, but the === compare keeps + // unrelated mutations from re-rendering the panel. + const breakdown = useScene((s) => + formatSelectionBreakdown(selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type)), + ) + + return ( + setSelection({ selectedIds: [] })} + title={`${selectedIds.length} selected`} + width={320} + > + {breakdown &&
{breakdown}
} +
+ + } + label="Duplicate" + onClick={() => duplicateSelectionAndPickUp()} + /> + } + label="Delete" + onClick={() => deleteSelection()} + /> + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 9752022a..34d36718 100644 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -28,6 +28,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MobilePanelSheet } from './mobile-panel-sheet' import { MobileSelectionBar } from './mobile-selection-bar' +import { MultiSelectionPanel } from './multi-selection-panel' import { getNodeDisplay } from './node-display' import { resetDesktopInspectorCollapsed } from './panel-wrapper' import { ParametricInspector } from './parametric-inspector' @@ -168,7 +169,13 @@ function MobilePanelLayer({ ) } -export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.ReactNode }) { +export function PanelManager({ + inspectorFooter, + multiSelectionFooter, +}: { + inspectorFooter?: React.ReactNode + multiSelectionFooter?: React.ReactNode +}) { const isMobile = useIsMobile() const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedZoneId = useViewer((s) => s.selection.zoneId) @@ -237,5 +244,11 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac ) } + // Multi-selection: compact docked panel (desktop only — the mobile branch + // above keeps today's behavior and renders nothing for multi-selections). + if (selectedIds.length > 1) { + return + } + return panelForType(selectedNodeType, inspectorFooter) } diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.test.ts b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts new file mode 100644 index 00000000..5a3df38f --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { formatSelectionBreakdown } from './selection-breakdown' + +describe('formatSelectionBreakdown', () => { + test('counts per type in first-appearance order, pluralizing with +s', () => { + expect(formatSelectionBreakdown(['slab', 'stair', 'fence', 'fence'])).toBe( + '1 slab · 1 stair · 2 fences', + ) + }) + + test('humanizes hyphenated kinds', () => { + expect(formatSelectionBreakdown(['roof-segment', 'roof-segment', 'wall'])).toBe( + '2 roof segments · 1 wall', + ) + }) + + test('skips missing nodes', () => { + expect(formatSelectionBreakdown(['wall', undefined, null])).toBe('1 wall') + }) + + test('empty selection formats to an empty string', () => { + expect(formatSelectionBreakdown([])).toBe('') + }) +}) diff --git a/packages/editor/src/components/ui/panels/selection-breakdown.ts b/packages/editor/src/components/ui/panels/selection-breakdown.ts new file mode 100644 index 00000000..b8c5c913 --- /dev/null +++ b/packages/editor/src/components/ui/panels/selection-breakdown.ts @@ -0,0 +1,20 @@ +/** + * "1 slab · 1 stair · 2 fences" — one entry per node type in first-appearance + * order so the line stays stable while shift-clicking. Labels derive from the + * type id ('roof-segment' → 'roof segment'); pluralization is a simple +s + * (the codebase has no pluralize helper and no current kind needs one). + * Missing nodes (stale ids) are skipped. + */ +export function formatSelectionBreakdown(types: Array): string { + const counts = new Map() + for (const type of types) { + if (!type) continue + counts.set(type, (counts.get(type) ?? 0) + 1) + } + const parts: string[] = [] + for (const [type, count] of counts) { + const label = type.replace(/-/g, ' ') + parts.push(`${count} ${count === 1 ? label : `${label}s`}`) + } + return parts.join(' · ') +} diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index 8c54a739..49ef0bdd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -1,4 +1,9 @@ -import { clearSceneHistory, emitter, useScene, validateBuildJson } from '@pascal-app/core' +import { + clearSceneHistory, + emitter, + useScene, + validateBuildJson, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { TreeView, VisualJson } from '@visual-json/react' import { Camera, Download, Map as MapIcon, Save, Trash2, Upload } from 'lucide-react' @@ -394,14 +399,14 @@ export function SettingsPanel({
-
Floorplan
+
Floor plan
- ) : ( - - - - )} -
-
- {projectName || 'Untitled'} -
- {owner?.username && ( - - @{owner.username} - - )} -
-
- - {/* Breadcrumb — only shown when navigated into a building */} - {building && ( -
-
- - - {building && ( - <> - - - - )} - - {level && ( - <> - - - - )} - - {zone && ( - <> - - - {zone.name} - - - )} - - {selectedNode && zone && ( - <> - - - {getNodeName(selectedNode)} - - - )} -
-
- )} -
- - {/* Level List (only when building is selected) */} - {building && levels.length > 0 && ( -
- - Levels - -
- {levels.map((lvl) => { - const isSelected = lvl.id === selection.levelId - return ( - - ) - })} -
-
- )} -
- - {/* Controls Panel - Bottom Center */} -
- -
- {/* Scans and Guides Visibility */} - {canShowScans && ( - useViewer.getState().setShowScans(!showScans)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Scans - - )} - - {canShowGuides && ( - useViewer.getState().setShowGuides(!showGuides)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Guides - - )} - - {(canShowScans || canShowGuides) &&
} - - {/* Camera Mode */} - - useViewer - .getState() - .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') - } - size="icon" - tooltipSide="top" - variant="ghost" - > - - - - - - - - - - {/* Level Mode */} - { - if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') - const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] - const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length - useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - {levelMode === 'solo' && } - {levelMode === 'exploded' && ( - - )} - {(levelMode === 'stacked' || levelMode === 'manual') && ( - - )} - - - - - {/* Wall Mode */} - { - const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ - 'cutaway', - 'up', - 'down', - 'translucent', - ] - const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length - useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - {(() => { - const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon - return - })()} - - -
- - {/* Camera Actions */} - emitter.emit('camera-controls:orbit-ccw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Left - - - emitter.emit('camera-controls:orbit-cw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Right - - - emitter.emit('camera-controls:top-view')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Top View - - -
- - {/* First-person walkthrough */} - { - flushSync(() => useEditor.getState().setFirstPersonMode(true)) - requestWalkthroughPointerLock() - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - -
- -
- - ) -} +}: ViewerOverlayProps) => ( + <> + + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + /> + +) diff --git a/packages/editor/src/components/viewer/viewer-controls-bar.tsx b/packages/editor/src/components/viewer/viewer-controls-bar.tsx new file mode 100644 index 00000000..929dbd4a --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-controls-bar.tsx @@ -0,0 +1,466 @@ +'use client' + +import { emitter } from '@pascal-app/core' +import { + CLAY_PALETTE, + type EdgeMode, + getSceneTheme, + SCENE_THEMES, + useViewer, +} from '@pascal-app/viewer' +import { + Box, + Camera, + Check, + Contrast, + Diamond, + Eye, + EyeOff, + Footprints, + Layers, + Layers2, + Palette, + PenLine, + SlidersHorizontal, + Sparkles, + Square, + SwatchBook, +} from 'lucide-react' +import { cn } from '../../lib/utils' +import { ActionButton } from '../ui/action-menu/action-button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '../ui/primitives/dropdown-menu' +import { TooltipProvider } from '../ui/primitives/tooltip' + +const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { + stacked: 'Stacked', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeConfig = { + up: { + icon: (props: any) => ( + Full height + ), + label: 'Full height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => ( + Low + ), + label: 'Low', + }, +} + +const SHADING_OPTIONS = [ + { id: 'solid', name: 'Solid', detail: 'Flat and fast — no ambient occlusion', icon: Box }, + { id: 'rendered', name: 'Rendered', detail: 'Full ambient occlusion', icon: Sparkles }, +] as const + +const EDGE_OPTIONS = [ + { id: 'off', name: 'Off', detail: 'No edge lines' }, + { id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' }, + { id: 'strong', name: 'Strong', detail: 'Crisp, opaque edge lines' }, +] as const satisfies readonly { id: EdgeMode; name: string; detail: string }[] + +// Keep the dropdown open when flipping an in-place toggle row. +const keepOpen = (event: Event, fn: () => void) => { + event.preventDefault() + fn() +} + +// Scans + guides folded into one control. A baked GLB carries none of its own, +// but the GLB viewer re-adds them from scene data when the privacy flags allow, +// so the toggle shows for whichever exist. +function VisibilityMenu({ + canShowScans, + canShowGuides, +}: { + canShowScans: boolean + canShowGuides: boolean +}) { + const showScans = useViewer((s) => s.showScans) + const showGuides = useViewer((s) => s.showGuides) + return ( + + + + + + + + {canShowScans && ( + keepOpen(e, () => useViewer.getState().setShowScans(!showScans))} + > + + Scans + {showScans ? ( + + ) : ( + + )} + + )} + {canShowGuides && ( + keepOpen(e, () => useViewer.getState().setShowGuides(!showGuides))} + > + + Guides + {showGuides ? ( + + ) : ( + + )} + + )} + + + ) +} + +// One "Display" button gathering shadows, camera projection, colors, render +// mode, scene theme and edges. +function DisplayMenu() { + const cameraMode = useViewer((s) => s.cameraMode) + const shading = useViewer((s) => s.shading) + const textures = useViewer((s) => s.textures) + const shadows = useViewer((s) => s.shadows) + const sceneTheme = useViewer((s) => s.sceneTheme) + const edges = useViewer((s) => s.edges) + const activeShading = SHADING_OPTIONS.find((o) => o.id === shading) ?? SHADING_OPTIONS[0] + const activeTheme = getSceneTheme(sceneTheme) + const activeEdges = EDGE_OPTIONS.find((o) => o.id === edges) ?? EDGE_OPTIONS[0] + return ( + + + + + + + + keepOpen(e, () => useViewer.getState().setShadows(!shadows))} + > + + Shadows + {shadows ? 'On' : 'Off'} + + + keepOpen(e, () => + useViewer + .getState() + .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective'), + ) + } + > + + Camera + + {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'} + + + keepOpen(e, () => useViewer.getState().setTextures(!textures))} + > + {textures ? : } + Colors + + {textures ? 'Colored' : 'Monochrome'} + + + + + + + + + Render + {activeShading.name} + + + {SHADING_OPTIONS.map((option) => { + const OptionIcon = option.icon + return ( + useViewer.getState().setShading(option.id)} + > + +
+ {option.name} + {option.detail} +
+ {shading === option.id ? ( + + ) : null} +
+ ) + })} +
+
+ + + + + Theme + + {activeTheme.name} + + + + {SCENE_THEMES.map((t) => { + const swatches = (['wall', 'roof', 'floor', 'glazing'] as const).map( + (role) => t.clayTints?.[role] ?? CLAY_PALETTE[role], + ) + return ( + useViewer.getState().setSceneTheme(t.id)} + > + + {swatches.map((color, index) => ( + + ))} + + {t.name} + {sceneTheme === t.id ? : null} + + ) + })} + + + + + + + Edges + {activeEdges.name} + + + {EDGE_OPTIONS.map((option) => ( + useViewer.getState().setEdges(option.id)} + > +
+ {option.name} + {option.detail} +
+ {edges === option.id ? : null} +
+ ))} +
+
+
+
+ ) +} + +export type ViewerControlsBarProps = { + canShowScans?: boolean + canShowGuides?: boolean + /** A baked GLB is the active artifact: hide controls it can't honor (wall + * modes aren't baked into the GLB). */ + glbActive?: boolean + /** In GLB mode, whether scans/guides were re-added from scene data — so the + * visibility control surfaces the matching toggle even though the artifact + * itself carries none. */ + glbHasScans?: boolean + glbHasGuides?: boolean + walkthroughActive?: boolean + onWalkthroughToggle: () => void + className?: string +} + +export const ViewerControlsBar = ({ + canShowScans = true, + canShowGuides = true, + glbActive = false, + glbHasScans = false, + glbHasGuides = false, + walkthroughActive = false, + onWalkthroughToggle, + className, +}: ViewerControlsBarProps) => { + const levelMode = useViewer((s) => s.levelMode) + const wallMode = useViewer((s) => s.wallMode) + // Sessions may carry a stale mode outside the cycle (e.g. the retired + // 'translucent'); render and cycle it as cutaway instead of crashing. + const safeWallMode = ( + wallMode in wallModeConfig ? wallMode : 'cutaway' + ) as keyof typeof wallModeConfig + const WallModeIcon = wallModeConfig[safeWallMode].icon + + return ( +
+ +
+ {((canShowScans && (!glbActive || glbHasScans)) || + (canShowGuides && (!glbActive || glbHasGuides))) && ( + <> + +
+ + )} + + {/* Level mode */} + { + if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') + const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] + const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length + useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + {levelMode === 'solo' && } + {levelMode === 'exploded' && } + {(levelMode === 'stacked' || levelMode === 'manual') && } + + + {/* Wall mode — parametric only; baked GLB walls are fixed-height. */} + {!glbActive && ( + { + const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const nextIndex = (modes.indexOf(safeWallMode) + 1) % modes.length + useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + + + )} + +
+ + + +
+ + {/* Walkthrough */} + + + + +
+ + {/* Camera actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit right + + + emitter.emit('camera-controls:top-view')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Top view + +
+ +
+ ) +} diff --git a/packages/editor/src/components/viewer/viewer-scene-header.tsx b/packages/editor/src/components/viewer/viewer-scene-header.tsx new file mode 100644 index 00000000..1d44a9be --- /dev/null +++ b/packages/editor/src/components/viewer/viewer-scene-header.tsx @@ -0,0 +1,235 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + getLevelDisplayName, + type LevelNode, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { ArrowLeft, ChevronRight, Layers } from 'lucide-react' +import Link from 'next/link' +import type { ReactNode } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { cn } from '../../lib/utils' + +const getNodeName = (node: AnyNode): string => { + if ('name' in node && node.name) return node.name + if (node.type === 'wall') return 'Wall' + if (node.type === 'fence') return 'Fence' + if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' + if (node.type === 'slab') return 'Slab' + if (node.type === 'ceiling') return 'Ceiling' + if (node.type === 'roof') return 'Roof' + if (node.type === 'roof-segment') return 'Roof Segment' + return node.type +} + +export type ViewerSceneHeaderProps = { + projectName?: string | null + owner?: { username?: string | null } | null + onBack?: () => void + /** Fallback destination when no `onBack` handler is supplied. Must already be + * sanitized by the caller. */ + backHref?: string + /** Extra row under the project info (e.g. likes/fork actions). */ + stats?: ReactNode +} + +export const ViewerSceneHeader = ({ + projectName, + owner, + onBack, + backHref = '/', + stats, +}: ViewerSceneHeaderProps) => { + const selection = useViewer((s) => s.selection) + + // Subscribe only to the specific nodes we read so that creating an unrelated + // node elsewhere in the scene doesn't re-render this overlay. + const firstSelectedId = selection.selectedIds[0] ?? null + const building = useScene((s) => + selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null, + ) + const level = useScene((s) => + selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null, + ) + const zone = useScene((s) => + selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null, + ) + const selectedNode = useScene((s) => + firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null, + ) + // Highest first so the list reads top-down like a building section. + const levels = useScene( + useShallow((s) => { + if (!building) return [] + return building.children + .map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined) + .filter((n): n is LevelNode => n?.type === 'level') + .sort((a, b) => b.level - a.level) + }), + ) + + const handleLevelClick = (levelId: LevelNode['id']) => { + // When switching levels, deselect zone and items + useViewer.getState().setSelection({ levelId }) + } + + const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level') => { + switch (depth) { + case 'root': + useViewer.getState().resetSelection() + break + case 'building': + useViewer.getState().setSelection({ levelId: null }) + break + case 'level': + useViewer.getState().setSelection({ zoneId: null }) + break + } + } + + return ( +
+
+ {/* Project info + back */} +
+ {onBack ? ( + + ) : ( + + + + )} +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ + {stats && ( +
{stats}
+ )} + + {/* Breadcrumb — only shown when navigated into a building */} + {building && ( +
+
+ + + + + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + + {getNodeName(selectedNode)} + + + )} +
+
+ )} +
+ + {/* Level list (only when a building is selected) */} + {building && levels.length > 0 && ( +
+ + Levels + +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId + return ( + + ) + })} +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx new file mode 100644 index 00000000..8af196ae --- /dev/null +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -0,0 +1,111 @@ +'use client' + +import type { ReactNode } from 'react' +import { cn } from '../lib/utils' +import type { WalkthroughInteract } from '../store/use-first-person-hud' + +export type { WalkthroughInteract } from '../store/use-first-person-hud' + +export type WalkthroughHudProps = { + floorLabel?: string | null + zoneLabel?: string | null + interact?: WalkthroughInteract + /** Pointer lock temporarily released (OS screenshot) — the pill flips to + * "Click to resume" and lets clicks fall through to the canvas. */ + suspended?: boolean + onExit?: () => void + children?: ReactNode +} + +export function WalkthroughHud({ + floorLabel, + zoneLabel, + interact = null, + suspended = false, + onExit, + children, +}: WalkthroughHudProps) { + const kbdClass = 'rounded border border-border/60 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]' + const pillClass = + 'flex items-center gap-1.5 rounded-full border border-border/40 bg-background/70 px-3 py-1 text-muted-foreground text-xs backdrop-blur-xl' + const exitContent = ( + <> + Esc + to exit + + ) + + return ( +
+
+ {floorLabel && ( +
+ {floorLabel} +
+ )} + {zoneLabel && ( +
+ {zoneLabel} +
+ )} + {children} +
+ +
+
+
+ +
+ {suspended ? ( +
+ Click + or + P + to resume + · + {exitContent} +
+ ) : ( + <> +
+ P + free cursor +
+ {onExit ? ( + + ) : ( +
{exitContent}
+ )} + + )} +
+ + {interact && ( +
+
+ + E + + or click to + + {interact.verb} {interact.label} + +
+
+ )} +
+ ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5db96e10..c9a0c81e 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -26,6 +26,7 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { BakeExporter } from './components/editor/bake-exporter' +export { FirstPersonControls } from './components/editor/first-person-controls' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' // Embed surface — the editor's real in-canvas affordances, so a host can mount // authentic selection handles, interactive build tools, and the mover on top @@ -80,6 +81,9 @@ export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { useFloorplanRender } from './components/editor-2d/floorplan-render-context' +export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer' +export { FloorplanGeometryRenderer } from './components/editor-2d/renderers/floorplan-geometry-renderer' export { FloorplanNodePreview, type FloorplanNodePreviewProps, @@ -132,6 +136,13 @@ export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' export { PlacementBox } from './components/tools/shared/placement-box' +// Pointer-decided support surface (deck top vs floor underneath) — the +// draw tools (wall / fence) ride their grid plane and commit cap on it. +export { + type PointerSupportSurface, + resolvePointerSupportElevation, + resolvePointerSupportSurface, +} from './components/tools/shared/pointer-support-cap' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, @@ -200,8 +211,15 @@ export { } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' export { ActionButton, ActionGroup } from './components/ui/controls/action-button' -export { MaterialPaintPanel } from './components/ui/controls/material-paint-panel' -export { MaterialPicker } from './components/ui/controls/material-picker' +export { + MaterialPaintPanel, + type MaterialPaintPanelProps, +} from './components/ui/controls/material-paint-panel' +export { + MaterialPicker, + type MaterialPickerProps, + type MaterialSourceFilter, +} from './components/ui/controls/material-picker' export { MetricControl } from './components/ui/controls/metric-control' export { PanelSection } from './components/ui/controls/panel-section' export { SegmentedControl } from './components/ui/controls/segmented-control' @@ -247,6 +265,19 @@ export { SnapTargetBadge, SnapTargetIcon, } from './components/ui/snap-target-badge' +export { + ViewerControlsBar, + type ViewerControlsBarProps, +} from './components/viewer/viewer-controls-bar' +export { + ViewerSceneHeader, + type ViewerSceneHeaderProps, +} from './components/viewer/viewer-scene-header' +export { + WalkthroughHud, + type WalkthroughHudProps, + type WalkthroughInteract, +} from './components/walkthrough-hud' export type { SaveStatus } from './hooks/use-auto-save' // useDragAction is the React-side glue for the registry's DragAction // primitive. Public so registry-driven kinds (Phase 5+ Stage D ports) @@ -304,6 +335,28 @@ export { type FloorplanStairSegmentEntry, getFloorplanWallThickness, } from './lib/floorplan' +export type { + FloorplanAnnotationCategory, + FloorplanAnnotationVisibility, +} from './lib/floorplan/annotation-visibility' +export { + createFloorplanContextExtensions, + FLOORPLAN_CONTEXT_EXTENSION_KEY, + FLOORPLAN_GEOMETRY_METADATA_KEY, + FLOORPLAN_NODE_EXTENSION_KEY, + type FloorplanAnnotationRole, + type FloorplanMetricNotation, + type FloorplanNodeExtension, + type FloorplanRenderPurpose, + type FloorplanSchedule, + type FloorplanToolContext, + floorplanGeometryMetadata, + getFloorplanNodeExtension, + readFloorplanContext, + readFloorplanGeometryMetadata, + readFloorplanMetricNotationOverride, + withFloorplanGeometryMetadata, +} from './lib/floorplan/floorplan-extension' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' export { exportSceneToGlb } from './lib/glb-export' export { @@ -434,6 +487,10 @@ export { export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' +export { + DRAWING_TYPE_OPTIONS, + default as useDrawingView, +} from './store/use-drawing-view' export type { CaptureMode, FloorplanSelectionTool, @@ -456,7 +513,14 @@ export { } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' +export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud' +export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility' export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview' +export { + default as useFloorplanPreflight, + type FloorplanPreflightIssue, + type FloorplanPreflightIssueKind, +} from './store/use-floorplan-preflight' export { default as useInteractionScope, getEditingHole, diff --git a/packages/editor/src/lib/door-interaction.ts b/packages/editor/src/lib/door-interaction.ts index 7d9efc48..1ad72088 100644 --- a/packages/editor/src/lib/door-interaction.ts +++ b/packages/editor/src/lib/door-interaction.ts @@ -15,7 +15,7 @@ type DoorOpenAnimationOptions = { persist?: boolean } -function getDisplayedDoorValue( +export function getDisplayedDoorValue( doorId: AnyNodeId, field: keyof DoorInteractiveState, nodeValue: number | undefined, diff --git a/packages/editor/src/lib/floorplan/annotation-visibility.test.ts b/packages/editor/src/lib/floorplan/annotation-visibility.test.ts new file mode 100644 index 00000000..332233ea --- /dev/null +++ b/packages/editor/src/lib/floorplan/annotation-visibility.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import { + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + filterFloorplanAnnotationGeometry, + normalizeFloorplanAnnotationVisibility, +} from './annotation-visibility' +import { floorplanGeometryMetadata } from './floorplan-extension' + +describe('floor-plan annotation visibility', () => { + test('fills missing persisted categories with visible defaults', () => { + expect(normalizeFloorplanAnnotationVisibility({ measurements: false })).toEqual({ + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + measurements: false, + }) + }) + + test('removes automatic dimension primitives without removing plan geometry', () => { + const line = { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 0 } satisfies FloorplanGeometry + const geometry = { + kind: 'group', + children: [ + line, + { + kind: 'dimension-string', + segments: [{ start: [0, 0], end: [2, 0], text: '2.00m' }], + offsetNormal: [0, 1], + offsetDistance: 0.3, + extensionOvershoot: 0.1, + }, + ], + } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry(geometry, { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + automaticDimensions: false, + }), + ).toEqual({ kind: 'group', children: [line] }) + }) + + test('removes a complete curved automatic dimension group', () => { + const curvedDimension = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }), + children: [ + { kind: 'line', x1: 0, y1: 0, x2: 2, y2: 2 }, + { kind: 'dimension-label', cx: 1, cy: 1, text: 'R 2m', angle: 0 }, + ], + } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry(curvedDimension, { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + automaticDimensions: false, + }), + ).toBeNull() + }) + + test('removes only the opening mark from door geometry', () => { + const body = { + kind: 'polygon', + points: [ + [0, 0], + [1, 0], + [1, 0.1], + ], + } satisfies FloorplanGeometry + const mark = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { kind: 'line', x1: 0.5, y1: 0, x2: 0.5, y2: 0.5 }, + { kind: 'rect', x: 0.3, y: 0.5, width: 0.4, height: 0.3 }, + { kind: 'text', x: 0.5, y: 0.65, text: '101', fontSize: 0.15, upright: true }, + ], + } satisfies FloorplanGeometry + const geometry = { kind: 'group', children: [body, mark] } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry(geometry, { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + openingMarks: false, + }), + ).toEqual({ kind: 'group', children: [body] }) + }) + + test('hides manual dimensions and measurements independently', () => { + const manualDimension = { + kind: 'text', + x: 0, + y: 0, + text: 'Annotation', + fontSize: 0.15, + metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }), + } satisfies FloorplanGeometry + const measurement = { + ...manualDimension, + metadata: floorplanGeometryMetadata({ annotationRole: 'measurement' }), + } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry(manualDimension, { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + manualDimensions: false, + }), + ).toBeNull() + expect( + filterFloorplanAnnotationGeometry( + { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'manual-dimension' }), + children: [ + { + kind: 'dimension', + start: [0, 0], + end: [1, 0], + offsetNormal: [0, 1], + offsetDistance: 0.5, + extensionOvershoot: 0.1, + text: '1m', + }, + ], + }, + { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + automaticDimensions: false, + }, + ), + ).not.toBeNull() + expect( + filterFloorplanAnnotationGeometry(measurement, { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + measurements: false, + }), + ).toBeNull() + }) + + test('hides structural grids and only the center marks within column geometry', () => { + const centerMark = { + kind: 'line', + x1: -0.1, + y1: -0.1, + x2: 0.1, + y2: 0.1, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + } satisfies FloorplanGeometry + const gridReference = { + kind: 'text', + x: 0, + y: 0.3, + text: 'B-2', + fontSize: 0.13, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + } satisfies FloorplanGeometry + const footprint = { + kind: 'rect', + x: -0.2, + y: -0.2, + width: 0.4, + height: 0.4, + } satisfies FloorplanGeometry + const visibility = { + ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + structuralGrids: false, + } + + expect( + filterFloorplanAnnotationGeometry( + { + kind: 'group', + children: [footprint, centerMark, gridReference], + }, + visibility, + ), + ).toEqual({ kind: 'group', children: [footprint] }) + expect( + filterFloorplanAnnotationGeometry( + { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'structural-grid' }), + children: [footprint], + }, + visibility, + ), + ).toBeNull() + }) + + test('hides room labels without removing the room footprint', () => { + const footprint = { + kind: 'polygon', + points: [ + [0, 0], + [4, 0], + [4, 3], + ], + } satisfies FloorplanGeometry + const roomName = { + kind: 'text', + x: 2, + y: 1.5, + text: 'Office', + fontSize: 0.2, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry( + { kind: 'group', children: [footprint, roomName] }, + { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, roomLabels: false }, + ), + ).toEqual({ kind: 'group', children: [footprint] }) + }) + + test('hides stair notes and break lines without removing stair geometry', () => { + const footprint = { + kind: 'polygon', + points: [ + [0, 0], + [1, 0], + [1, 3], + [0, 3], + ], + } satisfies FloorplanGeometry + const direction = { + kind: 'text', + x: 0.5, + y: 0.5, + text: 'UP', + fontSize: 0.16, + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } satisfies FloorplanGeometry + const breakLine = { + kind: 'polyline', + points: [ + [0, 2], + [1, 2], + ], + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } satisfies FloorplanGeometry + + expect( + filterFloorplanAnnotationGeometry( + { kind: 'group', children: [footprint, direction, breakLine] }, + { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, stairAnnotations: false }, + ), + ).toEqual({ kind: 'group', children: [footprint] }) + }) +}) diff --git a/packages/editor/src/lib/floorplan/annotation-visibility.ts b/packages/editor/src/lib/floorplan/annotation-visibility.ts new file mode 100644 index 00000000..4f466e0e --- /dev/null +++ b/packages/editor/src/lib/floorplan/annotation-visibility.ts @@ -0,0 +1,110 @@ +import type { FloorplanGeometry } from '@pascal-app/core' +import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './floorplan-extension' + +export type FloorplanAnnotationCategory = + | 'automaticDimensions' + | 'manualDimensions' + | 'measurements' + | 'openingMarks' + | 'structuralGrids' + | 'roomLabels' + | 'stairAnnotations' + +export type FloorplanAnnotationVisibility = Record + +export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = { + automaticDimensions: true, + manualDimensions: true, + measurements: true, + openingMarks: true, + structuralGrids: true, + roomLabels: true, + stairAnnotations: true, +} + +export function normalizeFloorplanAnnotationVisibility( + value: unknown, +): FloorplanAnnotationVisibility { + if (!value || typeof value !== 'object') return { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } + const persisted = value as Partial> + return { + automaticDimensions: + typeof persisted.automaticDimensions === 'boolean' + ? persisted.automaticDimensions + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions, + manualDimensions: + typeof persisted.manualDimensions === 'boolean' + ? persisted.manualDimensions + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.manualDimensions, + measurements: + typeof persisted.measurements === 'boolean' + ? persisted.measurements + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.measurements, + openingMarks: + typeof persisted.openingMarks === 'boolean' + ? persisted.openingMarks + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.openingMarks, + structuralGrids: + typeof persisted.structuralGrids === 'boolean' + ? persisted.structuralGrids + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.structuralGrids, + roomLabels: + typeof persisted.roomLabels === 'boolean' + ? persisted.roomLabels + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.roomLabels, + stairAnnotations: + typeof persisted.stairAnnotations === 'boolean' + ? persisted.stairAnnotations + : DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.stairAnnotations, + } +} + +export function filterFloorplanAnnotationGeometry( + geometry: FloorplanGeometry, + visibility: FloorplanAnnotationVisibility, + inheritedRole?: FloorplanAnnotationRole, +): FloorplanGeometry | null { + const role = readFloorplanGeometryMetadata(geometry).annotationRole ?? inheritedRole + if (role && !isAnnotationRoleVisible(role, visibility)) return null + if ( + !visibility.automaticDimensions && + role !== 'manual-dimension' && + (geometry.kind === 'dimension' || + geometry.kind === 'dimension-string' || + geometry.kind === 'dimension-label' || + geometry.kind === 'equal-spacing-badge') + ) { + return null + } + if (geometry.kind !== 'group') return geometry + + const children = geometry.children + .map((child) => filterFloorplanAnnotationGeometry(child, visibility, role)) + .filter((child): child is FloorplanGeometry => child !== null) + if (children.length === 0) return null + if (children.length === geometry.children.length) return geometry + return { ...geometry, children } +} + +function isAnnotationRoleVisible( + role: FloorplanAnnotationRole, + visibility: FloorplanAnnotationVisibility, +): boolean { + switch (role) { + case 'automatic-dimension': + return visibility.automaticDimensions + case 'manual-dimension': + return visibility.manualDimensions + case 'measurement': + return visibility.measurements + case 'opening-mark': + return visibility.openingMarks + case 'structural-grid': + case 'column-center': + return visibility.structuralGrids + case 'room-label': + return visibility.roomLabels + case 'stair-annotation': + return visibility.stairAnnotations + } +} diff --git a/packages/editor/src/lib/floorplan/drawing-coordination.test.ts b/packages/editor/src/lib/floorplan/drawing-coordination.test.ts new file mode 100644 index 00000000..7abbb384 --- /dev/null +++ b/packages/editor/src/lib/floorplan/drawing-coordination.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type ConstructionDrawingType, + nodeRegistry, + registerNode, +} from '@pascal-app/core' +import { z } from 'zod' +import { resolveNodeForDrawingType } from './drawing-coordination' +import { FLOORPLAN_NODE_EXTENSION_KEY } from './floorplan-extension' + +describe('resolveNodeForDrawingType', () => { + afterEach(() => nodeRegistry._reset()) + + test('dispatches drawing coordination through the registered extension', () => { + const node = { + id: 'drawing-test_main', + type: 'drawing-test', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + } as unknown as AnyNode + registerNode({ + kind: 'drawing-test', + schemaVersion: 1, + schema: z.object({ type: z.literal('drawing-test') }) as never, + category: 'utility', + defaults: () => ({}) as never, + extensions: { + [FLOORPLAN_NODE_EXTENSION_KEY]: { + resolveForDrawing: ({ drawingType }: { drawingType: ConstructionDrawingType }) => + drawingType === 'floor-plan' ? null : node, + }, + }, + } as never) + + expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBeNull() + expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'foundation-plan')).toBe(node) + }) + + test('leaves nodes without a drawing extension unchanged', () => { + const node = { id: 'unknown', type: 'unknown' } as unknown as AnyNode + expect(resolveNodeForDrawingType(node, { [node.id]: node }, 'floor-plan')).toBe(node) + }) +}) diff --git a/packages/editor/src/lib/floorplan/drawing-coordination.ts b/packages/editor/src/lib/floorplan/drawing-coordination.ts new file mode 100644 index 00000000..857b8b27 --- /dev/null +++ b/packages/editor/src/lib/floorplan/drawing-coordination.ts @@ -0,0 +1,13 @@ +import { type AnyNode, type ConstructionDrawingType, nodeRegistry } from '@pascal-app/core' +import { getFloorplanNodeExtension } from './floorplan-extension' + +export function resolveNodeForDrawingType( + node: AnyNode, + nodes: Record, + drawingType: ConstructionDrawingType, +): AnyNode | null { + const extension = getFloorplanNodeExtension(nodeRegistry.get(node.type)) + return extension?.resolveForDrawing + ? extension.resolveForDrawing({ node, nodes, drawingType }) + : node +} diff --git a/packages/editor/src/lib/floorplan/floorplan-export.test.ts b/packages/editor/src/lib/floorplan/floorplan-export.test.ts new file mode 100644 index 00000000..16df0641 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-export.test.ts @@ -0,0 +1,591 @@ +import { beforeAll, describe, expect, test } from 'bun:test' +import { + DrawingSheetNode, + type FloorplanGeometry, + nodeRegistry, + registerNode, +} from '@pascal-app/core' +import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer' +import { + filterFloorplanExportOverlay, + fitPlanToBox, + isFloorplanExportAnnotationGeometry, + partitionFloorplanExportOverlay, + pointsPerMeterForDrawingScale, + resolveDrawingSheetDocumentMarkers, + resolveDrawingSheetGeneralNotes, + resolveDrawingSheetKeyedNotes, + resolveFloorplanExportAnnotationVisibility, + resolveFloorplanExportNodeGeometry, + resolveFloorplanExportPlacement, + resolveFloorplanExportRotationDeg, + resolveFloorplanExportViewport, + resolveFloorplanExportViewState, + resolveFloorplanMeasurementSize, + resolveFloorplanPageLayout, + resolveFloorplanScreenUnitsPerPixel, + resolveGraphicScaleLength, + resolveSheetComposition, + resolveSheetExportLayout, + resolveSheetPageSetup, + rotateFloorplanExportBounds, +} from './floorplan-export' +import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension' + +const drawingSheetExtension: FloorplanNodeExtension = { + resolveDrawingSheet: ({ node, levelId, drawingType }) => + node.placedViews.some( + (view) => + (view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType, + ) + ? node + : null, +} + +beforeAll(() => { + if (nodeRegistry.has('drawing-sheet')) return + registerNode({ + kind: 'drawing-sheet', + schemaVersion: 1, + schema: DrawingSheetNode, + category: 'analysis', + defaults: () => ({}) as never, + capabilities: {}, + extensions: { + 'pascal:editor/floorplan': drawingSheetExtension, + }, + }) +}) + +describe('filterFloorplanExportOverlay', () => { + test('preserves value labels and removes editing handles', () => { + const label = { + kind: 'dimension-label', + appearance: 'outlined', + cx: 1, + cy: 0, + text: '2.00m', + angle: 0, + } satisfies FloorplanGeometry + const overlay = { + kind: 'group', + children: [ + label, + { + kind: 'endpoint-handle', + point: [0, 0], + state: 'idle', + affordance: 'move-measurement-vertex', + payload: { vertexIndex: 0 }, + }, + ], + } satisfies FloorplanGeometry + + expect(filterFloorplanExportOverlay(overlay)).toEqual({ + kind: 'group', + children: [label], + }) + }) + + test('preserves wall, door, and window shapes used as annotation obstacles', () => { + const fixedGeometry = { + kind: 'group', + children: [ + { + kind: 'polygon', + points: [ + [0, 0], + [4, 0], + [4, 0.2], + [0, 0.2], + ], + fill: '#374151', + stroke: '#1f2937', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }), + }, + { + kind: 'path', + d: 'M 1 0 A 1 1 0 0 1 2 1', + fill: 'none', + stroke: '#64748b', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), + }, + { + kind: 'line', + x1: 2.5, + y1: 0, + x2: 3.5, + y2: 0, + stroke: '#1f2937', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), + }, + { kind: 'move-handle', point: [2, 0.1] }, + ], + } satisfies FloorplanGeometry + + const { overlay } = splitFloorplanOverlay(fixedGeometry) + expect(overlay).not.toBeNull() + expect(filterFloorplanExportOverlay(overlay!)).toEqual({ + kind: 'group', + children: fixedGeometry.children.slice(0, 3), + transform: undefined, + }) + }) + + test('keeps structural obstacles in model bounds while leaving marks as annotations', () => { + const wall = { + kind: 'polygon', + points: [ + [0, 0], + [4, 0], + [4, 0.2], + [0, 0.2], + ], + fill: '#374151', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }), + } satisfies FloorplanGeometry + const openingMark = { + kind: 'group', + metadata: floorplanGeometryMetadata({ annotationRole: 'opening-mark' }), + children: [ + { + kind: 'rect', + x: 1, + y: 1, + width: 0.4, + height: 0.2, + fill: '#ffffff', + stroke: '#334155', + }, + { kind: 'text', x: 1.2, y: 1.1, text: 'W01', fontSize: 0.1, upright: true }, + ], + } satisfies FloorplanGeometry + + expect( + partitionFloorplanExportOverlay({ kind: 'group', children: [wall, openingMark] }), + ).toEqual({ + model: { kind: 'group', children: [wall], transform: undefined }, + annotations: { kind: 'group', children: [openingMark], transform: undefined }, + }) + }) + + test('moves automatic dimensions embedded in base wall geometry into the PDF annotation layer', () => { + const wall = { + kind: 'polygon', + points: [ + [0, 0], + [4, 0], + [4, 0.2], + [0, 0.2], + ], + fill: '#374151', + } satisfies FloorplanGeometry + const dimensions = { + kind: 'dimension-string', + segments: [{ start: [0, 0], end: [4, 0], text: '4m' }], + offsetNormal: [0, -1], + offsetDistance: 1, + extensionOvershoot: 0.12, + } satisfies FloorplanGeometry + + expect( + resolveFloorplanExportNodeGeometry( + { kind: 'group', children: [wall, dimensions] }, + null, + false, + ), + ).toEqual({ + model: { kind: 'group', children: [wall], transform: undefined }, + annotations: { kind: 'group', children: [dimensions], transform: undefined }, + }) + }) +}) + +describe('fitPlanToBox', () => { + test('preserves aspect ratio and centers the plan', () => { + expect(fitPlanToBox(20, 10, 10, 20, 400, 300)).toEqual({ + x: 10, + y: 70, + width: 400, + height: 200, + }) + }) +}) + +describe('floor plan export policy', () => { + test('uses the live floor-plan formatting profile for metric and imperial dimensions', () => { + expect(resolveFloorplanExportViewState('metric', 'millimeters')).toMatchObject({ + purpose: 'edit', + unit: 'metric', + metricNotation: 'millimeters', + }) + expect(resolveFloorplanExportViewState('imperial', 'meters')).toMatchObject({ + purpose: 'edit', + unit: 'imperial', + metricNotation: 'meters', + }) + }) + + test('fits an oversized plan inside the complete export viewport', () => { + const placement = resolveFloorplanExportPlacement(30, 20, 10, 20, 400, 300) + + expect(placement.x).toBe(10) + expect(placement.y).toBeCloseTo(36.67, 2) + expect(placement.width).toBe(400) + expect(placement.height).toBeCloseTo(266.67, 2) + expect(placement.x).toBeGreaterThanOrEqual(10) + expect(placement.y).toBeGreaterThanOrEqual(20) + expect(placement.x + placement.width).toBeLessThanOrEqual(410) + expect(placement.y + placement.height).toBeLessThanOrEqual(320) + }) + + test('exports the same annotation categories that are visible in the live view', () => { + const liveVisibility = { + automaticDimensions: true, + manualDimensions: false, + measurements: true, + openingMarks: true, + structuralGrids: false, + roomLabels: false, + stairAnnotations: true, + } + + expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility) + }) + + test('matches live screen sizing to the fitted export viewport', () => { + expect(resolveFloorplanScreenUnitsPerPixel(7, 4.5, 572, 463)).toBeCloseTo(0.012_237_762, 8) + }) + + test('keeps the export viewport anchored to the structural drawing bounds', () => { + expect(resolveFloorplanExportViewport({ x: -5, y: -6, width: 13, height: 13.5 })).toEqual({ + x: -7.7, + y: -8.7, + width: 18.4, + height: 18.9, + }) + }) + + test('fits the viewport around the rotated plan instead of clipping its corners', () => { + const bounds = rotateFloorplanExportBounds({ x: 0, y: 0, width: 10, height: 5 }, 90) + + expect(bounds.x).toBeCloseTo(-5, 8) + expect(bounds.y).toBeCloseTo(0, 8) + expect(bounds.width).toBeCloseTo(5, 8) + expect(bounds.height).toBeCloseTo(10, 8) + }) + + test('keeps annotation-only nodes out of primary model bounds', () => { + expect( + isFloorplanExportAnnotationGeometry({ + kind: 'group', + children: [], + metadata: { 'pascal:editor/floorplan': { annotationRole: 'measurement' } }, + }), + ).toBe(true) + expect( + isFloorplanExportAnnotationGeometry({ + kind: 'group', + children: [], + metadata: { 'pascal:editor/floorplan': { annotationRole: 'manual-dimension' } }, + }), + ).toBe(true) + expect(isFloorplanExportAnnotationGeometry({ kind: 'polygon', points: [] })).toBe(false) + }) + + test('matches the current floor-plan rotation instead of forcing north-up', () => { + expect(resolveFloorplanExportRotationDeg(Math.PI / 6, Math.PI / 2)).toBeCloseTo(60, 8) + }) +}) + +describe('pointsPerMeterForDrawingScale', () => { + test('converts metric ratios to plotted points per metre', () => { + expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4) + }) + + test('converts imperial architectural scales to plotted points per metre', () => { + expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4) + }) +}) + +describe('resolveFloorplanMeasurementSize', () => { + test('sizes the hidden SVG in screen pixels before resolving label collisions', () => { + expect( + resolveFloorplanMeasurementSize({ x: -2, y: -3, width: 18.4, height: 18.9 }, 0.024), + ).toEqual({ width: 18.4 / 0.024, height: 18.9 / 0.024 }) + }) +}) + +describe('resolveSheetExportLayout', () => { + test('reserves a plan viewport, side panel, and title block on one sheet page', () => { + expect(resolveSheetExportLayout(842, 595)).toEqual({ + planBox: { x: 36, y: 36, width: 572, height: 463 }, + sidePanel: { x: 626, y: 36, width: 180, height: 463 }, + titleBlock: { x: 36, y: 517, width: 770, height: 42 }, + }) + }) +}) + +describe('resolveFloorplanPageLayout', () => { + test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => { + expect(resolveFloorplanPageLayout(842, 595)).toEqual({ + planBox: { x: 36, y: 64, width: 770, height: 495 }, + }) + }) +}) + +describe('resolveGraphicScaleLength', () => { + test('chooses a model length that fits the available paper width', () => { + const scale = resolveGraphicScaleLength('1:50', 150) + + expect(scale.modelMeters).toBe(2) + expect(scale.widthPt).toBeCloseTo(113.39, 2) + expect(scale.label).toBe('2 m') + }) +}) + +describe('resolveSheetComposition', () => { + test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + sheetNumber: 'A1.1', + sheetTitle: 'Plans', + placedViews: [ + { + id: 'drawing-view_main', + levelId: 'level_main', + drawingType: 'floor-plan', + drawingNumber: '2', + title: 'Main Floor Plan', + scale: '1:50', + }, + ], + generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }], + keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }], + }) + + expect( + resolveSheetComposition( + { [sheet.id]: sheet }, + 'level_main', + 'Main Level', + 'floor-plan', + 'Floor plan', + '1/4"=1\'-0"', + ), + ).toMatchObject({ + sheetNumber: 'A1.1', + sheetTitle: 'Plans', + paperSize: 'arch-b', + orientation: 'landscape', + drawingNumber: '2', + viewTitle: 'Main Floor Plan', + scale: '1:50', + generalNotes: [{ number: 1, text: 'Verify all dimensions.' }], + keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }], + keyedNoteInstances: [], + }) + }) + + test('resolves reusable general note sets before sheet-local notes', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + generalNoteSetIds: ['sheet-note-set_project'], + generalNoteSets: [ + { + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }], + }, + ], + generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }], + }) + + expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([ + { number: 1, text: 'Coordinate with structural.' }, + { number: 2, text: 'Verify dimensions.' }, + ]) + }) + + test('reports duplicate reusable and sheet-local general notes', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + generalNoteSets: [ + { + id: 'sheet-note-set_project', + name: 'Project Notes', + notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }], + }, + ], + generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }], + }) + + expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([ + { + severity: 'warning', + message: + 'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.', + }, + ]) + }) + + test('derives keyed-note legends from repeated stable instances', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }], + keyedNoteDefinitions: [ + { id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' }, + { id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' }, + ], + keyedNoteInstances: [ + { + id: 'keyed-note-instance_patch-1', + definitionId: 'keyed-note_patch', + placedViewId: 'drawing-view_main', + position: [2, 3], + }, + { + id: 'keyed-note-instance_patch-2', + definitionId: 'keyed-note_patch', + placedViewId: 'drawing-view_main', + position: [4, 3], + }, + ], + keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }], + }) + + expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({ + legend: [{ key: 'A', text: 'Patch existing slab.' }], + instances: [ + { id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 }, + { id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 }, + ], + warnings: [], + }) + }) + + test('reports keyed-note instances with missing definitions', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + keyedNoteInstances: [ + { + id: 'keyed-note-instance_missing', + definitionId: 'keyed-note_missing', + position: [2, 3], + }, + ], + }) + + expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([ + { + severity: 'warning', + message: + 'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.', + }, + ]) + }) + + test('resolves scoped drawing sheet document markers', () => { + const sheet = DrawingSheetNode.parse({ + id: 'drawing-sheet_a101', + placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }], + 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], + ], + }, + { + id: 'sheet-marker_other-view', + kind: 'detail-reference', + label: '3', + placedViewId: 'drawing-view_other', + position: [5, 5], + }, + ], + }) + + expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([ + { + id: 'sheet-marker_wall-a', + kind: 'wall-tag', + label: 'W1', + title: '', + sheetReference: '', + drawingReference: '', + revisionId: '', + x: 2, + y: 3, + endX: null, + endY: null, + points: [], + }, + { + id: 'sheet-marker_revision-a', + kind: 'revision-cloud', + label: '1', + title: '', + sheetReference: '', + drawingReference: '', + revisionId: 'A', + x: 0.5, + y: 0.5, + endX: null, + endY: null, + points: [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + { x: 1, y: 2 }, + ], + }, + ]) + }) +}) + +describe('resolveSheetPageSetup', () => { + test('resolves supported paper sizes and orientation to page points', () => { + expect( + resolveSheetPageSetup({ + paperSize: 'arch-b', + orientation: 'landscape', + customPaperWidth: null, + customPaperHeight: null, + }), + ).toEqual({ width: 1296, height: 864, orientation: 'landscape' }) + + const a3 = resolveSheetPageSetup({ + paperSize: 'a3', + orientation: 'portrait', + customPaperWidth: null, + customPaperHeight: null, + }) + expect(a3.width).toBeCloseTo(841.89, 2) + expect(a3.height).toBeCloseTo(1190.55, 2) + }) + + test('uses custom paper dimensions in inches', () => { + expect( + resolveSheetPageSetup({ + paperSize: 'custom', + orientation: 'portrait', + customPaperWidth: 24, + customPaperHeight: 36, + }), + ).toEqual({ width: 1728, height: 2592, orientation: 'portrait' }) + }) +}) diff --git a/packages/editor/src/lib/floorplan/floorplan-export.tsx b/packages/editor/src/lib/floorplan/floorplan-export.tsx index a87b6c36..48449322 100644 --- a/packages/editor/src/lib/floorplan/floorplan-export.tsx +++ b/packages/editor/src/lib/floorplan/floorplan-export.tsx @@ -3,7 +3,14 @@ import { type AnyNode, type AnyNodeId, + type ConstructionDrawingType, + type DrawingSheetNode, + type DrawingSheetOrientation, + type DrawingSheetPaperSize, + type DrawingSheetScale, type FloorplanGeometry, + type FloorplanPalette, + type FloorplanPoint, type LiveNodeOverrides, nodeRegistry, resolveBuildingForLevel, @@ -13,14 +20,32 @@ import { useViewer } from '@pascal-app/viewer' import { createElement } from 'react' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' +import { resolveSvgAnnotationCollisions } from '../../components/editor-2d/renderers/floorplan-annotation-layout' import { FloorplanGeometryRenderer } from '../../components/editor-2d/renderers/floorplan-geometry-renderer' import { buildContext, + collectFloorplanLinkedLevelNodes, floorplanLayerRank, getFloorplanLevelData, isFloorplanNodeVisible, splitFloorplanOverlay, } from '../../components/editor-2d/renderers/floorplan-registry-layer' +import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view' +import useEditor from '../../store/use-editor' +import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility' +import { + type FloorplanAnnotationVisibility, + filterFloorplanAnnotationGeometry, +} from './annotation-visibility' +import { resolveNodeForDrawingType } from './drawing-coordination' +import { + type FloorplanMetricNotation, + type FloorplanSchedule, + getFloorplanNodeExtension, + readFloorplanGeometryMetadata, +} from './floorplan-extension' +import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document' +import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer' import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry' /** @@ -31,8 +56,9 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry' * a neutral `viewState` so nodes render in their default, unselected form. * Every level of the active building becomes its own page, titled with the * level's label, with the plan fit to the page (independent of the live - * pan/zoom). jsPDF + svg2pdf are dynamically imported so they only load when - * an export actually runs. + * pan/zoom). PDFKit is dynamically imported so it only loads when an export + * actually runs. Geometry and labels are emitted as native PDF vectors and + * text instead of being reinterpreted from browser SVG. * * `scope: 'structure'` keeps only `category === 'structure'` nodes (walls, * slabs, ceilings, doors, windows, stairs, columns, roofs…); `'full'` keeps @@ -41,37 +67,161 @@ import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry' export type FloorplanExportScope = 'full' | 'structure' const SVG_NS = 'http://www.w3.org/2000/svg' -/** Meters of margin around the plan bounds. */ -const PADDING_M = 1 +/** Minimum and proportional margin around the structural drawing bounds. */ +const MIN_PLAN_PADDING_M = 1 +const PLAN_PADDING_RATIO = 0.2 /** PDF page margin + title band, in pt. */ const PAGE_MARGIN_PT = 36 const TITLE_BAND_PT = 28 +const SHEET_GAP_PT = 18 +const SHEET_SIDE_PANEL_WIDTH_PT = 180 +const TITLE_BLOCK_HEIGHT_PT = 42 +const POINTS_PER_INCH = 72 +const METERS_PER_INCH = 0.0254 -// Neutral view state — no selection / hover / palette, so builders emit their -// default appearance (the core palette only carries selection/handle colors). +const NEUTRAL_PALETTE: FloorplanPalette = { + selectedStroke: '#334155', + selectedFill: '#ffffff', + selectedHatch: '#334155', + wallHoverStroke: '#334155', + endpointHandleFill: '#ffffff', + endpointHandleStroke: '#334155', + endpointHandleHoverStroke: '#334155', + endpointHandleActiveFill: '#334155', + endpointHandleActiveStroke: '#334155', + curveHandleFill: '#ffffff', + curveHandleStroke: '#334155', + curveHandleHoverStroke: '#334155', + measurementStroke: '#334155', + measurementLabelBackground: '#ffffff', + measurementLabelText: '#111827', +} + +// Neutral view state — no selection / hover. A neutral palette keeps the +// full view state (including unit preference) available to node builders. const NEUTRAL_VIEW_STATE = { selected: false, + purpose: 'edit', highlighted: false, hovered: false, moving: false, - palette: undefined, + palette: NEUTRAL_PALETTE, } as const +export function resolveFloorplanExportViewState( + unit: 'metric' | 'imperial', + metricNotation: FloorplanMetricNotation, +) { + return { ...NEUTRAL_VIEW_STATE, unit, metricNotation } +} + type ExportLevel = { id: AnyNodeId; label: string } +type ExportGeometry = { + id: AnyNodeId + model: FloorplanGeometry | null + annotations: FloorplanGeometry | null +} + +type SheetComposition = { + sheetNumber: string + sheetTitle: string + paperSize: DrawingSheetPaperSize + orientation: DrawingSheetOrientation + customPaperWidth: number | null + customPaperHeight: number | null + drawingNumber: string + viewTitle: string + drawingLabel: string + scale: DrawingSheetScale + generalNotes: { number: number; text: string }[] + keyedNoteLegend: { key: string; text: string }[] + keyedNoteInstances: { id: string; key: string; x: number; y: number }[] + documentMarkers: ResolvedDocumentMarker[] + preflightIssues: SheetPreflightIssue[] +} + +export type SheetExportLayout = { + planBox: { x: number; y: number; width: number; height: number } + sidePanel: { x: number; y: number; width: number; height: number } + titleBlock: { x: number; y: number; width: number; height: number } +} + +export type FloorplanPageLayout = { + planBox: { x: number; y: number; width: number; height: number } +} + +type ScheduleDrawResult = { + drawnSchedules: number + overflowSchedules: FloorplanSchedule[] +} + +export type SheetPageSetup = { + width: number + height: number + orientation: DrawingSheetOrientation +} + +export type SheetPreflightIssue = { + severity: 'warning' + message: string +} + +type ResolvedGeneralNotes = { + notes: { number: number; text: string }[] + duplicateWarnings: SheetPreflightIssue[] +} + +type ResolvedKeyedNotes = { + legend: { key: string; text: string }[] + instances: { id: string; key: string; x: number; y: number }[] + warnings: SheetPreflightIssue[] +} + +type ResolvedDocumentMarker = { + id: string + kind: string + label: string + title: string + sheetReference: string + drawingReference: string + revisionId: string + x: number + y: number + endX: number | null + endY: number | null + points: { x: number; y: number }[] +} + export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise { const nodes = useScene.getState().nodes - const unit = useViewer.getState().unit + const viewer = useViewer.getState() + const unit = viewer.unit + const metricNotation = viewer.metricNotation + const annotationVisibility = resolveFloorplanExportAnnotationVisibility( + useFloorplanAnnotationVisibility.getState().visibility, + ) + const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth + const drawingType = useDrawingView.getState().drawingType + const annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides + const drawingLabel = + DRAWING_TYPE_OPTIONS.find((option) => option.id === drawingType)?.label ?? 'Floor plan' const levels = resolveExportLevels(nodes) if (levels.length === 0) { console.warn('[floorplan-export] no level to export') return } - const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')]) - const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4' }) - const pageW = doc.internal.pageSize.getWidth() - const pageH = doc.internal.pageSize.getHeight() + const defaultPageSetup = resolveSheetPageSetup({ + paperSize: 'a4', + orientation: 'landscape', + customPaperWidth: null, + customPaperHeight: null, + }) + const { doc, save } = await createFloorplanPdfDocument([ + defaultPageSetup.width, + defaultPageSetup.height, + ]) const host = document.createElement('div') host.style.cssText = @@ -81,53 +231,89 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise) - const building = buildingId ? nodes[buildingId] : undefined - const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0 - const rotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - (buildingRotationY * 180) / Math.PI + if (geometries.length > 0) { + // Preserve the live floor-plan orientation rather than forcing north-up. + const buildingId = resolveBuildingForLevel(level.id, nodes as Record) + const building = buildingId ? nodes[buildingId] : undefined + const buildingRotationY = building?.type === 'building' ? (building.rotation[1] ?? 0) : 0 + const rotationDeg = resolveFloorplanExportRotationDeg(buildingRotationY, navigationAzimuth) - const mounted = await mountFloorplanSvg(host, geometries, rotationDeg) - if (!mounted) continue + const mounted = await mountFloorplanSvg( + host, + geometries, + rotationDeg, + annotationLayoutOverrides, + ) + if (mounted) { + try { + doc.addPage([pageSetup.width, pageSetup.height], pageSetup.orientation) + pageCount++ - try { - if (pageCount > 0) doc.addPage() - pageCount++ - - doc.setFontSize(14) - doc.text(level.label, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12) - - // Fit the plan into the page below the title band, preserving aspect. - const boxX = PAGE_MARGIN_PT - const boxY = PAGE_MARGIN_PT + TITLE_BAND_PT - const boxW = pageW - PAGE_MARGIN_PT * 2 - const boxH = pageH - PAGE_MARGIN_PT * 2 - TITLE_BAND_PT - const aspect = mounted.width / mounted.height - let w = boxW - let h = w / aspect - if (h > boxH) { - h = boxH - w = h * aspect + const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel( + mounted.width, + mounted.height, + layout.planBox.width, + layout.planBox.height, + ) + await mounted.setScreenUnitsPerPixel(screenUnitsPerPixel) + const fitted = resolveFloorplanExportPlacement( + mounted.width, + mounted.height, + layout.planBox.x, + layout.planBox.y, + layout.planBox.width, + layout.planBox.height, + ) + drawFloorplanPageHeader(doc, level.label, drawingLabel) + const model = combineGeometryList(geometries.map((geometry) => geometry.model)) + if (model) { + await renderFloorplanGeometryToPdfKit(doc, model, { + annotationLayer: false, + placement: fitted, + rotationDeg, + viewport: mounted.viewport, + }) + } + const annotations = combineGeometryList( + geometries.map((geometry) => geometry.annotations), + ) + if (annotations) { + await renderFloorplanGeometryToPdfKit(doc, annotations, { + annotationLabelShifts: mounted.annotationLabelShifts, + annotationLayer: true, + placement: fitted, + rotationDeg, + viewport: mounted.viewport, + }) + } + } finally { + mounted.cleanup() + } } - const x = boxX + (boxW - w) / 2 - const y = boxY + (boxH - h) / 2 + } - // svg2pdf doesn't honour `vector-effect: non-scaling-stroke` (which - // many builders use to keep door/window/stair line weights constant - // on screen). Left as-is, those pixel-sized widths render as - // metre-wide strokes — huge grey blobs. Convert them to the real-unit - // width that lands at the intended point weight once svg2pdf scales - // the plan onto the page. - inlineNonScalingStrokes(mounted.svg, w / mounted.width) - - await svg2pdf(mounted.svg, doc, { x, y, width: w, height: h }) - } finally { - mounted.cleanup() + if (scheduleOverflow.length > 0) { + pageCount = drawFloorplanSchedulePages(doc, level.label, scheduleOverflow, pageCount) } } @@ -137,24 +323,978 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise, + levelId: AnyNodeId, + unit: 'metric' | 'imperial', +): FloorplanSchedule[] { + const siblingsByType = new Map() + const visit = (id: AnyNodeId) => { + const node = nodes[id] + if (!node) return + if (node.visible !== false) { + const siblings = siblingsByType.get(node.type) + if (siblings) siblings.push(node) + else siblingsByType.set(node.type, [node]) + } + const children = (node as { children?: AnyNodeId[] }).children + if (Array.isArray(children)) for (const childId of children) visit(childId) + } + visit(levelId) + + const schedules: FloorplanSchedule[] = [] + for (const [kind, definition] of nodeRegistry.entries()) { + const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule + if (!scheduleContribution) continue + const siblings = siblingsByType.get(kind) ?? [] + const schedule = scheduleContribution({ siblings, nodes, levelId, unit }) + if (schedule && schedule.rows.length > 0) schedules.push(schedule) + } + return schedules +} + +export function resolveSheetComposition( + nodes: Record, + levelId: AnyNodeId, + levelLabel: string, + drawingType: ConstructionDrawingType, + drawingLabel: string, + fallbackScale: DrawingSheetScale, +): SheetComposition { + const sheet = findDrawingSheetForLevel(nodes, levelId, drawingType) + const placedView = sheet?.placedViews.find( + (view) => + (view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType, + ) + const generalNotes = sheet + ? resolveDrawingSheetGeneralNotes(sheet) + : { notes: [], duplicateWarnings: [] } + const keyedNotes = sheet + ? resolveDrawingSheetKeyedNotes(sheet, placedView?.id ?? null) + : { legend: [], instances: [], warnings: [] } + const documentMarkers = sheet + ? resolveDrawingSheetDocumentMarkers(sheet, placedView?.id ?? null) + : [] + return { + sheetNumber: sheet?.sheetNumber ?? 'A1.0', + sheetTitle: sheet?.sheetTitle ?? drawingLabel, + paperSize: sheet?.paperSize ?? 'a4', + orientation: sheet?.orientation ?? 'landscape', + customPaperWidth: sheet?.customPaperWidth ?? null, + customPaperHeight: sheet?.customPaperHeight ?? null, + drawingNumber: placedView?.drawingNumber ?? '1', + viewTitle: placedView?.title ?? `${levelLabel} ${drawingLabel}`, + drawingLabel, + scale: placedView?.scale ?? fallbackScale, + generalNotes: generalNotes.notes, + keyedNoteLegend: keyedNotes.legend, + keyedNoteInstances: keyedNotes.instances, + documentMarkers, + preflightIssues: [...generalNotes.duplicateWarnings, ...keyedNotes.warnings], + } +} + +export function resolveDrawingSheetGeneralNotes(sheet: DrawingSheetNode): ResolvedGeneralNotes { + const generalNoteSets = sheet.generalNoteSets ?? [] + const generalNoteSetIds = sheet.generalNoteSetIds ?? [] + const sheetNotes = sheet.generalNotes ?? [] + const selectedSetIds = + generalNoteSetIds.length > 0 + ? new Set(generalNoteSetIds) + : new Set(generalNoteSets.map((set) => set.id)) + const noteSources = [ + ...generalNoteSets + .filter((set) => selectedSetIds.has(set.id)) + .flatMap((set) => set.notes.map((note) => ({ text: note.text, source: set.name }))), + ...sheetNotes.map((note) => ({ text: note.text, source: 'sheet' })), + ] + const notes = noteSources.map((note, index) => ({ number: index + 1, text: note.text })) + const duplicateWarnings: SheetPreflightIssue[] = [] + const seen = new Map }>() + for (const note of noteSources) { + const key = normalizeGeneralNoteText(note.text) + const existing = seen.get(key) + if (existing) { + existing.count += 1 + existing.sources.add(note.source) + continue + } + seen.set(key, { text: note.text, count: 1, sources: new Set([note.source]) }) + } + for (const duplicate of seen.values()) { + if (duplicate.count < 2) continue + duplicateWarnings.push({ + severity: 'warning', + message: `Duplicate general note: "${duplicate.text}" appears in ${[ + ...duplicate.sources, + ].join(' and ')}.`, + }) + } + return { notes, duplicateWarnings } +} + +function normalizeGeneralNoteText(text: string): string { + return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase() +} + +export function resolveDrawingSheetKeyedNotes( + sheet: DrawingSheetNode, + placedViewId: string | null = null, +): ResolvedKeyedNotes { + const definitions = sheet.keyedNoteDefinitions ?? [] + const instances = sheet.keyedNoteInstances ?? [] + const definitionById = new Map(definitions.map((definition) => [definition.id, definition])) + const scopedInstances = instances.filter( + (instance) => instance.placedViewId === null || instance.placedViewId === placedViewId, + ) + const warnings: SheetPreflightIssue[] = [] + const usedDefinitions = new Map() + const resolvedInstances: ResolvedKeyedNotes['instances'] = [] + + for (const instance of scopedInstances) { + const definition = definitionById.get(instance.definitionId) + if (!definition) { + warnings.push({ + severity: 'warning', + message: `Keyed-note symbol ${instance.id} references missing definition ${instance.definitionId}.`, + }) + continue + } + usedDefinitions.set(definition.id, { key: definition.key, text: definition.text }) + resolvedInstances.push({ + id: instance.id, + key: definition.key, + x: instance.position[0], + y: instance.position[1], + }) + } + + const derivedLegend = [...usedDefinitions.values()].sort((left, right) => + left.key.localeCompare(right.key, undefined, { numeric: true }), + ) + return { + legend: derivedLegend.length > 0 ? derivedLegend : (sheet.keyedNoteLegend ?? []), + instances: resolvedInstances, + warnings, + } +} + +export function resolveDrawingSheetDocumentMarkers( + sheet: DrawingSheetNode, + placedViewId: string | null = null, +): ResolvedDocumentMarker[] { + return (sheet.documentMarkers ?? []) + .filter((marker) => marker.placedViewId === null || marker.placedViewId === placedViewId) + .map((marker) => ({ + id: marker.id, + kind: marker.kind, + label: marker.label, + title: marker.title, + sheetReference: marker.sheetReference, + drawingReference: marker.drawingReference, + revisionId: marker.revisionId, + x: marker.position[0], + y: marker.position[1], + endX: marker.endPosition?.[0] ?? null, + endY: marker.endPosition?.[1] ?? null, + points: marker.points.map(([x, y]) => ({ x, y })), + })) +} + +export function resolveSheetPageSetup( + sheet: Pick< + SheetComposition, + 'paperSize' | 'orientation' | 'customPaperWidth' | 'customPaperHeight' + >, +): SheetPageSetup { + const base = paperSizePoints(sheet.paperSize, sheet.customPaperWidth, sheet.customPaperHeight) + const [width, height] = + sheet.orientation === 'landscape' + ? [Math.max(base.width, base.height), Math.min(base.width, base.height)] + : [Math.min(base.width, base.height), Math.max(base.width, base.height)] + return { width, height, orientation: sheet.orientation } +} + +function paperSizePoints( + paperSize: DrawingSheetPaperSize, + customPaperWidth: number | null, + customPaperHeight: number | null, +): { width: number; height: number } { + switch (paperSize) { + case 'letter': + return inchesToPoints(8.5, 11) + case 'tabloid': + return inchesToPoints(11, 17) + case 'arch-a': + return inchesToPoints(9, 12) + case 'arch-b': + return inchesToPoints(12, 18) + case 'arch-c': + return inchesToPoints(18, 24) + case 'a3': + return millimetersToPoints(297, 420) + case 'custom': + return inchesToPoints(customPaperWidth ?? 18, customPaperHeight ?? 12) + case 'a4': + return millimetersToPoints(210, 297) + } +} + +function inchesToPoints(width: number, height: number): { width: number; height: number } { + return { width: width * POINTS_PER_INCH, height: height * POINTS_PER_INCH } +} + +function millimetersToPoints(width: number, height: number): { width: number; height: number } { + return inchesToPoints(width / 25.4, height / 25.4) +} + +function findDrawingSheetForLevel( + nodes: Record, + levelId: AnyNodeId, + drawingType: ConstructionDrawingType, +): DrawingSheetNode | null { + for (const node of Object.values(nodes)) { + const resolveDrawingSheet = getFloorplanNodeExtension( + nodeRegistry.get(node.type), + )?.resolveDrawingSheet + const sheet = resolveDrawingSheet?.({ node: node as never, levelId, drawingType }) + if (sheet) return sheet + } + return null +} + +export function resolveSheetExportLayout(pageWidth: number, pageHeight: number): SheetExportLayout { + const contentX = PAGE_MARGIN_PT + const contentY = PAGE_MARGIN_PT + const contentWidth = pageWidth - PAGE_MARGIN_PT * 2 + const contentHeight = pageHeight - PAGE_MARGIN_PT * 2 + const titleBlock = { + x: contentX, + y: contentY + contentHeight - TITLE_BLOCK_HEIGHT_PT, + width: contentWidth, + height: TITLE_BLOCK_HEIGHT_PT, + } + const upperHeight = contentHeight - TITLE_BLOCK_HEIGHT_PT - SHEET_GAP_PT + const sidePanel = { + x: contentX + contentWidth - SHEET_SIDE_PANEL_WIDTH_PT, + y: contentY, + width: SHEET_SIDE_PANEL_WIDTH_PT, + height: upperHeight, + } + return { + planBox: { + x: contentX, + y: contentY, + width: contentWidth - SHEET_SIDE_PANEL_WIDTH_PT - SHEET_GAP_PT, + height: upperHeight, + }, + sidePanel, + titleBlock, + } +} + +export function resolveFloorplanPageLayout( + pageWidth: number, + pageHeight: number, +): FloorplanPageLayout { + const planY = PAGE_MARGIN_PT + TITLE_BAND_PT + return { + planBox: { + x: PAGE_MARGIN_PT, + y: planY, + width: pageWidth - PAGE_MARGIN_PT * 2, + height: pageHeight - planY - PAGE_MARGIN_PT, + }, + } +} + +function drawFloorplanPageHeader( + doc: FloorplanPdfDocument, + levelLabel: string, + drawingLabel: string, +): void { + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + doc.text(`${levelLabel} - ${drawingLabel}`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12) +} + +function drawFloorplanSchedulePages( + doc: FloorplanPdfDocument, + levelLabel: string, + schedules: readonly FloorplanSchedule[], + initialPageCount: number, +): number { + const pageW = doc.internal.pageSize.getWidth() + const pageH = doc.internal.pageSize.getHeight() + const tableWidth = pageW - PAGE_MARGIN_PT * 2 + const bottom = pageH - PAGE_MARGIN_PT + const headerHeight = 22 + const rowHeight = 20 + let pageCount = initialPageCount + let y = 0 + + const startPage = () => { + doc.addPage([pageW, pageH]) + pageCount++ + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(14) + doc.setLineWidth(0.5) + doc.text(`${levelLabel} - Construction Schedules`, PAGE_MARGIN_PT, PAGE_MARGIN_PT + 12) + y = PAGE_MARGIN_PT + TITLE_BAND_PT + 8 + } + + const drawHeader = (schedule: FloorplanSchedule, continued: boolean) => { + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(11) + doc.text(`${schedule.title}${continued ? ' (CONTINUED)' : ''}`, PAGE_MARGIN_PT, y + 11) + y += 18 + doc.setFillColor('#334155') + doc.rect(PAGE_MARGIN_PT, y, tableWidth, headerHeight, 'F') + doc.setTextColor('#ffffff') + doc.setFontSize(8) + const widths = scheduleColumnWidths(schedule, tableWidth) + doc.setDrawColor('#64748b') + drawScheduleColumnDividers(doc, widths, y, headerHeight) + let x = PAGE_MARGIN_PT + schedule.columns.forEach((column, index) => { + const width = widths[index] ?? 0 + doc.text(column.label, x + 4, y + 14, { maxWidth: Math.max(0, width - 8) }) + x += width + }) + y += headerHeight + } + + startPage() + for (const schedule of schedules) { + const issueHeight = (schedule.issues?.length ?? 0) * 13 + const minimumTableHeight = 18 + issueHeight + headerHeight + rowHeight + if (y + minimumTableHeight > bottom) startPage() + + if (schedule.issues?.length) { + doc.setTextColor('#b45309') + doc.setFont('helvetica', 'normal') + doc.setFontSize(8) + for (const issue of schedule.issues) { + doc.text(`WARNING: ${issue}`, PAGE_MARGIN_PT, y + 9) + y += 13 + } + } + + drawHeader(schedule, false) + const widths = scheduleColumnWidths(schedule, tableWidth) + schedule.rows.forEach((row, rowIndex) => { + if (y + rowHeight > bottom) { + startPage() + drawHeader(schedule, true) + } + if (rowIndex % 2 === 1) { + doc.setFillColor('#f1f5f9') + doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight, 'F') + } + doc.setDrawColor('#cbd5e1') + doc.rect(PAGE_MARGIN_PT, y, tableWidth, rowHeight) + drawScheduleColumnDividers(doc, widths, y, rowHeight) + doc.setTextColor('#111827') + doc.setFont('helvetica', 'normal') + doc.setFontSize(8) + let x = PAGE_MARGIN_PT + schedule.columns.forEach((column, columnIndex) => { + const width = widths[columnIndex] ?? 0 + const text = truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, width - 8)) + doc.text(text, x + 4, y + 13) + x += width + }) + y += rowHeight + }) + y += 20 + } + + return pageCount +} + +function scheduleColumnWidths(schedule: FloorplanSchedule, tableWidth: number): number[] { + const totalWeight = schedule.columns.reduce((sum, column) => sum + (column.weight ?? 1), 0) + return schedule.columns.map((column) => (tableWidth * (column.weight ?? 1)) / totalWeight) +} + +function drawScheduleColumnDividers( + doc: FloorplanPdfDocument, + widths: readonly number[], + y: number, + height: number, +) { + let x = PAGE_MARGIN_PT + for (const width of widths.slice(0, -1)) { + x += width + doc.line(x, y, x, y + height) + } +} + +function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: number): string { + if (doc.getTextWidth(value) <= maxWidth) return value + let truncated = value + while (truncated.length > 0 && doc.getTextWidth(`${truncated}...`) > maxWidth) { + truncated = truncated.slice(0, -1) + } + return `${truncated}...` +} + +function drawSheetChrome( + doc: FloorplanPdfDocument, + layout: SheetExportLayout, + composition: SheetComposition, + schedules: readonly FloorplanSchedule[], +): ScheduleDrawResult { + doc.setDrawColor('#0f172a') + doc.setLineWidth(0.6) + doc.rect( + PAGE_MARGIN_PT, + PAGE_MARGIN_PT, + doc.internal.pageSize.getWidth() - PAGE_MARGIN_PT * 2, + doc.internal.pageSize.getHeight() - PAGE_MARGIN_PT * 2, + ) + doc.setDrawColor('#cbd5e1') + doc.setLineWidth(0.4) + doc.rect(layout.planBox.x, layout.planBox.y, layout.planBox.width, layout.planBox.height) + doc.rect(layout.sidePanel.x, layout.sidePanel.y, layout.sidePanel.width, layout.sidePanel.height) + doc.rect( + layout.titleBlock.x, + layout.titleBlock.y, + layout.titleBlock.width, + layout.titleBlock.height, + ) + + drawSheetTitleBlock(doc, layout, composition) + drawNorthArrow(doc, layout.planBox.x + layout.planBox.width - 26, layout.planBox.y + 38) + drawGraphicScale(doc, layout.planBox.x + 18, layout.planBox.y + layout.planBox.height - 22, { + scale: composition.scale, + maxWidth: Math.min(150, layout.planBox.width * 0.3), + }) + drawSheetDocumentMarkers(doc, composition) + drawKeyedNoteSymbols(doc, composition) + return drawSheetSidePanel(doc, layout.sidePanel, composition, schedules) +} + +function drawSheetDocumentMarkers(doc: FloorplanPdfDocument, composition: SheetComposition): void { + if (composition.documentMarkers.length === 0) return + doc.setDrawColor('#111827') + doc.setTextColor('#111827') + doc.setLineWidth(0.7) + doc.setFont('helvetica', 'bold') + doc.setFontSize(7) + for (const marker of composition.documentMarkers) { + const x = marker.x * 72 + const y = marker.y * 72 + const end = + marker.endX !== null && marker.endY !== null + ? { x: marker.endX * 72, y: marker.endY * 72 } + : null + switch (marker.kind) { + case 'wall-tag': + case 'glazing-tag': + case 'assembly-tag': + drawTagMarker(doc, marker, x, y) + break + case 'section-callout': + case 'elevation-callout': + case 'detail-reference': + drawCalloutMarker(doc, marker, x, y, end) + break + case 'delta-marker': + drawDeltaMarker(doc, marker, x, y) + break + case 'revision-cloud': + drawRevisionCloudMarker(doc, marker, x, y) + break + } + } +} + +function drawTagMarker( + doc: FloorplanPdfDocument, + marker: ResolvedDocumentMarker, + x: number, + y: number, +) { + const width = Math.max(20, marker.label.length * 5 + 10) + const height = 14 + if (marker.kind === 'glazing-tag') { + doc.roundedRect(x - width / 2, y - height / 2, width, height, 2, 2) + } else if (marker.kind === 'assembly-tag') { + doc.rect(x - width / 2, y - height / 2, width, height) + } else { + doc.circle(x, y, Math.max(7, width / 2)) + } + doc.text(marker.label, x, y + 2.4, { align: 'center' }) +} + +function drawCalloutMarker( + doc: FloorplanPdfDocument, + marker: ResolvedDocumentMarker, + x: number, + y: number, + end: { x: number; y: number } | null, +) { + if (end) doc.line(x, y, end.x, end.y) + doc.circle(x, y, 8) + doc.line(x - 8, y, x + 8, y) + doc.text(marker.label, x, y - 1.8, { align: 'center' }) + const reference = [marker.drawingReference, marker.sheetReference].filter(Boolean).join('/') + if (reference) { + doc.setFont('helvetica', 'normal') + doc.text(reference, x, y + 6, { align: 'center' }) + doc.setFont('helvetica', 'bold') + } +} + +function drawDeltaMarker( + doc: FloorplanPdfDocument, + marker: ResolvedDocumentMarker, + x: number, + y: number, +) { + const radius = 8 + const points = [ + [x, y - radius], + [x + radius * 0.87, y + radius / 2], + [x - radius * 0.87, y + radius / 2], + ] as const + doc.triangle(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1]) + doc.text(marker.revisionId || marker.label, x, y + 3, { align: 'center' }) +} + +function drawRevisionCloudMarker( + doc: FloorplanPdfDocument, + marker: ResolvedDocumentMarker, + x: number, + y: number, +) { + const points: [number, number][] | null = + marker.points.length >= 3 ? marker.points.map((point) => [point.x * 72, point.y * 72]) : null + if (points) { + for (let index = 0; index < points.length; index += 1) { + const current = points[index]! + const next = points[(index + 1) % points.length]! + const [x1, y1] = current + const [x2, y2] = next + doc.line(x1, y1, x2, y2) + } + } else { + doc.roundedRect(x - 28, y - 16, 56, 32, 8, 8) + } + if (marker.revisionId) drawDeltaMarker(doc, marker, x, y) +} + +function drawKeyedNoteSymbols(doc: FloorplanPdfDocument, composition: SheetComposition): void { + if (composition.keyedNoteInstances.length === 0) return + doc.setDrawColor('#111827') + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(7) + for (const instance of composition.keyedNoteInstances) { + const x = instance.x * 72 + const y = instance.y * 72 + doc.circle(x, y, 6) + doc.text(instance.key, x, y + 2.4, { align: 'center' }) + } +} + +function drawSheetTitleBlock( + doc: FloorplanPdfDocument, + layout: SheetExportLayout, + composition: SheetComposition, +) { + const title = layout.titleBlock + const sheetNumberWidth = 86 + const drawingRefWidth = 72 + doc.setDrawColor('#cbd5e1') + doc.line( + title.x + title.width - sheetNumberWidth, + title.y, + title.x + title.width - sheetNumberWidth, + title.y + title.height, + ) + doc.line( + title.x + title.width - sheetNumberWidth - drawingRefWidth, + title.y, + title.x + title.width - sheetNumberWidth - drawingRefWidth, + title.y + title.height, + ) + + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(12) + doc.text(composition.viewTitle.toLocaleUpperCase(), title.x + 10, title.y + 16) + doc.setFont('helvetica', 'normal') + doc.setFontSize(8) + doc.text(`Scale: ${formatDrawingScaleLabel(composition.scale)}`, title.x + 10, title.y + 30) + doc.text( + `Drawing: ${composition.drawingNumber}`, + title.x + title.width - sheetNumberWidth - drawingRefWidth + 10, + title.y + 16, + ) + doc.text( + composition.drawingLabel, + title.x + title.width - sheetNumberWidth - drawingRefWidth + 10, + title.y + 30, + ) + doc.setFont('helvetica', 'bold') + doc.setFontSize(15) + doc.text(composition.sheetNumber, title.x + title.width - sheetNumberWidth + 10, title.y + 25) + doc.setFontSize(7) + doc.text( + composition.sheetTitle.toLocaleUpperCase(), + title.x + title.width - sheetNumberWidth + 10, + title.y + 36, + { + maxWidth: sheetNumberWidth - 20, + }, + ) +} + +function drawNorthArrow(doc: FloorplanPdfDocument, x: number, y: number) { + doc.setDrawColor('#111827') + doc.setFillColor('#111827') + doc.setLineWidth(0.6) + doc.triangle(x, y - 24, x - 6, y - 5, x + 6, y - 5, 'F') + doc.line(x, y - 5, x, y + 14) + doc.setFont('helvetica', 'bold') + doc.setFontSize(9) + doc.text('N', x, y - 28, { align: 'center' }) +} + +export function resolveGraphicScaleLength( + scale: DrawingSheetScale, + maxWidthPt: number, +): { modelMeters: number; widthPt: number; label: string } { + const pointsPerMeter = pointsPerMeterForDrawingScale(scale) + const maxMeters = Math.max(0.1, maxWidthPt / pointsPerMeter) + const candidates = [50, 20, 10, 5, 2, 1, 0.5, 0.25] + const modelMeters = candidates.find((candidate) => candidate <= maxMeters) ?? 0.1 + return { + modelMeters, + widthPt: modelMeters * pointsPerMeter, + label: `${modelMeters >= 1 ? modelMeters : modelMeters * 1000}${modelMeters >= 1 ? ' m' : ' mm'}`, + } +} + +function drawGraphicScale( + doc: FloorplanPdfDocument, + x: number, + y: number, + options: { scale: DrawingSheetScale; maxWidth: number }, +) { + const resolved = resolveGraphicScaleLength(options.scale, options.maxWidth) + const half = resolved.widthPt / 2 + doc.setDrawColor('#111827') + doc.setFillColor('#111827') + doc.setLineWidth(0.6) + doc.rect(x, y, half, 5, 'F') + doc.rect(x + half, y, half, 5) + doc.setFont('helvetica', 'normal') + doc.setFontSize(7) + doc.text('0', x, y + 15, { align: 'center' }) + doc.text(resolved.label, x + resolved.widthPt, y + 15, { align: 'center' }) + doc.text(formatDrawingScaleLabel(options.scale), x + resolved.widthPt / 2, y - 4, { + align: 'center', + }) +} + +function drawSheetSidePanel( + doc: FloorplanPdfDocument, + panel: SheetExportLayout['sidePanel'], + composition: SheetComposition, + schedules: readonly FloorplanSchedule[], +): ScheduleDrawResult { + let y = panel.y + 12 + const left = panel.x + 8 + const width = panel.width - 16 + const bottom = panel.y + panel.height - 8 + + y = drawSheetNotes(doc, 'GENERAL NOTES', composition.generalNotes, left, y, width, bottom) + y = drawKeyedNoteLegend(doc, composition, left, y + 8, width, bottom) + return drawInlineSchedules(doc, schedules, left, y + 8, width, bottom) +} + +function drawSheetNotes( + doc: FloorplanPdfDocument, + title: string, + notes: readonly { number: number; text: string }[], + x: number, + y: number, + width: number, + bottom: number, +) { + if (notes.length === 0) return y + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(8) + doc.text(title, x, y) + y += 9 + doc.setFont('helvetica', 'normal') + doc.setFontSize(7) + for (const note of notes) { + const lines = doc.splitTextToSize(`${note.number}. ${note.text}`, width) + if (y + lines.length * 8 > bottom) break + doc.text(lines, x, y) + y += lines.length * 8 + 3 + } + return y +} + +function drawKeyedNoteLegend( + doc: FloorplanPdfDocument, + composition: SheetComposition, + x: number, + y: number, + width: number, + bottom: number, +) { + if (composition.keyedNoteLegend.length === 0) return y + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(8) + doc.text('KEYED NOTES', x, y) + y += 9 + doc.setFont('helvetica', 'normal') + doc.setFontSize(7) + for (const note of composition.keyedNoteLegend) { + const lines = doc.splitTextToSize(`${note.key}. ${note.text}`, width) + if (y + lines.length * 8 > bottom) break + doc.text(lines, x, y) + y += lines.length * 8 + 3 + } + return y +} + +function drawInlineSchedules( + doc: FloorplanPdfDocument, + schedules: readonly FloorplanSchedule[], + x: number, + y: number, + width: number, + bottom: number, +): ScheduleDrawResult { + const overflowSchedules: FloorplanSchedule[] = [] + let drawnSchedules = 0 + for (const schedule of schedules) { + const rowHeight = 12 + const tableHeight = 18 + rowHeight * Math.min(schedule.rows.length, 6) + if (y + tableHeight > bottom) { + overflowSchedules.push(schedule) + continue + } + drawnSchedules++ + doc.setTextColor('#111827') + doc.setFont('helvetica', 'bold') + doc.setFontSize(8) + doc.text(schedule.title.toLocaleUpperCase(), x, y) + y += 10 + const widths = scheduleColumnWidths(schedule, width) + doc.setFillColor('#334155') + doc.rect(x, y, width, rowHeight, 'F') + doc.setTextColor('#ffffff') + doc.setFontSize(6) + let colX = x + schedule.columns.forEach((column, index) => { + doc.text(column.label, colX + 2, y + 8, { maxWidth: Math.max(0, (widths[index] ?? 0) - 4) }) + colX += widths[index] ?? 0 + }) + y += rowHeight + doc.setFont('helvetica', 'normal') + doc.setTextColor('#111827') + const inlineRows = schedule.rows.slice(0, 6) + for (const row of inlineRows) { + colX = x + schedule.columns.forEach((column, index) => { + const colWidth = widths[index] ?? 0 + doc.text( + truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, colWidth - 4)), + colX + 2, + y + 8, + ) + colX += colWidth + }) + doc.setDrawColor('#cbd5e1') + doc.rect(x, y, width, rowHeight) + y += rowHeight + } + if (schedule.rows.length > inlineRows.length) { + overflowSchedules.push({ + ...schedule, + title: `${schedule.title} Continued`, + rows: schedule.rows.slice(inlineRows.length), + }) + } + y += 12 + } + return { drawnSchedules, overflowSchedules } +} + type MountedFloorplan = { svg: SVGSVGElement + annotationLabelShifts: readonly FloorplanPoint[] /** Padded viewBox dimensions, in meters — used for aspect-preserving fit. */ width: number height: number + viewport: FloorplanExportBounds + setScreenUnitsPerPixel: (value: number) => Promise cleanup: () => void } +export type FloorplanExportBounds = { + x: number + y: number + width: number + height: number +} + +export function fitPlanToBox( + planWidth: number, + planHeight: number, + boxX: number, + boxY: number, + boxWidth: number, + boxHeight: number, +) { + const aspect = planWidth / planHeight + let width = boxWidth + let height = width / aspect + if (height > boxHeight) { + height = boxHeight + width = height * aspect + } + return { + x: boxX + (boxWidth - width) / 2, + y: boxY + (boxHeight - height) / 2, + width, + height, + } +} + +export function resolveFloorplanExportPlacement( + planWidth: number, + planHeight: number, + boxX: number, + boxY: number, + boxWidth: number, + boxHeight: number, +) { + return fitPlanToBox(planWidth, planHeight, boxX, boxY, boxWidth, boxHeight) +} + +export function resolveFloorplanExportViewport( + modelBounds: FloorplanExportBounds, +): FloorplanExportBounds { + const padding = Math.max( + MIN_PLAN_PADDING_M, + Math.max(modelBounds.width, modelBounds.height) * PLAN_PADDING_RATIO, + ) + return { + x: modelBounds.x - padding, + y: modelBounds.y - padding, + width: modelBounds.width + padding * 2, + height: modelBounds.height + padding * 2, + } +} + +export function rotateFloorplanExportBounds( + bounds: FloorplanExportBounds, + rotationDeg: number, +): FloorplanExportBounds { + const radians = (rotationDeg * Math.PI) / 180 + const cosine = Math.cos(radians) + const sine = Math.sin(radians) + const corners = [ + [bounds.x, bounds.y], + [bounds.x + bounds.width, bounds.y], + [bounds.x + bounds.width, bounds.y + bounds.height], + [bounds.x, bounds.y + bounds.height], + ] as const + const rotated = corners.map(([x, y]) => ({ + x: x * cosine - y * sine, + y: x * sine + y * cosine, + })) + const minX = Math.min(...rotated.map((point) => point.x)) + const minY = Math.min(...rotated.map((point) => point.y)) + const maxX = Math.max(...rotated.map((point) => point.x)) + const maxY = Math.max(...rotated.map((point) => point.y)) + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} + +export function resolveFloorplanScreenUnitsPerPixel( + modelWidth: number, + modelHeight: number, + boxWidth: number, + boxHeight: number, +): number { + return Math.max(modelWidth / boxWidth, modelHeight / boxHeight) +} + +export function resolveFloorplanExportAnnotationVisibility( + liveVisibility: FloorplanAnnotationVisibility, +): FloorplanAnnotationVisibility { + return { ...liveVisibility } +} + +export function resolveFloorplanExportRotationDeg( + buildingRotationY: number, + navigationAzimuth?: number, +): number { + const userRotationDeg = + navigationAzimuth === undefined + ? 0 + : (navigationAzimuth * 180) / Math.PI - FLOORPLAN_VIEW_ROTATION_DEG + return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI +} + +export function pointsPerMeterForDrawingScale(scale: DrawingSheetScale): number { + if (scale.startsWith('1:')) { + const denominator = Number.parseFloat(scale.slice(2)) + if (Number.isFinite(denominator) && denominator > 0) { + return POINTS_PER_INCH / METERS_PER_INCH / denominator + } + } + + const imperial = scale.match(/^(.+)"=1'-0"$/) + if (imperial) { + const paperInchesPerFoot = parseImperialPaperInches(imperial[1] ?? '') + if (paperInchesPerFoot > 0) { + return (paperInchesPerFoot / 12) * (POINTS_PER_INCH / METERS_PER_INCH) + } + } + + return pointsPerMeterForDrawingScale('1/4"=1\'-0"') +} + +function parseImperialPaperInches(value: string): number { + const trimmed = value.trim() + if (trimmed.includes('/')) { + const [numerator, denominator] = trimmed.split('/').map((part) => Number.parseFloat(part)) + return numerator && denominator ? numerator / denominator : 0 + } + const parsed = Number.parseFloat(trimmed) + return Number.isFinite(parsed) ? parsed : 0 +} + +function formatDrawingScaleLabel(scale: DrawingSheetScale): string { + return scale.replace('=', ' = ') +} + async function mountFloorplanSvg( parent: HTMLElement, - geometries: { id: AnyNodeId; base: FloorplanGeometry }[], + geometries: ExportGeometry[], rotationDeg: number, + annotationLayoutOverrides = useDrawingView.getState().annotationLayoutOverrides, ): Promise { const container = document.createElement('div') parent.appendChild(container) @@ -164,101 +1304,162 @@ async function mountFloorplanSvg( container.remove() } - // Render a full `` as the React root child so React enters the SVG - // namespace at the `` tag, then mutate the DOM node afterwards — - // viewBox/background depend on the post-mount measured bounds. - flushSync(() => { - root.render( - createElement( - 'svg', - { xmlns: SVG_NS }, + const render = (screenUnitsPerPixel?: number) => { + flushSync(() => { + root.render( createElement( - 'g', - { 'data-floorplan-content': '' }, + 'svg', + { xmlns: SVG_NS }, createElement( 'g', - { transform: `rotate(${rotationDeg})` }, - geometries.map(({ id, base }) => - createElement(FloorplanGeometryRenderer, { key: id, geometry: base }), + { 'data-floorplan-content': '' }, + createElement( + 'g', + { transform: `rotate(${rotationDeg})` }, + createElement( + 'g', + { 'data-floorplan-model': '' }, + geometries.map(({ id, model }) => + model + ? createElement(FloorplanGeometryRenderer, { + key: id, + geometry: model, + sceneRotationDeg: rotationDeg, + }) + : null, + ), + ), + createElement( + 'g', + { 'data-floorplan-annotations': '' }, + geometries.map(({ id, annotations }) => + annotations + ? createElement(FloorplanGeometryRenderer, { + key: id, + geometry: annotations, + renderMode: 'pdf', + sceneRotationDeg: rotationDeg, + screenUnitsPerPixel, + }) + : null, + ), + ), ), ), ), - ), - ) - }) + ) + }) + } + + render() // Give async asset images (item icons) a couple of frames to resolve so // they're included in the measured bounds and the rendered output. await nextFrames(2) const svg = container.querySelector('svg') - const content = svg?.querySelector('[data-floorplan-content]') as SVGGraphicsElement | null - const bbox = content?.getBBox() - if (!svg || !bbox || bbox.width === 0 || bbox.height === 0) { + if (!svg) { cleanup() return null } - const minX = bbox.x - PADDING_M - const minY = bbox.y - PADDING_M - const width = bbox.width + PADDING_M * 2 - const height = bbox.height + PADDING_M * 2 - svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`) - svg.setAttribute('width', `${width}`) - svg.setAttribute('height', `${height}`) - - const background = document.createElementNS(SVG_NS, 'rect') - background.setAttribute('x', `${minX}`) - background.setAttribute('y', `${minY}`) - background.setAttribute('width', `${width}`) - background.setAttribute('height', `${height}`) - background.setAttribute('fill', '#ffffff') - svg.insertBefore(background, svg.firstChild) - - return { svg, width, height, cleanup } + const modelBounds = measureFloorplanBounds(svg, '[data-floorplan-model]') + if (!modelBounds) { + cleanup() + return null + } + const viewport = resolveFloorplanExportViewport( + rotateFloorplanExportBounds(modelBounds, rotationDeg), + ) + const mounted: MountedFloorplan = { + svg, + annotationLabelShifts: [], + width: viewport.width, + height: viewport.height, + viewport, + cleanup, + setScreenUnitsPerPixel: async (value) => { + render(value) + applyFloorplanViewport(mounted, viewport, value) + await nextFrames(1) + resolveSvgAnnotationCollisions(svg, { layoutOverrides: annotationLayoutOverrides }) + mounted.annotationLabelShifts = readSvgAnnotationLabelShifts(svg) + }, + } + applyFloorplanViewport(mounted, viewport) + return mounted } -/** - * Bake `vector-effect: non-scaling-stroke` widths into real user units. - * - * svg2pdf ignores the non-scaling hint, so a `stroke-width="1.25"` meant as - * "1.25 screen px" would otherwise render as 1.25 metres on the page. We - * rewrite each such width (and any dash pattern) to `px / ptPerUnit` so it - * lands at ~`px` points once svg2pdf scales the plan by `ptPerUnit`, then drop - * the now-misleading attribute. - */ -function inlineNonScalingStrokes(svg: SVGSVGElement, ptPerUnit: number) { - if (!Number.isFinite(ptPerUnit) || ptPerUnit <= 0) return - for (const el of svg.querySelectorAll('[vector-effect="non-scaling-stroke"]')) { - const sw = el.getAttribute('stroke-width') - if (sw) { - const px = Number.parseFloat(sw) - if (Number.isFinite(px)) el.setAttribute('stroke-width', `${px / ptPerUnit}`) - } - const dash = el.getAttribute('stroke-dasharray') - if (dash) { - const scaled = dash - .split(/[\s,]+/) - .map((v) => { - const n = Number.parseFloat(v) - return Number.isFinite(n) ? `${n / ptPerUnit}` : v - }) - .join(' ') - el.setAttribute('stroke-dasharray', scaled) - } - el.removeAttribute('vector-effect') +function readSvgAnnotationLabelShifts(svg: SVGSVGElement): FloorplanPoint[] { + return Array.from(svg.querySelectorAll('[data-floorplan-annotation-label]')).map( + (label) => { + const x = Number(label.dataset.floorplanAnnotationLayoutDx ?? 0) + const y = Number(label.dataset.floorplanAnnotationLayoutDy ?? 0) + return [Number.isFinite(x) ? x : 0, Number.isFinite(y) ? y : 0] + }, + ) +} + +function measureFloorplanBounds( + svg: SVGSVGElement, + selector: string, +): FloorplanExportBounds | null { + const content = svg.querySelector(selector) as SVGGraphicsElement | null + const bbox = content?.getBBox() + if (!bbox || bbox.width === 0 || bbox.height === 0) return null + return { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height } +} + +export function resolveFloorplanMeasurementSize( + viewport: FloorplanExportBounds, + screenUnitsPerPixel: number, +): { width: number; height: number } { + return { + width: viewport.width / screenUnitsPerPixel, + height: viewport.height / screenUnitsPerPixel, } } +function applyFloorplanViewport( + mounted: MountedFloorplan, + viewport: FloorplanExportBounds, + screenUnitsPerPixel?: number, +): void { + mounted.width = viewport.width + mounted.height = viewport.height + mounted.svg.setAttribute( + 'viewBox', + `${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`, + ) + const measurementSize = screenUnitsPerPixel + ? resolveFloorplanMeasurementSize(viewport, screenUnitsPerPixel) + : { width: mounted.width, height: mounted.height } + mounted.svg.setAttribute('width', `${measurementSize.width}`) + mounted.svg.setAttribute('height', `${measurementSize.height}`) + + mounted.svg.querySelector('[data-floorplan-background]')?.remove() + const background = document.createElementNS(SVG_NS, 'rect') + background.setAttribute('data-floorplan-background', '') + background.setAttribute('x', `${viewport.x}`) + background.setAttribute('y', `${viewport.y}`) + background.setAttribute('width', `${mounted.width}`) + background.setAttribute('height', `${mounted.height}`) + background.setAttribute('fill', '#ffffff') + mounted.svg.insertBefore(background, mounted.svg.firstChild) +} + function collectFloorplanGeometry( nodes: Record, levelId: AnyNodeId, scope: FloorplanExportScope, unit: 'metric' | 'imperial', -): { id: AnyNodeId; base: FloorplanGeometry }[] { + metricNotation: FloorplanMetricNotation, + annotationVisibility: FloorplanAnnotationVisibility, + drawingType: ConstructionDrawingType, +): ExportGeometry[] { const noLiveOverrides = new Map() const levelNodeIdsByType = new Map() - const entries: { id: AnyNodeId; node: AnyNode }[] = [] + const entries: { id: AnyNodeId; node: AnyNode; parentOverride?: AnyNode }[] = [] const visit = (id: AnyNodeId) => { const node = nodes[id] @@ -274,13 +1475,31 @@ function collectFloorplanGeometry( isFloorplanNodeVisible(node) && (scope === 'full' || def.category === 'structure') ) { - entries.push({ id, node }) + const drawingNode = resolveNodeForDrawingType(node, nodes, drawingType) + if (drawingNode) entries.push({ id, node: drawingNode }) } const childIds = (node as { children?: AnyNodeId[] }).children if (Array.isArray(childIds)) for (const cid of childIds) visit(cid) } visit(levelId) + const activeLevelNode = nodes[levelId] + if (activeLevelNode) { + const collectedIds = new Set(entries.map((entry) => entry.id)) + for (const linked of collectFloorplanLinkedLevelNodes(nodes, levelId, collectedIds)) { + const definition = nodeRegistry.get(linked.node.type) + if ( + isFloorplanNodeVisible(linked.node) && + (scope === 'full' || definition?.category === 'structure') + ) { + const drawingNode = resolveNodeForDrawingType(linked.node, nodes, drawingType) + if (drawingNode) { + entries.push({ id: linked.id, node: drawingNode, parentOverride: activeLevelNode }) + } + } + } + } + // Document order is paint order — sort the same way the live layer does so // zones sit under walls/slabs/furniture rather than on top of them. entries.sort((a, b) => floorplanLayerRank(a.node.type) - floorplanLayerRank(b.node.type)) @@ -288,8 +1507,8 @@ function collectFloorplanGeometry( // One-shot per-type cache for `computeFloorplanLevelData`; value type is // module-private to the registry layer, so let it infer. const levelDataCache = new Map() - const out: { id: AnyNodeId; base: FloorplanGeometry }[] = [] - for (const { id, node } of entries) { + const out: ExportGeometry[] = [] + for (const { id, node, parentOverride } of entries) { const builder = nodeRegistry.get(node.type)?.floorplan if (!builder) continue const levelData = getFloorplanLevelData( @@ -299,15 +1518,131 @@ function collectFloorplanGeometry( levelNodeIdsByType, levelDataCache, ) - const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData) + const baseContext = buildContext( + node, + nodes, + resolveFloorplanExportViewState(unit, metricNotation), + levelData, + ) + const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext const geometry = builder(node, ctx) if (!geometry) continue - const { base } = splitFloorplanOverlay(geometry) - if (base) out.push({ id, base }) + const visibleGeometry = filterFloorplanAnnotationGeometry(geometry, annotationVisibility) + if (!visibleGeometry) continue + const { base, overlay } = splitFloorplanOverlay(visibleGeometry) + const exportOverlay = overlay ? filterFloorplanExportOverlay(overlay) : null + const annotationOnly = isFloorplanExportAnnotationGeometry(visibleGeometry) + const { model, annotations } = resolveFloorplanExportNodeGeometry( + base, + exportOverlay, + annotationOnly, + ) + if (model || annotations) out.push({ id, model, annotations }) } return out } +export function filterFloorplanExportOverlay( + geometry: FloorplanGeometry, +): FloorplanGeometry | null { + if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) return null + if (geometry.kind !== 'group') return geometry + + const children = geometry.children + .map(filterFloorplanExportOverlay) + .filter((child): child is FloorplanGeometry => child !== null) + if (children.length === 0) return null + return { ...geometry, children } +} + +const FLOORPLAN_EXPORT_EDITING_KINDS = new Set([ + 'endpoint-handle', + 'midpoint-handle', + 'edge-handle', + 'move-handle', + 'move-arrow', + 'rotate-arrow', +]) + +type FloorplanExportOverlayPartition = { + model: FloorplanGeometry | null + annotations: FloorplanGeometry | null +} + +export function resolveFloorplanExportNodeGeometry( + base: FloorplanGeometry | null, + overlay: FloorplanGeometry | null, + annotationOnly: boolean, +): FloorplanExportOverlayPartition { + const combined = combineGeometry(base, overlay) + if (annotationOnly) return { model: null, annotations: combined } + return combined ? partitionFloorplanExportOverlay(combined) : { model: null, annotations: null } +} + +export function partitionFloorplanExportOverlay( + geometry: FloorplanGeometry, +): FloorplanExportOverlayPartition { + if (FLOORPLAN_EXPORT_EDITING_KINDS.has(geometry.kind)) { + return { model: null, annotations: null } + } + if (isFloorplanExportAnnotationGeometry(geometry)) { + return { model: null, annotations: filterFloorplanExportOverlay(geometry) } + } + if (geometry.kind !== 'group') { + return { model: geometry, annotations: null } + } + + const modelChildren: FloorplanGeometry[] = [] + const annotationChildren: FloorplanGeometry[] = [] + for (const child of geometry.children) { + const partition = partitionFloorplanExportOverlay(child) + if (partition.model) modelChildren.push(partition.model) + if (partition.annotations) annotationChildren.push(partition.annotations) + } + return { + model: + modelChildren.length > 0 + ? { kind: 'group', children: modelChildren, transform: geometry.transform } + : null, + annotations: + annotationChildren.length > 0 + ? { kind: 'group', children: annotationChildren, transform: geometry.transform } + : null, + } +} + +export function isFloorplanExportAnnotationGeometry(geometry: FloorplanGeometry): boolean { + if ( + geometry.kind === 'text' || + geometry.kind === 'dimension' || + geometry.kind === 'dimension-string' || + geometry.kind === 'dimension-label' || + geometry.kind === 'equal-spacing-badge' + ) { + return true + } + if (readFloorplanGeometryMetadata(geometry).annotationRole) return true + return false +} + +function combineGeometry( + base: FloorplanGeometry | null, + overlay: FloorplanGeometry | null, +): FloorplanGeometry | null { + if (!base) return overlay + if (!overlay) return base + return { kind: 'group', children: [base, overlay] } +} + +function combineGeometryList( + geometries: readonly (FloorplanGeometry | null)[], +): FloorplanGeometry | null { + const children = geometries.filter((geometry): geometry is FloorplanGeometry => geometry !== null) + if (children.length === 0) return null + if (children.length === 1) return children[0] ?? null + return { kind: 'group', children } +} + /** * Levels to export, ordered bottom-to-top. The active building (the building * owning the selected level, or the first one found) contributes all of its diff --git a/packages/editor/src/lib/floorplan/floorplan-extension.test.ts b/packages/editor/src/lib/floorplan/floorplan-extension.test.ts new file mode 100644 index 00000000..2b6ca277 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-extension.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import type { GeometryContext } from '@pascal-app/core' +import { + createFloorplanContextExtensions, + normalizeFloorplanWallDimensionReference, + readFloorplanContext, +} from './floorplan-extension' + +function context(extensions?: Readonly>): GeometryContext { + return { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, + extensions, + } +} + +describe('floor-plan context extensions', () => { + test('defaults wall dimensions to finished faces', () => { + expect(readFloorplanContext(context()).wallDimensionReference).toBe('finished-faces') + }) + + test('carries the selected centerline or stud-face reference', () => { + for (const wallDimensionReference of ['centerline', 'stud-faces'] as const) { + const extensions = createFloorplanContextExtensions({ wallDimensionReference }) + expect(readFloorplanContext(context(extensions)).wallDimensionReference).toBe( + wallDimensionReference, + ) + } + }) + + test('normalizes stale persisted references to the finished-face default', () => { + expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces') + expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces') + }) +}) diff --git a/packages/editor/src/lib/floorplan/floorplan-extension.ts b/packages/editor/src/lib/floorplan/floorplan-extension.ts new file mode 100644 index 00000000..9fba23f1 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-extension.ts @@ -0,0 +1,177 @@ +import type { + AnyNode, + AnyNodeId, + ConstructionDrawingType, + DrawingSheetNode, + FloorplanGeometry, + GeometryContext, + NodeDefinition, + SceneApi, +} from '@pascal-app/core' +import type { ComponentType } from 'react' + +export const FLOORPLAN_NODE_EXTENSION_KEY = 'pascal:editor/floorplan' +export const FLOORPLAN_GEOMETRY_METADATA_KEY = 'pascal:editor/floorplan' +export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan' + +export type FloorplanRenderPurpose = 'edit' | 'document' +export type FloorplanMetricNotation = 'meters' | 'millimeters' +export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces' +export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces' +export type FloorplanAnnotationRole = + | 'automatic-dimension' + | 'manual-dimension' + | 'measurement' + | 'opening-mark' + | 'structural-grid' + | 'column-center' + | 'room-label' + | 'stair-annotation' + +export type FloorplanSchedule = { + id: string + title: string + columns: ReadonlyArray<{ + key: string + label: string + weight?: number + }> + rows: ReadonlyArray<{ + id: string + cells: Readonly> + }> + issues?: readonly string[] +} + +export type FloorplanToolContext = { + sceneApi: SceneApi + activeLevelId: AnyNodeId | null + unit: 'metric' | 'imperial' + metricNotation: FloorplanMetricNotation + gridSnapStep: number + toolDefaults: Readonly> | null + selectNode: (id: AnyNodeId) => void + finishTool: () => void +} + +export type FloorplanNodeExtension = { + tool?: () => Promise<{ default: ComponentType }> + preferredView?: '2d' | '3d' + actionMenu?: { + canCurve?: (args: { node: N; nodes: Readonly> }) => boolean + } + resolveDrawingSheet?: (args: { + node: N + levelId: AnyNodeId + drawingType: ConstructionDrawingType + }) => DrawingSheetNode | null + schedule?: (args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: AnyNodeId + unit: 'metric' | 'imperial' + }) => FloorplanSchedule | null + linkedLevelIds?: (node: N) => readonly AnyNodeId[] + resolveForDrawing?: (args: { + node: N + nodes: Record + drawingType: ConstructionDrawingType + }) => AnyNode | null +} + +type FloorplanGeometryMetadata = { + annotationRole?: FloorplanAnnotationRole + annotationObstacle?: 'bounds' | 'outline' +} + +type FloorplanContextExtension = { + purpose: FloorplanRenderPurpose + metricNotation: FloorplanMetricNotation + wallDimensionReference: FloorplanWallDimensionReference +} + +export function normalizeFloorplanWallDimensionReference( + value: unknown, +): FloorplanWallDimensionReference { + return value === 'centerline' || value === 'stud-faces' + ? value + : DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE +} + +export function getFloorplanNodeExtension( + definition: NodeDefinition | undefined, +): FloorplanNodeExtension | undefined { + return definition?.extensions?.[FLOORPLAN_NODE_EXTENSION_KEY] as + | FloorplanNodeExtension + | undefined +} + +export function floorplanGeometryMetadata( + values: FloorplanGeometryMetadata, +): Readonly> { + return { [FLOORPLAN_GEOMETRY_METADATA_KEY]: values } +} + +export function withFloorplanGeometryMetadata( + geometry: T, + values: FloorplanGeometryMetadata, +): T { + if (!geometry) return geometry + const existing = readFloorplanGeometryMetadata(geometry) + return { + ...geometry, + metadata: floorplanGeometryMetadata({ ...existing, ...values }), + } as T +} + +export function readFloorplanGeometryMetadata(geometry: unknown): FloorplanGeometryMetadata { + const metadata = (geometry as { metadata?: Readonly> } | null)?.metadata + const value = metadata?.[FLOORPLAN_GEOMETRY_METADATA_KEY] + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as FloorplanGeometryMetadata) + : {} +} + +export function createFloorplanContextExtensions( + values: Partial, +): Readonly> { + return { + [FLOORPLAN_CONTEXT_EXTENSION_KEY]: { + purpose: values.purpose === 'document' ? 'document' : 'edit', + metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters', + wallDimensionReference: normalizeFloorplanWallDimensionReference( + values.wallDimensionReference, + ), + } satisfies FloorplanContextExtension, + } +} + +export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExtension { + const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY] + if (value && typeof value === 'object' && !Array.isArray(value)) { + const extension = value as Partial + return { + purpose: extension.purpose === 'document' ? 'document' : 'edit', + metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters', + wallDimensionReference: normalizeFloorplanWallDimensionReference( + extension.wallDimensionReference, + ), + } + } + return { + purpose: 'edit', + metricNotation: 'meters', + wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE, + } +} + +export function readFloorplanMetricNotationOverride( + ctx: GeometryContext, +): FloorplanMetricNotation | undefined { + const value = ctx.extensions?.[FLOORPLAN_CONTEXT_EXTENSION_KEY] + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined + const metricNotation = (value as { metricNotation?: unknown }).metricNotation + return metricNotation === 'meters' || metricNotation === 'millimeters' + ? metricNotation + : undefined +} diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts new file mode 100644 index 00000000..9db43343 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-document.ts @@ -0,0 +1,194 @@ +import type PdfKitDocument from 'pdfkit' + +type PdfKitDocumentInstance = InstanceType + +type PdfTextOptions = { + align?: 'left' | 'center' | 'right' + maxWidth?: number +} + +type PdfShapeStyle = 'F' | 'S' + +export class FloorplanPdfDocument { + readonly raw: PdfKitDocumentInstance + readonly internal: { + pageSize: { + getWidth: () => number + getHeight: () => number + } + } + + private currentFontSize = 12 + private readonly defaultPageSize: readonly [number, number] + + constructor(raw: PdfKitDocumentInstance, defaultPageSize: readonly [number, number]) { + this.raw = raw + this.defaultPageSize = defaultPageSize + this.internal = { + pageSize: { + getWidth: () => this.raw.page?.width ?? this.defaultPageSize[0], + getHeight: () => this.raw.page?.height ?? this.defaultPageSize[1], + }, + } + } + + addPage( + size: readonly [number, number] = this.defaultPageSize, + _orientation?: 'portrait' | 'landscape', + ): this { + this.raw.addPage({ size: [size[0], size[1]], margin: 0 }) + return this + } + + setTextColor(color: string): this { + this.raw.fillColor(color) + return this + } + + setDrawColor(color: string): this { + this.raw.strokeColor(color) + return this + } + + setFillColor(color: string): this { + this.raw.fillColor(color) + return this + } + + setLineWidth(width: number): this { + this.raw.lineWidth(width) + return this + } + + setFont(family: string, weight: string = 'normal'): this { + const normalizedFamily = family.toLocaleLowerCase() + const normalizedWeight = weight.toLocaleLowerCase() + const bold = normalizedWeight === 'bold' || Number.parseInt(normalizedWeight, 10) >= 500 + const base = normalizedFamily.includes('courier') ? 'Courier' : 'Helvetica' + this.raw.font(bold ? `${base}-Bold` : base) + return this + } + + setFontSize(size: number): this { + this.currentFontSize = size + this.raw.fontSize(size) + return this + } + + getTextWidth(value: string): number { + return this.raw.widthOfString(value, { lineBreak: false }) + } + + splitTextToSize(value: string, maxWidth: number): string[] { + if (maxWidth <= 0 || this.getTextWidth(value) <= maxWidth) return [value] + const words = value.trim().split(/\s+/) + const lines: string[] = [] + let line = '' + for (const word of words) { + const candidate = line ? `${line} ${word}` : word + if (!line || this.getTextWidth(candidate) <= maxWidth) { + line = candidate + continue + } + lines.push(line) + line = word + } + if (line) lines.push(line) + return lines.length > 0 ? lines : [''] + } + + text( + value: string | readonly string[], + x: number, + baselineY: number, + options: PdfTextOptions = {}, + ) { + const lines = typeof value === 'string' ? value.split('\n') : value + const lineHeight = this.currentFontSize * 1.2 + lines.forEach((line, index) => { + const width = this.getTextWidth(line) + const drawX = + options.align === 'center' ? x - width / 2 : options.align === 'right' ? x - width : x + this.raw.text(line, drawX, baselineY - this.currentFontSize * 0.78 + index * lineHeight, { + lineBreak: false, + width: options.maxWidth, + }) + }) + return this + } + + line(x1: number, y1: number, x2: number, y2: number): this { + this.raw.moveTo(x1, y1).lineTo(x2, y2).stroke() + return this + } + + rect(x: number, y: number, width: number, height: number, style: PdfShapeStyle = 'S'): this { + this.raw.rect(x, y, width, height) + if (style === 'F') this.raw.fill() + else this.raw.stroke() + return this + } + + roundedRect( + x: number, + y: number, + width: number, + height: number, + radiusX: number, + _radiusY: number, + style: PdfShapeStyle = 'S', + ): this { + this.raw.roundedRect(x, y, width, height, radiusX) + if (style === 'F') this.raw.fill() + else this.raw.stroke() + return this + } + + circle(x: number, y: number, radius: number, style: PdfShapeStyle = 'S'): this { + this.raw.circle(x, y, radius) + if (style === 'F') this.raw.fill() + else this.raw.stroke() + return this + } + + triangle( + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + style: PdfShapeStyle = 'S', + ): this { + this.raw.polygon([x1, y1], [x2, y2], [x3, y3]) + if (style === 'F') this.raw.fill() + else this.raw.stroke() + return this + } +} + +export async function createFloorplanPdfDocument(defaultPageSize: readonly [number, number]) { + const [{ default: PDFDocument }, { default: blobStream }] = await Promise.all([ + import('pdfkit/js/pdfkit.standalone'), + import('blob-stream'), + ]) + const raw = new PDFDocument({ autoFirstPage: false, compress: true, margin: 0 }) + const stream = raw.pipe(blobStream()) + return { + doc: new FloorplanPdfDocument(raw, defaultPageSize), + save: async (filename: string) => { + const blob = await new Promise((resolve, reject) => { + stream.on('finish', () => resolve(stream.toBlob('application/pdf'))) + stream.on('error', reject) + raw.on('error', reject) + raw.end() + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + setTimeout(() => URL.revokeObjectURL(url), 0) + }, + } +} diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts new file mode 100644 index 00000000..c325d0db --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from 'bun:test' +import type { FloorplanGeometry } from '@pascal-app/core' +import PDFDocument from 'pdfkit' +import { floorplanGeometryMetadata } from './floorplan-extension' +import { FloorplanPdfDocument } from './floorplan-pdfkit-document' +import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer' + +describe('renderFloorplanGeometryToPdfKit', () => { + test('writes dimension values as native PDF text with fixed point line weights', async () => { + const geometry = { + kind: 'dimension', + start: [0, 0], + end: [13, 0], + offsetNormal: [0, -1], + offsetDistance: 1, + extensionOvershoot: 0.1, + text: '13m', + } satisfies FloorplanGeometry + + const pdf = await renderTestPdf(geometry) + + expect(pdf).toContain('BT') + expect(pdf).toContain(`[<${Buffer.from('13m').toString('hex')}> 0] TJ`) + expect(pdf).toMatch(/0\.1 w/) + expect(pdf).toMatch(/0\.15 w/) + expect(pdf).not.toMatch(/ c\n/) + }) + + test('writes rotated annotation labels through PDF text operators', async () => { + const geometry = { + kind: 'text', + x: 4, + y: 3, + text: 'ROOM 101', + fontSize: 0.15, + fontWeight: 600, + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + } satisfies FloorplanGeometry + + const pdf = await renderTestPdf(geometry, 45) + + expect(pdf).toContain(`<${Buffer.from('OOM 101').toString('hex')}>`) + expect(pdf).toContain('BT') + }) + + test('uses one font face, weight, and point size for every dimension value path', async () => { + const geometries = [ + { + kind: 'dimension', + start: [0, 0], + end: [4, 0], + offsetNormal: [0, -1], + offsetDistance: 1, + extensionOvershoot: 0.1, + text: '4m', + }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: 2, + cy: 2, + text: '2m', + angle: 0, + }, + { + kind: 'text', + x: 1, + y: 3, + text: '1m', + fontSize: 0.22, + fontWeight: 700, + metadata: floorplanGeometryMetadata({ annotationRole: 'automatic-dimension' }), + }, + ] satisfies FloorplanGeometry[] + + const pdfs = await Promise.all(geometries.map((geometry) => renderTestPdf(geometry))) + const baseFonts = pdfs.flatMap((pdf) => + [...pdf.matchAll(/\/BaseFont \/([^\n]+)/g)].map((match) => match[1]), + ) + const fontSizes = pdfs.flatMap((pdf) => + [...pdf.matchAll(/\/F\d+ ([\d.]+) Tf/g)].map((match) => match[1]), + ) + + expect([...new Set(baseFonts)]).toEqual(['Courier']) + expect([...new Set(fontSizes)]).toEqual(['1.6']) + }) +}) + +async function renderTestPdf(geometry: FloorplanGeometry, rotationDeg = 0): Promise { + const raw = new PDFDocument({ autoFirstPage: false, compress: false }) + const chunks: Buffer[] = [] + raw.on('data', (chunk: Buffer) => chunks.push(chunk)) + const completed = new Promise((resolve) => { + raw.on('end', () => resolve(Buffer.concat(chunks).toString('latin1'))) + }) + const doc = new FloorplanPdfDocument(raw, [200, 200]) + doc.addPage() + await renderFloorplanGeometryToPdfKit(doc, geometry, { + annotationLayer: true, + placement: { x: 20, y: 20, width: 100, height: 100 }, + rotationDeg, + viewport: { x: 0, y: -2, width: 20, height: 20 }, + }) + raw.end() + return completed +} diff --git a/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts new file mode 100644 index 00000000..a92f7e34 --- /dev/null +++ b/packages/editor/src/lib/floorplan/floorplan-pdfkit-renderer.ts @@ -0,0 +1,680 @@ +import { type FloorplanGeometry, type FloorplanPoint, loadAssetUrl } from '@pascal-app/core' +import { + type ArchitecturalDimensionLayout, + computeArchitecturalDimensionLayout, +} from '../../components/editor-2d/renderers/floorplan-dimension-renderer' +import { + documentCircleGeometryAttrs, + documentRectGeometryAttrs, + resolveDocumentAnnotationGroupChildren, +} from '../../components/editor-2d/renderers/floorplan-geometry-renderer' +import { resolveFloorplanLabelAngle } from '../../components/editor-2d/renderers/floorplan-label-angle' +import type { FloorplanExportBounds } from './floorplan-export' +import { readFloorplanGeometryMetadata } from './floorplan-extension' +import type { FloorplanPdfDocument } from './floorplan-pdfkit-document' + +const DIMENSION_LINE_WIDTH_PT = 0.5 +const DIMENSION_TICK_WIDTH_PT = 0.75 +const DIMENSION_TEXT_FONT_FAMILY = 'Courier' +const DIMENSION_TEXT_FONT_SIZE_PT = 8 +const DIMENSION_TEXT_FONT_WEIGHT = 400 +const DIMENSION_BASELINE_OFFSET_PT = 5 +const DEFAULT_ANNOTATION_FONT_SIZE_PT = 8 +const ROOM_NUMBER_FONT_SIZE_PT = 7 +const ROOM_DETAIL_FONT_SIZE_PT = 5.5 +const MARK_FONT_SIZE_PT = 7 + +type DimensionGeometry = Extract +type DimensionStringGeometry = Extract +type StyledGeometry = Extract< + FloorplanGeometry, + { kind: 'path' | 'polygon' | 'polyline' | 'rect' | 'circle' | 'line' } +> + +type RenderContext = { + annotationLabelShiftIndex: number + annotationLabelShifts: readonly FloorplanPoint[] + annotationLayer: boolean + sceneRotationDeg: number + unitsPerPoint: number +} + +export type FloorplanPdfKitPlacement = { + x: number + y: number + width: number + height: number +} + +export async function renderFloorplanGeometryToPdfKit( + doc: FloorplanPdfDocument, + geometry: FloorplanGeometry, + options: { + annotationLabelShifts?: readonly FloorplanPoint[] + annotationLayer: boolean + placement: FloorplanPdfKitPlacement + rotationDeg: number + viewport: FloorplanExportBounds + }, +): Promise { + const pointsPerUnit = options.placement.width / options.viewport.width + if (!Number.isFinite(pointsPerUnit) || pointsPerUnit <= 0) return + + const raw = doc.raw + raw.save() + raw.translate(options.placement.x, options.placement.y) + raw.scale(pointsPerUnit) + raw.translate(-options.viewport.x, -options.viewport.y) + raw.rotate(options.rotationDeg, { origin: [0, 0] }) + await renderGeometry(doc, geometry, { + annotationLabelShiftIndex: 0, + annotationLabelShifts: options.annotationLabelShifts ?? [], + annotationLayer: options.annotationLayer, + sceneRotationDeg: options.rotationDeg, + unitsPerPoint: 1 / pointsPerUnit, + }) + raw.restore() +} + +async function renderGeometry( + doc: FloorplanPdfDocument, + geometry: FloorplanGeometry, + context: RenderContext, +): Promise { + const raw = doc.raw + switch (geometry.kind) { + case 'path': + raw.save().path(geometry.d) + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'polygon': + if (geometry.points.length < 2) return + raw.save().polygon(...geometry.points.map(([x, y]) => [x, y] as [number, number])) + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'polyline': + if (geometry.points.length < 2) return + raw.save().moveTo(geometry.points[0]![0], geometry.points[0]![1]) + for (const [x, y] of geometry.points.slice(1)) raw.lineTo(x, y) + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'rect': + raw.save() + { + const attrs = documentRectGeometryAttrs( + geometry, + context.annotationLayer ? context.unitsPerPoint : undefined, + ) + if ((attrs.rx ?? 0) > 0 || (attrs.ry ?? 0) > 0) { + raw.roundedRect( + attrs.x, + attrs.y, + attrs.width, + attrs.height, + Math.max(attrs.rx ?? 0, attrs.ry ?? 0), + ) + } else { + raw.rect(attrs.x, attrs.y, attrs.width, attrs.height) + } + } + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'circle': + raw + .save() + .circle( + geometry.cx, + geometry.cy, + documentCircleGeometryAttrs( + geometry, + context.annotationLayer ? context.unitsPerPoint : undefined, + ).r, + ) + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'line': + raw.save().moveTo(geometry.x1, geometry.y1).lineTo(geometry.x2, geometry.y2) + paintStyledGeometry(raw, geometry, context) + raw.restore() + return + case 'text': + drawGeometryText(doc, geometry, context) + return + case 'dimension': + drawDimension(doc, geometry, context) + return + case 'dimension-string': + drawDimensionString(doc, geometry, context) + return + case 'dimension-label': + drawDimensionLabel(doc, geometry, context) + return + case 'equal-spacing-badge': + drawEqualSpacingBadge(doc, geometry, context) + return + case 'image': + await drawImage(doc, geometry) + return + case 'group': + raw.save() + if (geometry.transform?.translate) { + raw.translate(geometry.transform.translate[0], geometry.transform.translate[1]) + } + if (geometry.transform?.rotate !== undefined) { + raw.rotate((geometry.transform.rotate * 180) / Math.PI, { origin: [0, 0] }) + } + for (const child of resolveDocumentAnnotationGroupChildren( + geometry.children, + context.annotationLayer ? context.unitsPerPoint : undefined, + )) { + await renderGeometry(doc, child, context) + } + raw.restore() + return + default: + return + } +} + +function paintStyledGeometry( + raw: FloorplanPdfDocument['raw'], + geometry: StyledGeometry, + context: RenderContext, +): void { + const fill = geometry.fill && geometry.fill !== 'none' ? geometry.fill : null + const stroke = geometry.stroke && geometry.stroke !== 'none' ? geometry.stroke : null + const opacity = geometry.opacity ?? 1 + const fillOpacity = (geometry.fillOpacity ?? 1) * opacity + const strokeOpacity = (geometry.strokeOpacity ?? 1) * opacity + + if (fill) raw.fillColor(fill).fillOpacity(fillOpacity) + if (stroke) { + raw.strokeColor(stroke).strokeOpacity(strokeOpacity) + raw.lineWidth(resolveStrokeWidth(geometry, context)) + if (geometry.strokeLinecap) raw.lineCap(geometry.strokeLinecap) + if (geometry.strokeLinejoin) raw.lineJoin(geometry.strokeLinejoin) + applyDash( + raw, + geometry.strokeDasharray, + geometry.vectorEffect === 'non-scaling-stroke', + context, + ) + } + + if (fill && stroke) raw.fillAndStroke(fill, stroke) + else if (fill) raw.fill(fill) + else if (stroke) raw.stroke(stroke) +} + +function resolveStrokeWidth(geometry: StyledGeometry, context: RenderContext): number { + if (context.annotationLayer) return DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint + if (geometry.vectorEffect === 'non-scaling-stroke') { + return (geometry.strokeWidth ?? 1) * context.unitsPerPoint + } + return geometry.strokeWidth ?? DIMENSION_LINE_WIDTH_PT * context.unitsPerPoint +} + +function applyDash( + raw: FloorplanPdfDocument['raw'], + dasharray: string | undefined, + nonScaling: boolean, + context: RenderContext, +): void { + if (!dasharray) { + raw.undash() + return + } + const values = dasharray + .split(/[\s,]+/) + .map(Number) + .filter((value) => Number.isFinite(value) && value > 0) + if (values.length === 0) return + const scale = nonScaling ? context.unitsPerPoint : 1 + raw.dash(values[0]! * scale, { space: (values[1] ?? values[0]!) * scale }) +} + +function drawGeometryText( + doc: FloorplanPdfDocument, + geometry: Extract, + context: RenderContext, +): void { + const dimensionValue = + readFloorplanGeometryMetadata(geometry).annotationRole === 'automatic-dimension' + const fontSize = context.annotationLayer + ? (dimensionValue ? DIMENSION_TEXT_FONT_SIZE_PT : annotationTextSizePt(geometry)) * + context.unitsPerPoint + : geometry.fontSize + const outlinedForScreen = geometry.paintOrder === 'stroke' && !!geometry.stroke + const fill = + outlinedForScreen && geometry.fill?.toLocaleLowerCase() === '#ffffff' + ? (geometry.stroke ?? '#111827') + : (geometry.fill ?? '#171717') + drawNativeText(doc, { + angleDeg: geometry.upright ? -context.sceneRotationDeg : 0, + anchor: geometry.textAnchor ?? 'start', + fill, + fontFamily: dimensionValue ? DIMENSION_TEXT_FONT_FAMILY : geometry.fontFamily, + fontSize, + fontWeight: dimensionValue ? DIMENSION_TEXT_FONT_WEIGHT : geometry.fontWeight, + opacity: geometry.opacity, + text: geometry.text, + x: geometry.x, + y: geometry.y, + }) +} + +function annotationTextSizePt(geometry: Extract): number { + switch (readFloorplanGeometryMetadata(geometry).annotationRole) { + case 'room-label': + if (geometry.fontSize >= 0.18) return DEFAULT_ANNOTATION_FONT_SIZE_PT + if (geometry.fontSize >= 0.145) return ROOM_NUMBER_FONT_SIZE_PT + return ROOM_DETAIL_FONT_SIZE_PT + case 'column-center': + case 'stair-annotation': + return MARK_FONT_SIZE_PT + default: + return DEFAULT_ANNOTATION_FONT_SIZE_PT + } +} + +function drawDimension( + doc: FloorplanPdfDocument, + geometry: DimensionGeometry, + context: RenderContext, +): void { + const layout = computeArchitecturalDimensionLayout( + geometry, + context.sceneRotationDeg, + context.unitsPerPoint, + ) + if (!layout) return + drawDimensionLayout(doc, geometry, layout, context) +} + +function drawDimensionString( + doc: FloorplanPdfDocument, + geometry: DimensionStringGeometry, + context: RenderContext, +): void { + const entries = geometry.segments.flatMap((segment) => { + const dimension: 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( + dimension, + context.sceneRotationDeg, + context.unitsPerPoint, + ) + return layout ? [{ dimension, layout }] : [] + }) + if (entries.length === 0) return + + const extensionLines = new Map() + const terminators = new Map< + string, + { point: FloorplanPoint; toward: FloorplanPoint; layout: ArchitecturalDimensionLayout } + >() + for (const { layout } of entries) { + extensionLines.set(pointKey(layout.dimensionStart), [ + layout.extensionStart, + layout.extensionStartTip, + ]) + extensionLines.set(pointKey(layout.dimensionEnd), [layout.extensionEnd, layout.extensionEndTip]) + terminators.set(pointKey(layout.dimensionStart), { + point: layout.dimensionStart, + toward: layout.dimensionEnd, + layout, + }) + terminators.set(pointKey(layout.dimensionEnd), { + point: layout.dimensionEnd, + toward: layout.dimensionStart, + layout, + }) + } + + const stroke = geometry.stroke ?? '#334155' + for (const [start, end] of extensionLines.values()) + drawDimensionLine(doc, start, end, stroke, context) + for (const { layout } of entries) { + drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context) + } + for (const terminator of terminators.values()) { + drawDimensionTerminator( + doc, + geometry.terminator ?? 'architectural-tick', + terminator.point, + terminator.toward, + terminator.layout, + stroke, + context, + ) + } + for (const { dimension, layout } of entries) + drawDimensionText(doc, dimension, layout, stroke, context) +} + +function drawDimensionLayout( + doc: FloorplanPdfDocument, + geometry: DimensionGeometry, + layout: ArchitecturalDimensionLayout, + context: RenderContext, +): void { + const stroke = geometry.stroke ?? '#334155' + drawDimensionLine(doc, layout.extensionStart, layout.extensionStartTip, stroke, context) + drawDimensionLine(doc, layout.extensionEnd, layout.extensionEndTip, stroke, context) + drawDimensionLine(doc, layout.dimensionLineStart, layout.dimensionLineEnd, stroke, context) + drawDimensionTerminator( + doc, + geometry.terminator ?? 'architectural-tick', + layout.dimensionStart, + layout.dimensionEnd, + layout, + stroke, + context, + ) + drawDimensionTerminator( + doc, + geometry.terminator ?? 'architectural-tick', + layout.dimensionEnd, + layout.dimensionStart, + layout, + stroke, + context, + ) + drawDimensionText(doc, geometry, layout, stroke, context) +} + +function drawDimensionLine( + doc: FloorplanPdfDocument, + start: FloorplanPoint, + end: FloorplanPoint, + stroke: string, + context: RenderContext, + widthPt = DIMENSION_LINE_WIDTH_PT, +): void { + doc.raw + .save() + .strokeColor(stroke) + .strokeOpacity(1) + .lineCap('butt') + .lineWidth(widthPt * context.unitsPerPoint) + .moveTo(start[0], start[1]) + .lineTo(end[0], end[1]) + .stroke() + .restore() +} + +function drawDimensionTerminator( + doc: FloorplanPdfDocument, + terminator: NonNullable, + point: FloorplanPoint, + toward: FloorplanPoint, + layout: ArchitecturalDimensionLayout, + stroke: string, + context: RenderContext, +): void { + const direction = normalized(point, toward) + if (!direction) return + const tickHalfLength = Math.hypot(layout.tickHalfVector[0], layout.tickHalfVector[1]) + if (terminator === 'dot') { + doc.raw + .save() + .fillColor(stroke) + .circle(point[0], point[1], tickHalfLength * 0.45) + .fill() + .restore() + 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') { + doc.raw + .save() + .fillColor(stroke) + .polygon([point[0], point[1]], [left[0], left[1]], [right[0], right[1]]) + .fill() + .restore() + return + } + drawDimensionLine(doc, point, left, stroke, context, DIMENSION_TICK_WIDTH_PT) + drawDimensionLine(doc, point, right, stroke, context, DIMENSION_TICK_WIDTH_PT) + return + } + const [tickX, tickY] = layout.tickHalfVector + drawDimensionLine( + doc, + [point[0] - tickX, point[1] - tickY], + [point[0] + tickX, point[1] + tickY], + stroke, + context, + DIMENSION_TICK_WIDTH_PT, + ) +} + +function drawDimensionText( + doc: FloorplanPdfDocument, + geometry: DimensionGeometry, + layout: ArchitecturalDimensionLayout, + stroke: string, + context: RenderContext, +): void { + const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * context.unitsPerPoint + const y = + geometry.textPosition === 'centered' + ? fontSize * 0.35 + : -DIMENSION_BASELINE_OFFSET_PT * context.unitsPerPoint + + const raw = doc.raw + const shift = nextAnnotationLabelShift(context) + raw + .save() + .translate(layout.labelPoint[0], layout.labelPoint[1]) + .rotate(layout.labelAngleDeg) + .translate(shift[0], shift[1]) + drawNativeText(doc, { + anchor: 'middle', + fill: stroke, + fontFamily: DIMENSION_TEXT_FONT_FAMILY, + fontSize, + fontWeight: DIMENSION_TEXT_FONT_WEIGHT, + text: geometry.text, + x: 0, + y, + }) + raw.restore() +} + +function drawDimensionLabel( + doc: FloorplanPdfDocument, + geometry: Extract, + context: RenderContext, +): void { + const unitsPerPoint = context.unitsPerPoint + const fontSize = DIMENSION_TEXT_FONT_SIZE_PT * unitsPerPoint + const padX = 6 * unitsPerPoint + const padY = 3 * unitsPerPoint + const textWidth = geometry.text.length * 6.2 * unitsPerPoint + const plateWidth = textWidth + padX * 2 + const plateHeight = fontSize + padY * 2 + const angle = resolveFloorplanLabelAngle( + geometry.angle, + context.sceneRotationDeg, + geometry.screenUpright, + ) + const offset = -(geometry.offsetPx ?? 0) * unitsPerPoint + const shift = nextAnnotationLabelShift(context) + const raw = doc.raw + raw + .save() + .translate(geometry.cx, geometry.cy) + .rotate(angle) + .translate(shift[0], shift[1] + offset) + raw + .fillColor('#ffffff') + .fillOpacity(0.92) + .roundedRect(-plateWidth / 2, -plateHeight / 2, plateWidth, plateHeight, 3 * unitsPerPoint) + .fill() + drawNativeText(doc, { + anchor: 'middle', + fill: '#111827', + fontFamily: DIMENSION_TEXT_FONT_FAMILY, + fontSize, + fontWeight: DIMENSION_TEXT_FONT_WEIGHT, + text: geometry.text, + x: 0, + y: 0, + }) + raw.restore() +} + +function drawEqualSpacingBadge( + doc: FloorplanPdfDocument, + geometry: Extract, + context: RenderContext, +): void { + const fontSize = 7 * context.unitsPerPoint + const width = Math.max( + 14 * context.unitsPerPoint, + geometry.text.length * fontSize * 0.62 + 6 * context.unitsPerPoint, + ) + const height = 12 * context.unitsPerPoint + const angle = resolveFloorplanLabelAngle(geometry.angle, context.sceneRotationDeg) + const raw = doc.raw + raw.save().translate(geometry.point[0], geometry.point[1]).rotate(angle) + raw + .fillColor('#ffffff') + .roundedRect(-width / 2, -height / 2, width, height, height / 2) + .fill() + drawNativeText(doc, { + anchor: 'middle', + fill: '#334155', + fontFamily: 'Courier', + fontSize, + fontWeight: 600, + text: geometry.text, + x: 0, + y: 0, + }) + raw.restore() +} + +function drawNativeText( + doc: FloorplanPdfDocument, + options: { + angleDeg?: number + anchor: 'start' | 'middle' | 'end' + fill: string + fontFamily?: string + fontSize: number + fontWeight?: number | string + opacity?: number + text: string + x: number + y: number + }, +): void { + const raw = doc.raw + const normalizedFamily = options.fontFamily?.toLocaleLowerCase() ?? '' + const family = + normalizedFamily.includes('mono') || normalizedFamily.includes('courier') + ? 'Courier' + : 'Helvetica' + const numericWeight = Number.parseInt(String(options.fontWeight ?? 400), 10) + const bold = + options.fontWeight === 'bold' || (Number.isFinite(numericWeight) && numericWeight >= 500) + raw.save().translate(options.x, options.y) + if (options.angleDeg) raw.rotate(options.angleDeg) + raw + .font(bold ? `${family}-Bold` : family) + .fontSize(options.fontSize) + .fillColor(options.fill) + raw.fillOpacity(options.opacity ?? 1) + const width = raw.widthOfString(options.text, { lineBreak: false }) + const x = options.anchor === 'middle' ? -width / 2 : options.anchor === 'end' ? -width : 0 + raw.text(options.text, x, -options.fontSize * 0.42, { lineBreak: false }) + raw.restore() +} + +async function drawImage( + doc: FloorplanPdfDocument, + geometry: Extract, +): Promise { + try { + const url = await loadAssetUrl(geometry.url) + if (!url) return + const response = await fetch(url) + if (!response.ok) return + const dataUrl = await blobToDataUrl(await response.blob()) + const raw = doc.raw + raw.save().translate(geometry.center[0], geometry.center[1]) + if (geometry.rotation) raw.rotate((geometry.rotation * 180) / Math.PI) + raw.opacity(geometry.opacity ?? 1) + const options = + geometry.preserveAspectRatio === 'none' + ? { width: geometry.width, height: geometry.height } + : { + fit: [geometry.width, geometry.height] as [number, number], + align: 'center' as const, + valign: 'center' as const, + } + raw.image(dataUrl, -geometry.width / 2, -geometry.height / 2, options) + raw.restore() + } catch { + return + } +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error) + reader.onload = () => resolve(String(reader.result)) + reader.readAsDataURL(blob) + }) +} + +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 addScaled( + point: FloorplanPoint, + direction: FloorplanPoint, + distance: number, +): FloorplanPoint { + return [point[0] + direction[0] * distance, point[1] + direction[1] * distance] +} + +function pointKey(point: FloorplanPoint): string { + return `${point[0].toFixed(6)},${point[1].toFixed(6)}` +} + +function nextAnnotationLabelShift(context: RenderContext): FloorplanPoint { + const shift = context.annotationLabelShifts[context.annotationLabelShiftIndex] ?? [0, 0] + context.annotationLabelShiftIndex += 1 + return shift +} diff --git a/packages/editor/src/lib/floorplan/geometry.ts b/packages/editor/src/lib/floorplan/geometry.ts index 0f0f50e2..6544e60b 100644 --- a/packages/editor/src/lib/floorplan/geometry.ts +++ b/packages/editor/src/lib/floorplan/geometry.ts @@ -3,9 +3,8 @@ import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types' // Baseline rotation (deg) that orients the plan-local scene "north up" on // screen. The on-screen floor-plan scene `` is rotated by -// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; the PDF -// export mirrors the aligned-to-north case (user offset 0) so an export points -// the same way as the app's north-aligned view. +// `FLOORPLAN_VIEW_ROTATION_DEG + userRotation - buildingRotation`; PDF export +// uses the same user rotation so its orientation matches the live plan. // // North is world −Z: with a 0 baseline a rotation-0 reference image (top = // −Z) reads upright in the north-aligned view, and "align north" maps to a diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 94c0e7de..c9557bf9 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test' import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { buildDoorPreviewMesh } from '@pascal-app/viewer' import * as THREE from 'three' -import { prepareSceneForExport } from './glb-export' +import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export' + +// The reference module reads the storage origin lazily on first use, so +// setting the env here (before any validation call) pins it for the file. +process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co' +const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin afterEach(() => { sceneRegistry.clear() @@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => { expect(meshes[0]!.material).toBe(meshes[1]!.material) }) + test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => { + const root = new THREE.Group() + const stamped = new THREE.CompressedTexture([], 4, 4) + stamped.wrapS = THREE.MirroredRepeatWrapping + stamped.wrapT = THREE.ClampToEdgeWrapping + stamped.repeat.set(2, 3) + stamped.offset.set(0.25, 0.5) + stamped.center.set(0.5, 0.5) + stamped.rotation = 0.75 + stamped.flipY = false + stamped.colorSpace = THREE.SRGBColorSpace + stamped.updateMatrix() + stamped.userData.pascalTextureRef = { + v: 1, + kind: 'library-material', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`, + map: 'basecolor', + colorSpace: 'srgb', + } + const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1) + root.add( + meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })), + meshWithNodeMaterial(nodeMaterial({ map: stamped })), + ) + + const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' }) + + const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial + const placeholder = material.map as THREE.DataTexture + expect(placeholder).not.toBe(stamped) + expect(placeholder.isDataTexture).toBe(true) + expect( + (placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture, + ).toBeUndefined() + expect(placeholder.image.width).toBe(1) + expect(placeholder.image.height).toBe(1) + expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255]) + expect(placeholder.wrapS).toBe(stamped.wrapS) + expect(placeholder.wrapT).toBe(stamped.wrapT) + expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray()) + expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray()) + expect(placeholder.center.toArray()).toEqual(stamped.center.toArray()) + expect(placeholder.rotation).toBe(stamped.rotation) + expect(placeholder.flipY).toBe(stamped.flipY) + expect(placeholder.colorSpace).toBe(stamped.colorSpace) + expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef) + expect(material.normalMap).toBe(unstamped) + const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial + expect(sharedMaterial.map).toBe(placeholder) + }) + + test('writes identical texture-reference extras to the texture and image definitions', () => { + const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1) + const ref = { + v: 1, + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`, + imageIndex: 3, + map: 'normal', + colorSpace: 'linear', + } + texture.userData.pascalTextureRef = ref + const imageDef: { extras?: Record } = {} + const textureDef: { source: number; extras?: Record } = { source: 0 } + const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter + + writeTextureReferenceExtras(writer, texture, textureDef) + + expect(textureDef.extras?.pascalTextureRef).toEqual(ref) + expect(imageDef.extras?.pascalTextureRef).toEqual(ref) + expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef) + }) + test('strips editor overlays that live off the scene layer', () => { const root = new THREE.Group() const realMesh = meshWithNodeMaterial(nodeMaterial()) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index e6b67b5b..ed0e4063 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -13,6 +13,7 @@ import { type ZoneNode, } from '@pascal-app/core' import { + getPascalTextureRef, poseDoorMovingParts, poseWindowMovingParts, SCENE_LAYER, @@ -20,7 +21,11 @@ import { } from '@pascal-app/viewer' import type { Object3D } from 'three' import * as THREE from 'three' -import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { + GLTFExporter, + type GLTFExporterPlugin, + type GLTFWriter, +} from 'three/examples/jsm/exporters/GLTFExporter.js' import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' /** @@ -41,6 +46,10 @@ export type GlbExport = { animations: THREE.AnimationClip[] } +export type GlbExportOptions = { + textures?: 'embed' | 'reference' +} + /** Resolve after the next couple of animation frames, giving React/R3F time to * commit and mount export-only geometry (e.g. instanced kinds' real meshes) * before the exporter clones the scene graph. Callers must set @@ -51,10 +60,63 @@ export function nextFrames(): Promise { }) } +type GltfExtrasDef = { + extras?: Record +} + +type TextureReferenceWriter = GLTFWriter & { + json: { + images?: GltfExtrasDef[] + } +} + +function getExportedImageIndex(textureDef: Record): number | null { + if (Number.isInteger(textureDef.source)) return textureDef.source as number + const extensions = textureDef.extensions as Record | undefined + const source = extensions?.EXT_texture_webp?.source ?? extensions?.EXT_texture_avif?.source + return Number.isInteger(source) ? (source as number) : null +} + +export function writeTextureReferenceExtras( + writer: GLTFWriter, + texture: THREE.Texture, + textureDef: Record, +) { + const ref = getPascalTextureRef(texture) + if (!ref) return + + const imageIndex = getExportedImageIndex(textureDef) + const imageDef = + imageIndex === null ? undefined : (writer as TextureReferenceWriter).json.images?.[imageIndex] + if (!imageDef) { + throw new Error('GLTFExporter did not expose an image for a referenced Pascal texture') + } + + const textureWithExtras = textureDef as GltfExtrasDef + textureWithExtras.extras = { + ...textureWithExtras.extras, + pascalTextureRef: ref, + } + imageDef.extras = { + ...imageDef.extras, + pascalTextureRef: ref, + } +} + +function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin { + return { + writeTexture: (texture, textureDef) => { + writeTextureReferenceExtras(writer, texture, textureDef) + }, + } +} + export async function exportSceneToGlb( sceneGroup: Object3D, nodes: Record, + options: GlbExportOptions = {}, ): Promise { + const textureMode = options.textures ?? 'embed' emitter.emit('thumbnail:before-capture', undefined) // Snap levels to their true stacked positions (like thumbnail capture) so the // export always reflects the clean stacked building, regardless of the live @@ -63,7 +125,10 @@ export async function exportSceneToGlb( const restoreLevels = snapLevelsToTruePositions() let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, nodes) + prepared = + textureMode === 'reference' + ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' }) + : prepareSceneForExport(sceneGroup, nodes) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) @@ -71,6 +136,7 @@ export async function exportSceneToGlb( const { scene: exportScene, animations } = prepared const exporter = new GLTFExporter() + if (textureMode === 'reference') exporter.register(textureReferencePlugin) // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read // those directly. WebGPUTextureUtils blits each one to RGBA on its own // offscreen renderer (passing the live renderer would resize/draw over the @@ -112,6 +178,7 @@ export async function exportSceneToGlb( export function prepareSceneForExport( source: THREE.Object3D, nodes: Record, + options: GlbExportOptions = {}, ): GlbExport { const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) @@ -142,7 +209,7 @@ export function prepareSceneForExport( pruneNonRenderableMeshes(scene, identityNodes) sanitizeMaterialGroups(scene, identityNodes) - convertMaterials(scene) + convertMaterials(scene, options.textures ?? 'embed') const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) @@ -352,14 +419,31 @@ const STANDARD_MAP_SLOTS = [ 'bumpMap', ] as const -function convertMaterials(root: THREE.Object3D) { +const REFERENCE_MAP_SLOTS = [ + ...STANDARD_MAP_SLOTS, + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'transmissionMap', + 'thicknessMap', + 'specularIntensityMap', + 'specularColorMap', + 'sheenRoughnessMap', + 'sheenColorMap', + 'anisotropyMap', +] as const + +function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') { const cache = new Map() + const placeholderCache = new Map() root.traverse((object) => { const mesh = object as THREE.Mesh if (!mesh.isMesh) return const material = mesh.material if (Array.isArray(material)) { - mesh.material = material.map((m) => convertMaterial(m, cache)) + mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache)) return } // glTF has no BackSide — GLTFExporter renders the *front* face for any @@ -373,7 +457,7 @@ function convertMaterials(root: THREE.Object3D) { ) { mesh.geometry = flipGeometryWinding(mesh.geometry) } - mesh.material = convertMaterial(material, cache) + mesh.material = convertMaterial(material, cache, textureMode, placeholderCache) }) } @@ -423,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet function convertMaterial( material: THREE.Material, cache: Map, + textureMode: 'embed' | 'reference', + placeholderCache: Map, ): THREE.Material { - if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material + const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true + if (!isNodeMaterial) { + if (textureMode === 'embed') return material + const cached = cache.get(material) + if (cached) return cached + const target = material.clone() + replaceReferencedTextures(target, placeholderCache) + cache.set(material, target) + return target + } const cached = cache.get(material) if (cached) return cached @@ -465,10 +560,90 @@ function convertMaterial( } } + if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache) + cache.set(material, target) return target } +function replaceReferencedTextures( + material: THREE.Material, + placeholderCache: Map, +) { + const textureMaterial = material as THREE.Material & Record + for (const slot of REFERENCE_MAP_SLOTS) { + const texture = textureMaterial[slot] + if (!(texture instanceof THREE.Texture) || !getPascalTextureRef(texture)) continue + + let placeholder = placeholderCache.get(texture) + if (!placeholder) { + placeholder = createReferencePlaceholder(texture) + placeholderCache.set(texture, placeholder) + } + textureMaterial[slot] = placeholder + } +} + +/** GLTFExporter serializes images via canvas drawImage/createImageBitmap, + * which reject a DataTexture's raw `{data,width,height}` image — so in DOM + * environments the placeholder must be canvas-backed. The DataTexture branch + * covers non-DOM runs (bun tests), where the exporter itself never runs. */ +function createPlaceholderCanvas(): OffscreenCanvas | HTMLCanvasElement | null { + const canvas = + typeof OffscreenCanvas !== 'undefined' + ? new OffscreenCanvas(1, 1) + : typeof document !== 'undefined' + ? Object.assign(document.createElement('canvas'), { width: 1, height: 1 }) + : null + if (!canvas) return null + const ctx = canvas.getContext('2d') as + | OffscreenCanvasRenderingContext2D + | CanvasRenderingContext2D + | null + if (!ctx) return null + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, 1, 1) + return canvas +} + +function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture { + const ref = getPascalTextureRef(texture) + if (!ref) throw new Error('Cannot create a placeholder for an invalid Pascal texture reference') + + const canvas = createPlaceholderCanvas() + const placeholder = canvas + ? new THREE.Texture(canvas) + : new THREE.DataTexture( + new Uint8Array([255, 255, 255, 255]), + 1, + 1, + THREE.RGBAFormat, + THREE.UnsignedByteType, + ) + placeholder.name = texture.name + placeholder.mapping = texture.mapping + placeholder.channel = texture.channel + placeholder.wrapS = texture.wrapS + placeholder.wrapT = texture.wrapT + placeholder.magFilter = texture.magFilter + placeholder.minFilter = texture.minFilter + placeholder.anisotropy = texture.anisotropy + placeholder.offset.copy(texture.offset) + placeholder.repeat.copy(texture.repeat) + placeholder.center.copy(texture.center) + placeholder.rotation = texture.rotation + placeholder.matrixAutoUpdate = texture.matrixAutoUpdate + placeholder.matrix.copy(texture.matrix) + placeholder.generateMipmaps = texture.generateMipmaps + placeholder.premultiplyAlpha = texture.premultiplyAlpha + placeholder.flipY = texture.flipY + placeholder.unpackAlignment = texture.unpackAlignment + placeholder.colorSpace = texture.colorSpace + placeholder.userData = { pascalTextureRef: ref } + placeholder.needsUpdate = true + return placeholder +} + // --- Animation clip baking ---------------------------------------------- function bakeAnimationClips( diff --git a/packages/editor/src/lib/level-duplication.test.ts b/packages/editor/src/lib/level-duplication.test.ts index 5f850071..b4f2434c 100644 --- a/packages/editor/src/lib/level-duplication.test.ts +++ b/packages/editor/src/lib/level-duplication.test.ts @@ -11,7 +11,7 @@ import { buildLevelDuplicateCreateOps } from './level-duplication' describe('buildLevelDuplicateCreateOps', () => { test('parents a duplicated bootstrap level back to its building', () => { - const level = LevelNode.parse({ level: 0, children: [] }) + const level = LevelNode.parse({ level: 0, height: 3.25, children: [] }) const building = BuildingNode.parse({ children: [level.id] }) const wall = WallNode.parse({ parentId: level.id, @@ -36,6 +36,7 @@ describe('buildLevelDuplicateCreateOps', () => { expect(sourceLevel.parentId).toBeNull() expect(levelCreateOp?.parentId).toBe(building.id) + expect(levelCreateOp?.node.type === 'level' ? levelCreateOp.node.height : undefined).toBe(3.25) }) test('does not copy spawn points from the source level', () => { diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts index ee370a8a..baa071ec 100644 --- a/packages/editor/src/lib/measurements.test.ts +++ b/packages/editor/src/lib/measurements.test.ts @@ -72,6 +72,11 @@ describe('linear measurements', () => { expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m') }) + test('formats metric measurements in whole millimeters', () => { + expect(formatLinearMeasurement(3.456, 'metric', 'millimeters')).toBe('3456mm') + expect(formatLinearMeasurement(-0.1524, 'metric', 'millimeters')).toBe('-152mm') + }) + test('formats imperial measurements as feet and inches', () => { expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`) expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`) diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts index 35e483c3..2257e193 100644 --- a/packages/editor/src/lib/measurements.ts +++ b/packages/editor/src/lib/measurements.ts @@ -1,6 +1,7 @@ import type { MeasurementPoint } from '@pascal-app/core' export type LinearUnit = 'metric' | 'imperial' +export type MetricNotation = 'meters' | 'millimeters' export const MEASUREMENT_ACTIVE_COLOR = '#6366f1' export const MEASUREMENT_DANGLING_COLOR = '#dc2626' @@ -173,7 +174,11 @@ export function formatVolumeLabel( return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}` } -export function formatLinearMeasurement(meters: number, unit: LinearUnit): string { +export function formatLinearMeasurement( + meters: number, + unit: LinearUnit, + metricNotation: MetricNotation = 'meters', +): string { if (!Number.isFinite(meters)) return '--' const absoluteMeters = Math.abs(meters) @@ -192,6 +197,12 @@ export function formatLinearMeasurement(meters: number, unit: LinearUnit): strin return `${sign}${wholeFeet}'${inches}"` } + if (metricNotation === 'millimeters') { + const roundedMillimeters = Math.round(absoluteMeters * 1000) + const sign = meters < 0 && roundedMillimeters !== 0 ? '-' : '' + return `${sign}${roundedMillimeters}mm` + } + const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2)) const sign = meters < 0 && roundedMeters !== 0 ? '-' : '' diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts index d0f1841a..e1410134 100644 --- a/packages/editor/src/lib/stair-levels.ts +++ b/packages/editor/src/lib/stair-levels.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + DEFAULT_LEVEL_HEIGHT, LevelNode, type LevelNode as LevelNodeType, resolveBuildingForLevel, @@ -154,6 +155,7 @@ export function resolveStairDestinationLevel({ if (createMissing && buildingId) { const createdLevel = LevelNode.parse({ children: [], + height: DEFAULT_LEVEL_HEIGHT, level: fromLevel.level + 1, parentId: buildingId, }) diff --git a/packages/editor/src/lib/window-interaction.ts b/packages/editor/src/lib/window-interaction.ts index 64c4ce99..2ed3ec4d 100644 --- a/packages/editor/src/lib/window-interaction.ts +++ b/packages/editor/src/lib/window-interaction.ts @@ -23,7 +23,7 @@ export function isOperableWindowType(windowType: string | undefined) { ) } -function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { +export function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) { const interactive = useInteractive.getState() const runtimeValue = interactive.windows[windowId]?.operationState if (runtimeValue !== undefined) return runtimeValue diff --git a/packages/editor/src/store/use-drawing-view.test.ts b/packages/editor/src/store/use-drawing-view.test.ts new file mode 100644 index 00000000..86ad65d6 --- /dev/null +++ b/packages/editor/src/store/use-drawing-view.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeAnnotationLayoutOverrides, normalizeDrawingType } from './use-drawing-view' + +describe('normalizeDrawingType', () => { + test('restores every persistent construction drawing type', () => { + expect(normalizeDrawingType('floor-plan')).toBe('floor-plan') + expect(normalizeDrawingType('foundation-plan')).toBe('foundation-plan') + expect(normalizeDrawingType('reflected-ceiling-plan')).toBe('reflected-ceiling-plan') + expect(normalizeDrawingType('roof-plan')).toBe('roof-plan') + expect(normalizeDrawingType('site-plan')).toBe('site-plan') + }) + + test('falls back to the floor plan for stale persisted values', () => { + expect(normalizeDrawingType('unknown')).toBe('floor-plan') + expect(normalizeDrawingType(null)).toBe('floor-plan') + }) +}) + +describe('normalizeAnnotationLayoutOverrides', () => { + test('keeps finite pinned drawing-view annotation offsets', () => { + expect( + normalizeAnnotationLayoutOverrides({ + a: { dx: 1.25, dy: -0.5, pinned: true }, + stale: { dx: Number.NaN, dy: 0, pinned: true }, + unpinned: { dx: 1, dy: 2, pinned: false }, + }), + ).toEqual({ + a: { dx: 1.25, dy: -0.5, pinned: true }, + }) + }) +}) diff --git a/packages/editor/src/store/use-drawing-view.ts b/packages/editor/src/store/use-drawing-view.ts new file mode 100644 index 00000000..f77d5ed1 --- /dev/null +++ b/packages/editor/src/store/use-drawing-view.ts @@ -0,0 +1,130 @@ +'use client' + +import { + CONSTRUCTION_DRAWING_TYPES, + type ConstructionDrawingType, + type DrawingSheetScale, +} from '@pascal-app/core' +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export const DRAWING_TYPE_OPTIONS = [ + { id: 'floor-plan', label: 'Floor plan' }, + { id: 'foundation-plan', label: 'Foundation plan' }, + { id: 'reflected-ceiling-plan', label: 'Reflected ceiling plan' }, + { id: 'roof-plan', label: 'Roof plan' }, + { id: 'site-plan', label: 'Site plan' }, +] as const satisfies readonly { id: ConstructionDrawingType; label: string }[] + +export const DRAWING_SCALE_OPTIONS = [ + { id: '1:20', label: '1:20' }, + { id: '1:25', label: '1:25' }, + { id: '1:50', label: '1:50' }, + { id: '1:75', label: '1:75' }, + { id: '1:100', label: '1:100' }, + { id: '1/8"=1\'-0"', label: '1/8" = 1\'-0"' }, + { id: '1/4"=1\'-0"', label: '1/4" = 1\'-0"' }, + { id: '1/2"=1\'-0"', label: '1/2" = 1\'-0"' }, + { id: '1"=1\'-0"', label: '1" = 1\'-0"' }, +] as const satisfies readonly { id: DrawingSheetScale; label: string }[] + +export type DrawingAnnotationLayoutOverride = { + dx: number + dy: number + pinned: true +} + +export type DrawingAnnotationLayoutOverrides = Record + +type DrawingViewState = { + drawingType: ConstructionDrawingType + drawingScale: DrawingSheetScale + annotationLayoutOverrides: DrawingAnnotationLayoutOverrides + setDrawingType: (drawingType: ConstructionDrawingType) => void + setDrawingScale: (drawingScale: DrawingSheetScale) => void + setAnnotationLayoutOverride: ( + id: string, + override: DrawingAnnotationLayoutOverride | null, + ) => void +} + +export function normalizeDrawingType(value: unknown): ConstructionDrawingType { + if (typeof value !== 'string') return 'floor-plan' + for (const drawingType of CONSTRUCTION_DRAWING_TYPES) { + if (drawingType === value) return drawingType + } + return 'floor-plan' +} + +export function normalizeDrawingScale(value: unknown): DrawingSheetScale { + if (typeof value !== 'string') return '1/4"=1\'-0"' + for (const option of DRAWING_SCALE_OPTIONS) { + if (option.id === value) return option.id + } + return '1/4"=1\'-0"' +} + +export function normalizeAnnotationLayoutOverrides( + value: unknown, +): DrawingAnnotationLayoutOverrides { + if (!value || typeof value !== 'object') return {} + const out: DrawingAnnotationLayoutOverrides = {} + for (const [id, raw] of Object.entries(value)) { + if (!id || !raw || typeof raw !== 'object') continue + const dx = (raw as { dx?: unknown }).dx + const dy = (raw as { dy?: unknown }).dy + const pinned = (raw as { pinned?: unknown }).pinned + if ( + typeof dx === 'number' && + Number.isFinite(dx) && + typeof dy === 'number' && + Number.isFinite(dy) && + pinned === true + ) { + out[id] = { dx, dy, pinned: true } + } + } + return out +} + +const useDrawingView = create()( + persist( + (set) => ({ + drawingType: 'floor-plan', + drawingScale: '1/4"=1\'-0"', + annotationLayoutOverrides: {}, + setDrawingType: (drawingType) => set({ drawingType }), + setDrawingScale: (drawingScale) => set({ drawingScale }), + setAnnotationLayoutOverride: (id, override) => + set((state) => { + const next = { ...state.annotationLayoutOverrides } + if (override) next[id] = override + else delete next[id] + return { annotationLayoutOverrides: next } + }), + }), + { + name: 'pascal-floorplan-drawing-view', + merge: (persistedState, currentState) => ({ + ...currentState, + drawingType: normalizeDrawingType( + (persistedState as { drawingType?: unknown } | undefined)?.drawingType, + ), + drawingScale: normalizeDrawingScale( + (persistedState as { drawingScale?: unknown } | undefined)?.drawingScale, + ), + annotationLayoutOverrides: normalizeAnnotationLayoutOverrides( + (persistedState as { annotationLayoutOverrides?: unknown } | undefined) + ?.annotationLayoutOverrides, + ), + }), + partialize: (state) => ({ + drawingType: state.drawingType, + drawingScale: state.drawingScale, + annotationLayoutOverrides: state.annotationLayoutOverrides, + }), + }, + ), +) + +export default useDrawingView diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 5bb6092a..f6a0734b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -140,7 +140,7 @@ export type Phase = 'site' | 'structure' | 'furnish' export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' // Structure mode tools (building elements) -export type StructureTool = +type BuiltInStructureTool = | 'wall' | 'fence' | 'room' @@ -149,6 +149,7 @@ export type StructureTool = | 'ceiling' | 'roof' | 'column' + | 'structural-grid' | 'elevator' | 'stair' | 'item' @@ -178,6 +179,9 @@ export type StructureTool = | 'pipe-fitting' | 'pipe-trap' +/** Registry node kinds are valid build tools without central union edits. */ +export type StructureTool = BuiltInStructureTool | (string & {}) + // Furnish mode tools (items and decoration) export type FurnishTool = 'item' | 'cabinet' diff --git a/packages/editor/src/store/use-first-person-hud.ts b/packages/editor/src/store/use-first-person-hud.ts new file mode 100644 index 00000000..b78a0fd9 --- /dev/null +++ b/packages/editor/src/store/use-first-person-hud.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand' + +export type WalkthroughInteract = { label: string; verb: string } | null + +export type FirstPersonHudState = { + floorLabel: string | null + zoneLabel: string | null + interact: WalkthroughInteract + setHud: (hud: Partial>) => void + reset: () => void +} + +export const useFirstPersonHud = create((set) => ({ + floorLabel: null, + zoneLabel: null, + interact: null, + setHud: (hud) => + set((state) => { + const floorLabel = hud.floorLabel === undefined ? state.floorLabel : hud.floorLabel + const zoneLabel = hud.zoneLabel === undefined ? state.zoneLabel : hud.zoneLabel + const interact = hud.interact === undefined ? state.interact : hud.interact + + if ( + floorLabel === state.floorLabel && + zoneLabel === state.zoneLabel && + interact?.label === state.interact?.label && + interact?.verb === state.interact?.verb + ) { + return state + } + + return { floorLabel, zoneLabel, interact } + }), + reset: () => + set((state) => + state.floorLabel === null && state.zoneLabel === null && state.interact === null + ? state + : { floorLabel: null, zoneLabel: null, interact: null }, + ), +})) diff --git a/packages/editor/src/store/use-floorplan-annotation-visibility.ts b/packages/editor/src/store/use-floorplan-annotation-visibility.ts new file mode 100644 index 00000000..2c9f2186 --- /dev/null +++ b/packages/editor/src/store/use-floorplan-annotation-visibility.ts @@ -0,0 +1,60 @@ +'use client' + +import { create } from 'zustand' +import { persist } from 'zustand/middleware' +import { + DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, + type FloorplanAnnotationCategory, + type FloorplanAnnotationVisibility, + normalizeFloorplanAnnotationVisibility, +} from '../lib/floorplan/annotation-visibility' +import { + DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE, + type FloorplanWallDimensionReference, + normalizeFloorplanWallDimensionReference, +} from '../lib/floorplan/floorplan-extension' + +type FloorplanAnnotationVisibilityState = { + visibility: FloorplanAnnotationVisibility + wallDimensionReference: FloorplanWallDimensionReference + setCategory: (category: FloorplanAnnotationCategory, visible: boolean) => void + setWallDimensionReference: (reference: FloorplanWallDimensionReference) => void + reset: () => void +} + +const useFloorplanAnnotationVisibility = create()( + persist( + (set) => ({ + visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY }, + wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE, + setCategory: (category, visible) => + set((state) => ({ visibility: { ...state.visibility, [category]: visible } })), + setWallDimensionReference: (wallDimensionReference) => set({ wallDimensionReference }), + reset: () => + set({ + visibility: { ...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY }, + wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE, + }), + }), + { + name: 'pascal-floorplan-annotation-visibility', + merge: (persistedState, currentState) => ({ + ...currentState, + visibility: normalizeFloorplanAnnotationVisibility( + (persistedState as { visibility?: unknown } | undefined)?.visibility, + ), + wallDimensionReference: normalizeFloorplanWallDimensionReference( + (persistedState as { wallDimensionReference?: unknown } | undefined) + ?.wallDimensionReference, + ), + }), + partialize: (state) => + ({ + visibility: state.visibility, + wallDimensionReference: state.wallDimensionReference, + }) as FloorplanAnnotationVisibilityState, + }, + ), +) + +export default useFloorplanAnnotationVisibility diff --git a/packages/editor/src/store/use-floorplan-preflight.test.ts b/packages/editor/src/store/use-floorplan-preflight.test.ts new file mode 100644 index 00000000..8dffa10d --- /dev/null +++ b/packages/editor/src/store/use-floorplan-preflight.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import useFloorplanPreflight, { type FloorplanPreflightIssue } from './use-floorplan-preflight' + +const COLLISION_ISSUE: FloorplanPreflightIssue = { + id: 'dimension-1', + kind: 'unresolved-collision', + severity: 'warning', + message: 'The same collision remains unresolved.', +} + +afterEach(() => { + useFloorplanPreflight.getState().setIssues([]) + useFloorplanPreflight.getState().setAuditIssues([]) +}) + +describe('useFloorplanPreflight', () => { + test('does not notify subscribers when layout issues are unchanged', () => { + const state = useFloorplanPreflight.getState() + state.setIssues([COLLISION_ISSUE]) + let notifications = 0 + const unsubscribe = useFloorplanPreflight.subscribe(() => { + notifications += 1 + }) + + useFloorplanPreflight.getState().setIssues([{ ...COLLISION_ISSUE }]) + + unsubscribe() + expect(notifications).toBe(0) + }) + + test('still publishes changed layout issues alongside audit issues', () => { + const state = useFloorplanPreflight.getState() + state.setAuditIssues([ + { + id: 'audit-1', + kind: 'dimension-completeness', + severity: 'info', + message: 'Audit issue', + }, + ]) + + useFloorplanPreflight.getState().setIssues([COLLISION_ISSUE]) + + expect(useFloorplanPreflight.getState().issues).toEqual([ + COLLISION_ISSUE, + expect.objectContaining({ id: 'audit-1' }), + ]) + }) +}) diff --git a/packages/editor/src/store/use-floorplan-preflight.ts b/packages/editor/src/store/use-floorplan-preflight.ts new file mode 100644 index 00000000..d00c7bd7 --- /dev/null +++ b/packages/editor/src/store/use-floorplan-preflight.ts @@ -0,0 +1,79 @@ +'use client' + +import { create } from 'zustand' + +export type FloorplanPreflightIssueKind = + | 'unresolved-collision' + | 'short-unreadable-segment' + | 'plan-geometry-conflict' + | 'dimension-completeness' + | 'clearance-advisory' + | 'module-advisory' + | 'sheet-content' + +export type FloorplanPreflightIssue = { + id: string + kind: FloorplanPreflightIssueKind + severity: 'info' | 'warning' + message: string +} + +function preflightIssuesEqual( + left: readonly FloorplanPreflightIssue[], + right: readonly FloorplanPreflightIssue[], +): boolean { + if (left.length !== right.length) return false + return left.every((issue, index) => { + const candidate = right[index] + return ( + candidate !== undefined && + issue.id === candidate.id && + issue.kind === candidate.kind && + issue.severity === candidate.severity && + issue.message === candidate.message + ) + }) +} + +type FloorplanPreflightState = { + issues: FloorplanPreflightIssue[] + layoutIssues: FloorplanPreflightIssue[] + auditIssues: FloorplanPreflightIssue[] + clearanceChecksEnabled: boolean + moduleChecksEnabled: boolean + setIssues: (issues: readonly FloorplanPreflightIssue[]) => void + setAuditIssues: (issues: readonly FloorplanPreflightIssue[]) => void + setClearanceChecksEnabled: (enabled: boolean) => void + setModuleChecksEnabled: (enabled: boolean) => void + reset: () => void +} + +export const useFloorplanPreflight = create((set) => ({ + issues: [], + layoutIssues: [], + auditIssues: [], + clearanceChecksEnabled: false, + moduleChecksEnabled: false, + setIssues: (issues) => + set((state) => + preflightIssuesEqual(state.layoutIssues, issues) + ? state + : { layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] }, + ), + setAuditIssues: (issues) => + set((state) => + preflightIssuesEqual(state.auditIssues, issues) + ? state + : { auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] }, + ), + setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }), + setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }), + reset: () => + set((state) => + state.layoutIssues.length === 0 + ? state + : { layoutIssues: [], issues: [...state.auditIssues] }, + ), +})) + +export default useFloorplanPreflight diff --git a/packages/mcp/src/templates/empty-studio.ts b/packages/mcp/src/templates/empty-studio.ts index 10a4b7c0..75c9de4c 100644 --- a/packages/mcp/src/templates/empty-studio.ts +++ b/packages/mcp/src/templates/empty-studio.ts @@ -1,3 +1,4 @@ +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { SceneGraph } from '@pascal-app/core/clone-scene-graph' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' @@ -137,7 +138,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [-W, -D], end: [W, -D], frontSide: 'unknown', @@ -153,7 +153,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: [], thickness: 0.1, - height: 2.5, start: [W, -D], end: [W, D], frontSide: 'unknown', @@ -169,7 +168,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['door_front'], thickness: 0.1, - height: 2.5, start: [W, D], end: [-W, D], frontSide: 'unknown', @@ -185,7 +183,6 @@ function buildNodes(): StudioNodes { metadata: {}, children: ['window_w'], thickness: 0.1, - height: 2.5, start: [-W, D], end: [-W, -D], frontSide: 'unknown', @@ -217,6 +214,7 @@ function buildNodes(): StudioNodes { visible: true, metadata: {}, level: 0, + height: DEFAULT_LEVEL_HEIGHT, children: [...wallIds, 'zone_living'] as AnyNodeId[], } as unknown as AnyNode diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts index 5dd8dcd7..4e695ac3 100644 --- a/packages/mcp/src/templates/garden-house.ts +++ b/packages/mcp/src/templates/garden-house.ts @@ -41,7 +41,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -250,6 +249,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts index d41a14b1..dcd8dd1e 100644 --- a/packages/mcp/src/templates/two-bedroom.ts +++ b/packages/mcp/src/templates/two-bedroom.ts @@ -45,7 +45,6 @@ function wall( metadata: {}, children, thickness: WALL_THICKNESS, - height: WALL_HEIGHT, start, end, frontSide: 'unknown', @@ -274,6 +273,7 @@ function buildTemplate(): SceneGraph { visible: true, metadata: {}, level: 0, + height: WALL_HEIGHT, children: [ 'wall_n', 'wall_e', diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index a8e43e92..307d6f88 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveStairTotalRise } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { CeilingNode, @@ -23,9 +24,10 @@ const RAILING_MODES = ['none', 'left', 'right', 'both'] as const export const createStoryShellInput = { levelId: NodeIdSchema, footprint: z.array(Vec2Schema).min(3), - wallHeight: measurement('length', 'm', { positive: true, description: 'Wall height.' }).default( - 2.8, - ), + wallHeight: measurement('length', 'm', { + positive: true, + description: 'Explicit wall height override. Omit for level-plane-bound walls.', + }).optional(), wallThickness: measurement('length', 'm', { positive: true, description: 'Wall thickness.', @@ -100,7 +102,7 @@ export const createStairBetweenLevelsInput = { totalRise: measurement('length', 'm', { positive: true, description: 'Total vertical rise.', - }).default(2.8), + }).optional(), stepCount: z.number().int().positive().default(14), railingMode: z.enum(RAILING_MODES).default('both'), destinationSlabId: NodeIdSchema.optional(), @@ -276,7 +278,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat start: points[i], end: points[(i + 1) % points.length], thickness: wallThickness, - height: wallHeight, + ...(wallHeight !== undefined ? { height: wallHeight } : {}), frontSide: 'exterior', backSide: 'interior', ...(wallMaterialPreset ? { materialPreset: wallMaterialPreset } : {}), @@ -292,6 +294,11 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat name: namePrefix ? `${namePrefix} Slab` : undefined, polygon: points, elevation: slabElevation, + // Grounded solid: underside on the level plane, so the created + // story slab occupies [0, slabElevation] like the legacy + // extrude-from-zero model. + thickness: Math.max(slabElevation, 0), + recessed: slabElevation < 0, ...(slabMaterialPreset ? { materialPreset: slabMaterialPreset } : {}), metadata: { role: 'story-slab' }, }) @@ -301,10 +308,13 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat let ceilingId: string | null = null if (createCeiling) { + // Height-less unless the caller pinned one: a new story ceiling + // follows the level top automatically. + const explicitCeilingHeight = ceilingHeight ?? wallHeight const ceiling = CeilingNode.parse({ name: namePrefix ? `${namePrefix} Ceiling` : undefined, polygon: points, - height: ceilingHeight ?? wallHeight, + ...(explicitCeilingHeight !== undefined ? { height: explicitCeilingHeight } : {}), ...(ceilingMaterialPreset ? { materialPreset: ceilingMaterialPreset } : {}), metadata: { role: 'story-ceiling' }, }) @@ -372,12 +382,12 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat const roofLevel = LevelNode.parse({ name: roofLevelLabel, level: roofLevelElevation ?? nextLevelIndex(bridge, buildingId, referenceLevel), + height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), children: [], metadata: { role: 'roof', label: roofLevelLabel, referenceLevelId: levelId, - height: roofLevelHeight ?? Math.max(wallHeight + peakHeight, 0.2), }, }) targetRoofLevelId = roofLevel.id as AnyNodeId @@ -460,15 +470,7 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat ) } - const segment = StairSegmentNode.parse({ - segmentType: 'stair', - width, - length: runLength, - height: totalRise, - stepCount, - ...(materialPreset ? { materialPreset } : {}), - }) - const stair = StairNode.parse({ + const stairDraft = StairNode.parse({ name: name ?? 'Stair', position: position as [number, number, number], rotation, @@ -478,15 +480,33 @@ export function registerConstructionTools(server: McpServer, bridge: SceneOperat slabOpeningMode: 'none', openingOffset, width, - totalRise, + ...(totalRise !== undefined ? { totalRise } : {}), stepCount, railingMode, - children: [segment.id], + children: [], ...(materialPreset ? { materialPreset } : {}), metadata: { openingManaged: 'manual-rectangular', }, }) + const riseNodes = { + ...bridge.getNodes(), + [fromLevel.id]: { + ...fromLevel, + children: [...(fromLevel as Extract).children, stairDraft.id], + }, + [stairDraft.id]: stairDraft, + } as Record + const resolvedTotalRise = resolveStairTotalRise(stairDraft, riseNodes) + const segment = StairSegmentNode.parse({ + segmentType: 'stair', + width, + length: runLength, + height: resolvedTotalRise, + stepCount, + ...(materialPreset ? { materialPreset } : {}), + }) + const stair = { ...stairDraft, children: [segment.id] } const openingPolygon = rectangularOpening({ position: position as [number, number, number], diff --git a/packages/mcp/src/tools/create-level.test.ts b/packages/mcp/src/tools/create-level.test.ts index cf548daa..268876b6 100644 --- a/packages/mcp/src/tools/create-level.test.ts +++ b/packages/mcp/src/tools/create-level.test.ts @@ -20,11 +20,11 @@ describe('create_level', () => { await Promise.all([server.connect(srvT), client.connect(cliT)]) }) - test('creates a level on a building', async () => { + test('appends a level on a building with stored height', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const result = await client.callTool({ name: 'create_level', - arguments: { buildingId: building.id, elevation: 3, label: 'Second' }, + arguments: { buildingId: building.id, elevation: 99, height: 3.1, label: 'Second' }, }) expect(result.isError).toBeFalsy() const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) @@ -32,7 +32,9 @@ describe('create_level', () => { const created = bridge.getNode(parsed.levelId) expect(created).not.toBeNull() expect(created!.type).toBe('level') - expect((created as { level: number }).level).toBe(3) + expect((created as { height: number; level: number }).level).toBe(1) + expect((created as { height: number; level: number }).height).toBe(3.1) + expect(created?.metadata.height).toBeUndefined() }) test('rejects unknown building id', async () => { diff --git a/packages/mcp/src/tools/create-level.ts b/packages/mcp/src/tools/create-level.ts index 784297c4..06ce61bd 100644 --- a/packages/mcp/src/tools/create-level.ts +++ b/packages/mcp/src/tools/create-level.ts @@ -1,4 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { DEFAULT_LEVEL_HEIGHT } from '@pascal-app/core' import type { AnyNodeId } from '@pascal-app/core/schema' import { LevelNode } from '@pascal-app/core/schema' import { z } from 'zod' @@ -10,10 +11,13 @@ import { NodeIdSchema } from './schemas' export const createLevelInput = { buildingId: NodeIdSchema, - elevation: z.number().optional(), + elevation: z + .number() + .optional() + .describe("Legacy parameter; new levels are appended above the building's current top level."), height: measurement('length', 'm', { min: 0, - description: 'Level height (stored in metadata).', + description: 'Stored floor-to-floor storey height.', }).optional(), label: z.string().optional(), } @@ -28,11 +32,11 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): { title: 'Create level', description: - 'Create a new level node attached to the given building. height and label are stored in metadata.', + "Append a new level above the given building's current top level. height is stored as the level's floor-to-floor storey height.", inputSchema: createLevelInput, outputSchema: createLevelOutput, }, - async ({ buildingId, elevation, height, label }) => { + async ({ buildingId, height, label }) => { const parent = bridge.getNode(buildingId as AnyNodeId) if (!parent) { throwMcpError(ErrorCode.InvalidParams, `Building not found: ${buildingId}`) @@ -45,11 +49,16 @@ export function registerCreateLevel(server: McpServer, bridge: SceneOperations): } const metadata: Record = {} - if (height !== undefined) metadata.height = height if (label !== undefined) metadata.label = label + const existingOrdinals = bridge + .getChildren(buildingId as AnyNodeId) + .filter((node) => node.type === 'level') + .map((node) => node.level) + const nextOrdinal = Math.max(-1, ...existingOrdinals) + 1 const levelNode = LevelNode.parse({ - level: elevation ?? 0, + level: nextOrdinal, + height: height ?? DEFAULT_LEVEL_HEIGHT, children: [], ...(Object.keys(metadata).length > 0 ? { metadata } : {}), ...(label !== undefined ? { name: label } : {}), diff --git a/packages/mcp/src/tools/describe-node.ts b/packages/mcp/src/tools/describe-node.ts index 8be93177..62832351 100644 --- a/packages/mcp/src/tools/describe-node.ts +++ b/packages/mcp/src/tools/describe-node.ts @@ -1,8 +1,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { resolveCeilingHeight } from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' import { z } from 'zod' import type { SceneOperations } from '../operations' import { ErrorCode, throwMcpError } from './errors' +import { resolveReportedWallHeight } from './scene-query' import { NodeIdSchema } from './schemas' export const describeNodeInput = { @@ -23,13 +25,13 @@ export const describeNodeOutput = { * Build a short, human-readable one-liner describing the node. * Covers the common shapes; falls back to a generic sentence otherwise. */ -function describe(node: AnyNode): string { +function describe(node: AnyNode, bridge: SceneOperations): string { switch (node.type) { case 'wall': { const [x1, z1] = node.start const [x2, z2] = node.end const t = node.thickness ?? 0.1 - const h = node.height ?? 2.5 + const h = resolveReportedWallHeight(bridge, node) return `Wall from (${x1},${z1}) to (${x2},${z2}), thickness ${t.toFixed(2)}m, height ${h.toFixed(2)}m` } case 'level': @@ -45,7 +47,7 @@ function describe(node: AnyNode): string { case 'slab': return `Slab with ${node.polygon.length} vertices` case 'ceiling': - return `Ceiling with ${node.polygon.length} vertices, height ${node.height.toFixed(2)}m` + return `Ceiling with ${node.polygon.length} vertices, height ${resolveCeilingHeight(node, bridge.getNodes()).toFixed(2)}m` case 'door': return `Door (${node.width.toFixed(2)}m x ${node.height.toFixed(2)}m)` case 'window': @@ -83,14 +85,22 @@ export function registerDescribeNode(server: McpServer, bridge: SceneOperations) const childrenIds = children.map((n) => n.id as string) const n = node as AnyNode + const properties = + n.type === 'wall' + ? { + ...n, + resolvedHeight: resolveReportedWallHeight(bridge, n), + heightIsExplicit: n.height !== undefined, + } + : n const payload = { id: n.id as string, type: n.type as string, parentId: (n.parentId ?? null) as string | null, ancestryIds, childrenIds, - properties: n as unknown as Record, - description: describe(n), + properties: properties as unknown as Record, + description: describe(n, bridge), } return { diff --git a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts index 96d2d2b1..3679a384 100644 --- a/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts +++ b/packages/mcp/src/tools/photo-to-scene/photo-to-scene.ts @@ -55,6 +55,7 @@ const VisionResponseSchema = z.object({ start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), thickness: z.number().optional(), + height: z.number().positive().optional(), }), ), rooms: z.array( @@ -82,13 +83,14 @@ const SYSTEM_PROMPT = `You are a vision assistant that extracts structured floor Your ONLY job: return a JSON object that exactly matches this schema — no prose, no markdown fences. { - "walls": [{ "start": [x, z], "end": [x, z], "thickness": number? }, ...], + "walls": [{ "start": [x, z], "end": [x, z], "thickness": number?, "height": number? }, ...], "rooms": [{ "name": string, "polygon": [[x,z], ...], "approximateAreaSqM": number? }, ...], "approximateDimensions": { "widthM": number, "depthM": number }, "confidence": number 0..1 } Coordinates are in metres. Origin can be the floor plan's centre or bottom-left — be consistent. +Only include a wall height when it is visibly measured or annotated in the image. If the image is unclear, lower the confidence score but still produce your best attempt. DO NOT wrap the JSON in markdown. DO NOT explain. Just output the raw JSON.` @@ -235,7 +237,7 @@ function buildSceneGraphFromVision( // Build the skeleton: site → building → level. const building = BuildingNode.parse({}) - const level = LevelNode.parse({ level: 0 }) + const level = LevelNode.parse({ level: 0, height: defaultWallHeight }) const site = SiteNode.parse({ children: [building.id] }) // Link parent ids so downstream traversal works. @@ -283,7 +285,7 @@ function buildSceneGraphFromVision( start: w.start, end: w.end, thickness: w.thickness ?? defaultWallThickness, - height: defaultWallHeight, + ...(w.height !== undefined ? { height: w.height } : {}), }) const linkedWall: AnyNodeT = { ...(wall as AnyNodeT), diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 22f7cc2d..b3eac2f4 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -1,5 +1,13 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + DEFAULT_LEVEL_HEIGHT, + getStoredLevelHeight, + getWallPlaneTop, + resolveStairTotalRise, + resolveWallEffectiveHeight, +} from '@pascal-app/core' import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema' +import { computeWallSlabSupport } from '@pascal-app/core/spatial-grid' import { z } from 'zod' import type { SceneOperations } from '../operations' import { @@ -111,6 +119,27 @@ function nodesOnLevel(bridge: SceneOperations, levelId: AnyNodeId): AnyNode[] { ) } +export function resolveReportedWallHeight( + bridge: SceneOperations, + wall: Extract, +): number { + const levelId = bridge.resolveLevelId(wall.id as AnyNodeId) + // Covering-clamped plane for plane-bound walls; explicit heights pass + // through resolveWallEffectiveHeight untouched. + const planeTop = levelId + ? getWallPlaneTop(wall, levelId, bridge.getNodes()) + : DEFAULT_LEVEL_HEIGHT + const levelNodes = levelId ? nodesOnLevel(bridge, levelId) : [] + const slabs = levelNodes.filter( + (node): node is Extract => node.type === 'slab', + ) + const walls = levelNodes.filter( + (node): node is Extract => node.type === 'wall', + ) + const support = computeWallSlabSupport(wall, slabs, walls, wall.supportSlabId) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + function metadataRecord(node: AnyNode): Record | null { return typeof node.metadata === 'object' && node.metadata !== null ? (node.metadata as Record) @@ -159,6 +188,7 @@ function openingSummaries(bridge: SceneOperations, wallId: AnyNodeId) { function wallSummary(bridge: SceneOperations, wall: AnyNode) { if (wall.type !== 'wall') return null const length = distance2D(wall.start, wall.end) + const resolvedHeight = resolveReportedWallHeight(bridge, wall) return { id: wall.id, name: wall.name, @@ -166,6 +196,8 @@ function wallSummary(bridge: SceneOperations, wall: AnyNode) { end: wall.end, length: Math.round(length * 100) / 100, height: wall.height, + resolvedHeight, + heightIsExplicit: wall.height !== undefined, thickness: wall.thickness, openings: openingSummaries(bridge, wall.id as AnyNodeId), } @@ -308,7 +340,7 @@ function stairFootprintPolygons( { width: stair.width ?? 1, length: 3, - height: stair.totalRise ?? 2.5, + height: resolveStairTotalRise(stair, nodes), stepCount: stair.stepCount ?? 10, attachmentSide: 'front' as const, }, @@ -657,17 +689,11 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (level.type !== 'level') continue const summary = levels.find((entry) => entry.levelId === level.id) if (!summary?.isOccupiedStory) continue - const expectedHeight = - typeof level.metadata === 'object' && - level.metadata !== null && - 'height' in level.metadata && - typeof level.metadata.height === 'number' - ? level.metadata.height - : 3.2 + const expectedHeight = getStoredLevelHeight(level) for (const wall of nodesOnLevel(bridge, level.id as AnyNodeId).filter( (node): node is AnyNode & { type: 'wall' } => node.type === 'wall', )) { - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, wall) if (wallHeight > expectedHeight + 0.25) { issues.push( `Wall ${wall.name ?? wall.id} on ${level.name ?? level.id} is ${wallHeight}m high; multi-story exterior walls should be split into level-owned story walls`, @@ -699,7 +725,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): if (localX - width / 2 < -0.01 || localX + width / 2 > length + 0.01) { issues.push(`${node.type} ${node.id} extends outside wall ${parent.id}`) } - const wallHeight = parent.height ?? 2.5 + const wallHeight = resolveReportedWallHeight(bridge, parent) const bottom = node.position[1] - height / 2 const top = node.position[1] + height / 2 if (bottom < -0.01 || top > wallHeight + 0.01) { diff --git a/packages/nodes/src/building/definition.test.ts b/packages/nodes/src/building/definition.test.ts new file mode 100644 index 00000000..63057987 --- /dev/null +++ b/packages/nodes/src/building/definition.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test' +import { buildingDefinition } from './definition' + +describe('buildingDefinition', () => { + test('tracks drawing-sheet child support in the schema version', () => { + expect(buildingDefinition.kind).toBe('building') + expect(buildingDefinition.schemaVersion).toBe(2) + expect( + buildingDefinition.schema.safeParse({ + id: 'building_default', + type: 'building', + ...buildingDefinition.defaults(), + children: ['level_main', 'drawing-sheet_a101'], + }).success, + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index 0b0522b7..20f7e305 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -12,7 +12,7 @@ import { BuildingNode } from './schema' */ export const buildingDefinition: NodeDefinition = { kind: 'building', - schemaVersion: 1, + schemaVersion: 2, schema: BuildingNode, category: 'site', diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index a07a4b59..56672a18 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -15,6 +15,7 @@ import { movingFootprintAnchors, nodeRegistry, resolveAlignment, + resolveSupportSlabPatch, spatialGridManager, useScene, type WallEvent, @@ -797,6 +798,7 @@ const CabinetTool = () => { name: island ? 'Kitchen Island' : 'Modular Cabinet', position, rotation: yaw, + parentId: activeLevelId, depth: patch.depth ?? cabinetDefinition.defaults().depth, carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, ...(island && { @@ -841,11 +843,16 @@ const CabinetTool = () => { buildModule(m.x, m.width, index), ) for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + const liveRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet + sceneApi.update( + liveRun.id as AnyNodeId, + resolveSupportSlabPatch(liveRun, sceneApi.nodes()), + ) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) sceneApi.resumeHistory() return { endModule: modules[modules.length - 1]!, - run: sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet, + run: sceneApi.get(cabinet.id as AnyNodeId) ?? liveRun, } } @@ -889,6 +896,19 @@ const CabinetTool = () => { } bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + const liveNextRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun + sceneApi.update( + liveNextRun.id as AnyNodeId, + resolveSupportSlabPatch(liveNextRun, sceneApi.nodes()), + ) + const rootRun = chainRootRunRef.current + if (rootRun) { + const liveRoot = sceneApi.get(rootRun.id as AnyNodeId) ?? rootRun + sceneApi.update( + liveRoot.id as AnyNodeId, + resolveSupportSlabPatch(liveRoot, sceneApi.nodes()), + ) + } sceneApi.resumeHistory() return { endModule: anchorModule, @@ -996,11 +1016,16 @@ const CabinetTool = () => { } const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) const module = buildModule(0, previewNode.width, 0) + const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module } + const committedCabinet = CabinetNode.parse({ + ...cabinet, + ...resolveSupportSlabPatch(cabinet, nodes), + }) useScene.getState().createNodes([ - { node: cabinet, parentId: activeLevelId }, - { node: module, parentId: cabinet.id }, + { node: committedCabinet, parentId: activeLevelId }, + { node: module, parentId: committedCabinet.id }, ]) - bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(committedCabinet.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) useEditor.getState().setMode('select') triggerSFX('sfx:item-place') diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index f0e96317..3ed66c0c 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { boundaryReshapeScope, clearCeilingSnapFeedback, @@ -173,7 +179,7 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = onVertexHoverChange={handleHandleHoverChange} polygon={effectiveCeiling.polygon} resolvePlanPoint={resolvePolygonEditorPlanPoint} - surfaceHeight={effectiveCeiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(effectiveCeiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 6dbc39eb..fc8b8635 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -1,7 +1,12 @@ -import type { - CeilingNode as CeilingNodeType, - HandleDescriptor, - NodeDefinition, +import { + type AnyNodeId, + type CeilingNode as CeilingNodeType, + getCeilingClampBound, + type HandleDescriptor, + type NodeDefinition, + resolveCeilingHeight, + type SceneApi, + useScene, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildCeilingFloorplan } from './floorplan' @@ -20,6 +25,18 @@ import { ceilingSlots } from './slots' const HEIGHT_HANDLE_OFFSET = 0.22 const MIN_CEILING_HEIGHT = 0.5 +// Ceilings no longer drive the storey height; the stored level height +// does. Height writes clamp under min(storey plane, lowest underside of +// any covering slab from the level above) − CEILING_CLAMP_MARGIN, so a +// ceiling can poke into neither the level above nor a deck hanging from +// it (clamp, never ask). +function ceilingHeightBound(n: CeilingNodeType, sceneApi: SceneApi): number { + const parent = n.parentId ? sceneApi.get(n.parentId as AnyNodeId) : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, sceneApi.nodes(), n.polygon ?? []) + : Number.POSITIVE_INFINITY +} + function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { const polygon = n.polygon ?? [] if (polygon.length === 0) return [0, 0] @@ -38,8 +55,12 @@ function ceilingPolygonCenter(n: CeilingNodeType): [number, number] { // the cursor upward grows the value directly. Live override + commit // flow comes from the shared registry arrow pipeline. // +// `currentValue` resolves through `resolveCeilingHeight`, and `apply` +// always writes `height` — so dragging a follows-mode ceiling converts +// it to an explicit custom height, mirroring the wall top-drag. +// // The placement Y is in *mesh-local* coords. CeilingSystem already -// parks `mesh.position.y = ceiling.height - 0.01`, so the local Y is +// parks `mesh.position.y = resolved height - 0.01`, so the local Y is // just the offset above that plane (NOT `height + offset` — that // would double-add the height and push the arrow off-screen). function ceilingHeightHandle(): HandleDescriptor { @@ -48,7 +69,8 @@ function ceilingHeightHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CEILING_HEIGHT, - currentValue: (n) => n.height ?? 2.5, + max: ceilingHeightBound, + currentValue: (n) => resolveCeilingHeight(n, useScene.getState().nodes), apply: (_n, newValue) => ({ height: newValue }), placement: { position: (n) => { @@ -87,6 +109,8 @@ export const ceilingDefinition: NodeDefinition = { category: 'structure', surfaceRole: 'ceiling', + // Height-less on purpose: a new ceiling follows the level top until the + // user gives it an explicit custom height. defaults: () => ({ object: 'node', parentId: null, @@ -96,14 +120,15 @@ export const ceilingDefinition: NodeDefinition = { polygon: [], holes: [], holeMetadata: [], - height: 2.5, autoFromWalls: false, }), capabilities: { selectable: { hitVolume: 'bbox' }, surfaces: { - top: { height: (n) => (n as CeilingNode).height }, + top: { + height: (n) => resolveCeilingHeight(n as CeilingNodeType, useScene.getState().nodes), + }, }, duplicable: true, deletable: true, @@ -124,7 +149,7 @@ export const ceilingDefinition: NodeDefinition = { features: (node) => polygonMeasurementFeatures({ featurePrefix: 'ceiling', - height: node.height, + height: resolveCeilingHeight(node, useScene.getState().nodes), label: 'Ceiling', polygon: node.polygon, }), diff --git a/packages/nodes/src/ceiling/floorplan-move.ts b/packages/nodes/src/ceiling/floorplan-move.ts index 42c0e2ef..6eb715e3 100644 --- a/packages/nodes/src/ceiling/floorplan-move.ts +++ b/packages/nodes/src/ceiling/floorplan-move.ts @@ -1,4 +1,4 @@ -import type { CeilingNode, FloorplanMoveTarget } from '@pascal-app/core' +import { type CeilingNode, type FloorplanMoveTarget, resolveCeilingHeight } from '@pascal-app/core' import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move' /** @@ -6,14 +6,14 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move * centroid-pivot mover (same pivot semantics as slab / items). See * `shared/polygon-centroid-move.ts` for the rationale. * - * `meshY = height − 0.01`: `CeilingSystem` parks the ceiling group at that Y - * on rebuild, so mirroring it during the drag avoids a vertical teleport in - * split view. + * `meshY = resolved height − 0.01`: `CeilingSystem` parks the ceiling group + * at that Y on rebuild, so mirroring it during the drag avoids a vertical + * teleport in split view. */ export const ceilingFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => createPolygonCentroidMoveTarget({ node, nodes, - meshY: (node.height ?? 2.5) - 0.01, + meshY: resolveCeilingHeight(node, nodes) - 0.01, extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined, }) diff --git a/packages/nodes/src/ceiling/hole-editor.tsx b/packages/nodes/src/ceiling/hole-editor.tsx index 1b0ac28c..b418112d 100644 --- a/packages/nodes/src/ceiling/hole-editor.tsx +++ b/packages/nodes/src/ceiling/hole-editor.tsx @@ -1,6 +1,12 @@ 'use client' -import { type CeilingNode, resolveLevelId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { + type CeilingNode, + resolveCeilingHeight, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' import { PolygonEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect } from 'react' @@ -64,7 +70,7 @@ export const CeilingHoleEditor: React.FC<{ onPolygonChange={handlePolygonChange} onPolygonPreview={handlePolygonPreview} polygon={hole} - surfaceHeight={ceiling.height ?? 2.5} + surfaceHeight={resolveCeilingHeight(ceiling, useScene.getState().nodes)} /> ) } diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index ac52e65e..43387a80 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -8,6 +8,7 @@ import { type GridEvent, polygonAnchors, resolveAlignment, + resolveCeilingHeight, sceneRegistry, snapScalar, useLiveTransforms, @@ -99,7 +100,8 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { (node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])), ) const originalCenterRef = useRef(getPolygonCenter(originalPolygonRef.current)) - const heightRef = useRef(node.height ?? 2.5) + // Resolved once at drag start — the ceiling plane can't change mid-move. + const heightRef = useRef(resolveCeilingHeight(node, useScene.getState().nodes)) const dragAnchorRef = useRef<[number, number] | null>(null) const previousGridPosRef = useRef<[number, number] | null>(null) const deltaRef = useRef<[number, number]>([0, 0]) @@ -254,7 +256,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => { diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 219b695f..07754c03 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -1,12 +1,20 @@ 'use client' -import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' +import { + type AnyNode, + type CeilingNode, + getCeilingClampBound, + resolveCeilingHeight, + useScene, +} from '@pascal-app/core' import { ActionButton, ActionGroup, + formatLinearMeasurement, holeEditScope, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useEditingHole, @@ -27,6 +35,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function CeilingPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -35,11 +44,40 @@ export function CeilingPanel() { selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined, ) + // Ceilings no longer drive the storey height — the stored level height + // does — so height writes clamp under min(storey plane, lowest covering + // slab underside from the level above) − margin instead of poking into + // the level above or a deck hanging from it (clamp, never ask). + // Selector returns a primitive, so recomputing per store update is + // re-render-safe. + const maxHeight = useScene((s) => { + const parent = node?.parentId ? s.nodes[node.parentId as AnyNode['id']] : undefined + return parent?.type === 'level' + ? getCeilingClampBound(parent.id, s.nodes, node?.polygon ?? []) + : 6 + }) + + // Effective height: the stored custom height, or — for follows-mode + // ceilings (absent `height`) — the live level-top bound. Primitive + // selector so it tracks level-height / covering-slab edits. + const resolvedHeight = useScene((s) => { + const ceiling = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) + : undefined + return ceiling?.type === 'ceiling' ? resolveCeilingHeight(ceiling, s.nodes) : 2.5 + }) + // Panel slider-drag fix recipe (plans/editor-node-registry.md): stable // handler refs so slider drags don't trigger Maximum update depth. const nodeRef = useRef(node) nodeRef.current = node + const maxHeightRef = useRef(maxHeight) + maxHeightRef.current = maxHeight + + const resolvedHeightRef = useRef(resolvedHeight) + resolvedHeightRef.current = resolvedHeight + const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return @@ -48,6 +86,31 @@ export function CeilingPanel() { [selectedId], ) + const handleHeightChange = useCallback( + (proposed: number) => { + handleUpdate({ height: Math.min(proposed, maxHeightRef.current) }) + }, + [handleUpdate], + ) + + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current resolved height so the surface doesn't + // jump at the moment of detaching from the level top. + handleUpdate({ height: Math.min(resolvedHeightRef.current, maxHeightRef.current) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = follows the level top; the store strips + // undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -156,6 +219,22 @@ export function CeilingPanel() { } const area = calculateArea(node.polygon) + const isFollows = node.height == null + + // Clean preset values per display system; imperial stores exact meters + // for 8'0" / 8'6" / 9'0" ceilings. + const heightPresets = + unit === 'imperial' + ? [ + { label: 'Low (8\'0")', height: 2.4384 }, + { label: 'Standard (8\'6")', height: 2.5908 }, + { label: 'High (9\'0")', height: 2.7432 }, + ] + : [ + { label: 'Low (2.4m)', height: 2.4 }, + { label: 'Standard (2.5m)', height: 2.5 }, + { label: 'High (3.0m)', height: 3.0 }, + ] return ( - handleUpdate({ height: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.height * 1000) / 1000} + + {isFollows ? ( +
+ Currently {formatLinearMeasurement(resolvedHeight, unit)} +
+ ) : ( + + )} + {/* Presets write an explicit height (clamped to the bound), so + clicking one on a follows-mode ceiling switches it to custom. */}
- handleUpdate({ height: 2.4 })} /> - handleUpdate({ height: 2.5 })} /> - handleUpdate({ height: 3.0 })} /> + {heightPresets.map((preset) => ( + handleHeightChange(preset.height)} + /> + ))}
diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 9f49086c..781c4c5a 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getMaterialPresetByRef, + resolveCeilingHeight, resolveMaterial, useLiveTransforms, useRegistry, @@ -46,7 +47,11 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { // ceiling slot references re-tints it live. const sceneMaterials = useScene((s) => s.materials) const liveTransform = useLiveTransforms((s) => s.get(node.id)) - const ceilingY = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) + // Resolved height: explicit when stored, else the live level-top bound + // (primitive selector, so follows-mode ceilings track level-height edits + // and covering-slab changes without a node write). + const resolvedHeight = useScene((s) => resolveCeilingHeight(node, s.nodes)) + const ceilingY = resolvedHeight - 0.01 + (liveTransform?.position[1] ?? 0) const position: [number, number, number] = [ liveTransform?.position[0] ?? 0, ceilingY, diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 016ef83d..109df7c8 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -1,10 +1,15 @@ import { ColumnNode as ColumnNodeSchema, type ColumnNode as ColumnNodeType, + type GroupMoveSnapArgs, type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' -import { buildColumnFloorplan } from './floorplan' +import { + collectStructuralGridAxes, + resolveStructuralGridSnap, +} from '../structural-grid/coordination' +import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan' import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances' import { columnFloorplanMoveTarget } from './floorplan-move' import { columnPaint } from './paint' @@ -295,6 +300,18 @@ function columnHandles(node: ColumnNodeType): HandleDescriptor[] return handles } +function resolveColumnStructuralGridMoveSnap({ + candidatePosition, + nodes, + levelId, +}: GroupMoveSnapArgs): [number, number, number] | null { + const snap = resolveStructuralGridSnap( + [candidatePosition[0], candidatePosition[2]], + collectStructuralGridAxes(nodes, levelId), + ) + return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null +} + /** * Column — Stage A registration. Wrap-export of the legacy * `ColumnRenderer` (no system — column geometry is computed inline in @@ -334,7 +351,11 @@ export const columnDefinition: NodeDefinition = { // Generic 3D translate-on-XZ via `MoveRegistryNodeTool` (grid snap + the // mode-driven snapping the overhaul standardised). 2D move keeps using // `floorplanMoveTarget`, which wins the 2D move dispatch. - movable: { axes: ['x', 'z'], gridSnap: true }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + groupMoveSnap: resolveColumnStructuralGridMoveSnap, + }, slots: (node) => columnSlots(node as ColumnNodeType), paint: columnPaint, // Slab elevation lift via the generic `` + the @@ -374,6 +395,8 @@ export const columnDefinition: NodeDefinition = { { key: 'Left click', label: 'Place column' }, { key: 'Esc', label: 'Cancel' }, ], + computeFloorplanLevelData: computeColumnFloorplanLevelData, + floorplanDependsOnSiblings: true, floorplan: buildColumnFloorplan, // 2D body move routes through this kind-specific target so the column // aligns by its footprint *edges* (and snaps flush to wall faces) instead diff --git a/packages/nodes/src/column/floorplan-move.ts b/packages/nodes/src/column/floorplan-move.ts index 9cee5e06..92eb441c 100644 --- a/packages/nodes/src/column/floorplan-move.ts +++ b/packages/nodes/src/column/floorplan-move.ts @@ -20,6 +20,10 @@ import { type WallPlanPoint, } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' +import { + collectStructuralGridAxes, + resolveStructuralGridSnap, +} from '../structural-grid/coordination' /** * 2D floor-plan move handler for column. Columns need the same footprint-edge @@ -65,10 +69,15 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ nod candidates, { applySnap: isMagneticSnapActive() }, ) - const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] + const structuralSnap = + isGridSnapActive() || isMagneticSnapActive() + ? resolveStructuralGridSnap(snapped, collectStructuralGridAxes(nodes, node.parentId)) + : null + const coordinated = structuralSnap?.point ?? snapped + const next: [number, number, number] = [coordinated[0], originalPosition[1], coordinated[1]] lastPosition = next - const snapKey = `${snapped[0]},${snapped[1]}` + const snapKey = `${coordinated[0]},${coordinated[1]}` if (snapKey !== lastSnapKey) { triggerSFX('sfx:grid-snap') lastSnapKey = snapKey diff --git a/packages/nodes/src/column/floorplan.test.ts b/packages/nodes/src/column/floorplan.test.ts new file mode 100644 index 00000000..dfca40a1 --- /dev/null +++ b/packages/nodes/src/column/floorplan.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'bun:test' +import { ColumnNode, type GeometryContext, StructuralGridNode } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildColumnFloorplan, computeColumnFloorplanLevelData } from './floorplan' + +const context = { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, +} satisfies GeometryContext + +describe('buildColumnFloorplan', () => { + test('marks the structural center of the column footprint', () => { + const column = ColumnNode.parse({ + id: 'column_main', + parentId: 'level_main', + position: [2, 0, 3], + crossSection: 'square', + width: 0.4, + depth: 0.4, + }) + + const geometry = buildColumnFloorplan(column, context) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + + expect(geometry.children[0]?.kind).toBe('polygon') + expect(readFloorplanGeometryMetadata(geometry.children[0]!)).toMatchObject({ + annotationObstacle: 'bounds', + }) + + expect(geometry.children.filter((child) => child.kind === 'line')).toEqual([ + expect.objectContaining({ + x1: 1.91, + y1: 2.91, + x2: 2.09, + y2: 3.09, + pointerEvents: 'none', + }), + expect.objectContaining({ + x1: 1.91, + y1: 3.09, + x2: 2.09, + y2: 2.91, + pointerEvents: 'none', + }), + ]) + expect( + geometry.children + .filter((child) => child.kind === 'line') + .every((child) => readFloorplanGeometryMetadata(child).annotationRole === 'column-center'), + ).toBe(true) + }) + + test('labels a column with its associative structural-grid reference', () => { + const column = ColumnNode.parse({ + id: 'column_main', + parentId: 'level_main', + position: [2, 0, 3], + crossSection: 'square', + width: 0.4, + depth: 0.4, + }) + const vertical = StructuralGridNode.parse({ + id: 'structural-grid_2', + parentId: 'level_main', + start: [2, 0], + end: [2, 6], + label: '2', + }) + const horizontal = StructuralGridNode.parse({ + id: 'structural-grid_b', + parentId: 'level_main', + start: [0, 3], + end: [6, 3], + label: 'B', + }) + const levelData = computeColumnFloorplanLevelData({ + siblings: [column], + nodes: { + [column.id]: column, + [vertical.id]: vertical, + [horizontal.id]: horizontal, + }, + }) + + const geometry = buildColumnFloorplan(column, { ...context, levelData }) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + + const label = geometry.children.find((child) => child.kind === 'text' && child.text === 'B-2') + expect(label).toMatchObject({ kind: 'text', text: 'B-2', upright: true }) + expect(label && readFloorplanGeometryMetadata(label).annotationRole).toBe('column-center') + }) +}) diff --git a/packages/nodes/src/column/floorplan.ts b/packages/nodes/src/column/floorplan.ts index 6f5d229d..462327c0 100644 --- a/packages/nodes/src/column/floorplan.ts +++ b/packages/nodes/src/column/floorplan.ts @@ -1,9 +1,16 @@ import type { + AnyNode, ColumnNode, FloorplanGeometry, FloorplanPoint, GeometryContext, + StructuralGridNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata } from '@pascal-app/editor' +import { + collectStructuralGridAxes, + resolveStructuralGridReference, +} from '../structural-grid/coordination' import type { ColumnResizePayload } from './floorplan-affordances' // Offsets for the floor-plan selection arrows. Resize chevrons hug the @@ -11,6 +18,8 @@ import type { ColumnResizePayload } from './floorplan-affordances' // further out so it doesn't crowd the resize arrows. const RESIZE_ARROW_OFFSET = 0.12 const ROTATE_ARROW_CORNER_OFFSET = 0.22 +const GRID_REFERENCE_OFFSET = 0.16 +const GRID_REFERENCE_FONT_SIZE = 0.13 const ROUND_CROSS_SECTIONS = new Set([ 'round', @@ -18,6 +27,22 @@ const ROUND_CROSS_SECTIONS = new Set([ 'sixteen-sided', ]) +export type ColumnFloorplanLevelData = { + structuralGrids: StructuralGridNode[] +} + +export function computeColumnFloorplanLevelData({ + siblings, + nodes, +}: { + siblings: readonly ColumnNode[] + nodes: Record +}): ColumnFloorplanLevelData { + return { + structuralGrids: collectStructuralGridAxes(nodes, siblings[0]?.parentId), + } +} + /** * Stage C floor-plan builder for column. Inlined from the legacy * `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The @@ -34,8 +59,8 @@ export function buildColumnFloorplan( node: ColumnNode, ctx: GeometryContext, ): FloorplanGeometry | null { - const polygon = getColumnPlanFootprint(node) - if (polygon.length < 3) return null + const points = getColumnFloorplanFootprint(node) + if (points.length < 3) return null const view = ctx.viewState const palette = view?.palette @@ -46,8 +71,6 @@ export function buildColumnFloorplan( const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151' const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af' - const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint) - const children: FloorplanGeometry[] = [ { kind: 'polygon', @@ -56,8 +79,60 @@ export function buildColumnFloorplan( stroke, strokeWidth: showSelectedChrome ? 0.03 : 0.02, opacity: 0.92, + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), }, ] + const { halfX, halfZ } = columnPlanHalfExtents(node) + const centerMarkHalf = Math.min(0.09, Math.max(0.035, Math.min(halfX, halfZ) * 0.45)) + const centerX = node.position[0] + const centerZ = node.position[2] + children.push( + { + kind: 'line', + x1: centerX - centerMarkHalf, + y1: centerZ - centerMarkHalf, + x2: centerX + centerMarkHalf, + y2: centerZ + centerMarkHalf, + stroke, + strokeWidth: 0.9, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + }, + { + kind: 'line', + x1: centerX - centerMarkHalf, + y1: centerZ + centerMarkHalf, + x2: centerX + centerMarkHalf, + y2: centerZ - centerMarkHalf, + stroke, + strokeWidth: 0.9, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + }, + ) + + const levelData = ctx.levelData as ColumnFloorplanLevelData | undefined + const gridReference = resolveStructuralGridReference( + [centerX, centerZ], + levelData?.structuralGrids ?? [], + ) + if (gridReference) { + children.push({ + kind: 'text', + x: centerX, + y: centerZ + halfZ + GRID_REFERENCE_OFFSET, + text: gridReference, + fontSize: GRID_REFERENCE_FONT_SIZE, + fill: stroke, + fontWeight: 700, + textAnchor: 'middle', + dominantBaseline: 'middle', + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'column-center' }), + }) + } // Hatch overlay on selected — same `` pattern as the wall. if (isSelected && palette) { @@ -146,7 +221,6 @@ export function buildColumnFloorplan( // Rotate-arrow at the +X / +Z corner — matches the 3D // `columnRotateHandle` corner placement so users see the rotation // affordance in the same quadrant across views. - const { halfX, halfZ } = columnPlanHalfExtents(node) const cornerLocalX = halfX + ROTATE_ARROW_CORNER_OFFSET const cornerLocalZ = halfZ + ROTATE_ARROW_CORNER_OFFSET const [cornerWorldX, cornerWorldZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, rot) @@ -163,6 +237,10 @@ export function buildColumnFloorplan( return { kind: 'group', children } } +export function getColumnFloorplanFootprint(node: ColumnNode): FloorplanPoint[] { + return getColumnPlanFootprint(node).map((point) => [point.x, point.y]) +} + // ── Inlined helpers from legacy floorplan-panel.tsx ─────────────────── type PlanPoint = { x: number; y: number } diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 31befe79..8daf2e9e 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -7,6 +7,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, useScene, } from '@pascal-app/core' import { @@ -31,6 +32,10 @@ import { stopPlacementCommitPropagation, subscribeFloorPlacementClicks, } from '../shared/floor-placement' +import { + collectStructuralGridAxes, + resolveStructuralGridSnap, +} from '../structural-grid/coordination' import { ColumnPreview } from './renderer' const DEFAULT_COLUMN_PRESET_ID = 'basicPillar' satisfies ColumnPresetId @@ -86,7 +91,7 @@ const ColumnTool = () => { setCursorVisible(true) } - const { position, guides } = resolveAlignedFloorPlacement({ + const { position: alignedPosition, guides } = resolveAlignedFloorPlacement({ node: previewNode, rawX: event.localPosition[0], rawZ: event.localPosition[2], @@ -96,7 +101,18 @@ const ColumnTool = () => { applyAlignmentSnap: isMagneticSnapActive(), bypassGrid: !isGridSnapActive(), }) - useAlignmentGuides.getState().set(guides) + const structuralSnap = + isGridSnapActive() || isMagneticSnapActive() + ? resolveStructuralGridSnap( + [alignedPosition[0], alignedPosition[2]], + collectStructuralGridAxes(useScene.getState().nodes, activeLevelId), + ) + : null + const position: [number, number, number] = structuralSnap + ? [structuralSnap.point[0], alignedPosition[1], structuralSnap.point[1]] + : alignedPosition + if (structuralSnap) useAlignmentGuides.getState().clear() + else useAlignmentGuides.getState().set(guides) const visualPosition = getFloorStackPreviewPosition({ node: previewNode, @@ -133,7 +149,7 @@ const ColumnTool = () => { } const commitAtCursor = (event: FloorPlacementClickTriggerEvent) => { - const position = + const fallbackPosition = lastCursorRef.current ?? getLevelLocalSnappedPosition( activeLevelId, @@ -141,10 +157,27 @@ const ColumnTool = () => { useEditor.getState().gridSnapStep, !isGridSnapActive(), ) + const structuralSnap = + isGridSnapActive() || isMagneticSnapActive() + ? resolveStructuralGridSnap( + [fallbackPosition[0], fallbackPosition[2]], + collectStructuralGridAxes(useScene.getState().nodes, activeLevelId), + ) + : null + const position: [number, number, number] = structuralSnap + ? [structuralSnap.point[0], fallbackPosition[1], structuralSnap.point[1]] + : fallbackPosition - const column = createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position) - useScene.getState().createNode(column, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [column.id] }) + const column = ColumnNode.parse({ + ...createColumnFromPreset(DEFAULT_COLUMN_PRESET_ID, position), + parentId: activeLevelId, + }) + const committedColumn = ColumnNode.parse({ + ...column, + ...resolveSupportSlabPatch(column, useScene.getState().nodes), + }) + useScene.getState().createNode(committedColumn, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedColumn.id] }) triggerSFX('sfx:structure-build') useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() @@ -155,7 +188,10 @@ const ColumnTool = () => { cursorVisibleRef.current = false setCursorVisible(false) useFacingPose.getState().clear() + // Restore select mode with the tool — `mode: 'build'` with no tool is + // a dead state where the selection manager ignores every click. useEditor.getState().setTool(null) + useEditor.getState().setMode('select') } stopPlacementCommitPropagation(event) } diff --git a/packages/nodes/src/construction-dimension/definition.test.ts b/packages/nodes/src/construction-dimension/definition.test.ts new file mode 100644 index 00000000..44445663 --- /dev/null +++ b/packages/nodes/src/construction-dimension/definition.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' +import { constructionDimensionDefinition } from './definition' + +describe('constructionDimensionDefinition', () => { + test('registers a selectable floor-plan construction annotation', () => { + expect(constructionDimensionDefinition.kind).toBe('construction-dimension') + expect(constructionDimensionDefinition.category).toBe('analysis') + expect(constructionDimensionDefinition.bake).toBe('strip') + expect(constructionDimensionDefinition.schemaVersion).toBe(7) + expect(constructionDimensionDefinition.dirtyTracking).toBe(false) + expect(constructionDimensionDefinition.capabilities).toMatchObject({ + selectable: { hitVolume: 'bbox' }, + deletable: true, + duplicable: true, + presettable: false, + }) + expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty( + 'move-construction-dimension-baseline', + ) + expect(constructionDimensionDefinition.floorplanAffordances).toHaveProperty( + 'move-construction-dimension-witness', + ) + }) + + test('produces schema-valid defaults', () => { + expect( + constructionDimensionDefinition.schema.safeParse({ + id: 'construction-dimension_default', + type: 'construction-dimension', + ...constructionDimensionDefinition.defaults(), + }).success, + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/construction-dimension/definition.ts b/packages/nodes/src/construction-dimension/definition.ts new file mode 100644 index 00000000..d90b6307 --- /dev/null +++ b/packages/nodes/src/construction-dimension/definition.ts @@ -0,0 +1,91 @@ +import { measurementAnchorReferenceNodeIds, type NodeDefinition } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { resolveConstructionDimensionForDrawing } from './drawing-coordination' +import { buildConstructionDimensionFloorplan } from './floorplan' +import { + moveConstructionDimensionBaselineAffordance, + moveConstructionDimensionWitnessAffordance, +} from './floorplan-affordances' +import { constructionDimensionParametrics } from './parametrics' +import { ConstructionDimensionNode } from './schema' + +export const constructionDimensionDefinition: NodeDefinition = { + kind: 'construction-dimension', + bake: 'strip', + schemaVersion: 7, + schema: ConstructionDimensionNode, + category: 'analysis', + extensions: { + 'pascal:editor/floorplan': { + tool: () => import('./floorplan-tool'), + resolveForDrawing: resolveConstructionDimensionForDrawing, + } satisfies FloorplanNodeExtension, + }, + snapProfile: 'item', + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + anchors: [ + [0, 0, 0], + [1, 0, 0], + ], + baseline: { origin: [0, 0.6], direction: [1, 0] }, + chainMode: 'point-to-point', + 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, + }), + capabilities: { + selectable: { hitVolume: 'bbox' }, + deletable: true, + duplicable: true, + presettable: false, + }, + + dirtyTracking: false, + parametrics: constructionDimensionParametrics, + floorplan: buildConstructionDimensionFloorplan, + floorplanDependencies: (node) => [ + ...measurementAnchorReferenceNodeIds(node.anchors), + ...(node.controllingDimensionId ? [node.controllingDimensionId] : []), + ], + floorplanAffordances: { + 'move-construction-dimension-baseline': moveConstructionDimensionBaselineAffordance, + 'move-construction-dimension-witness': moveConstructionDimensionWitnessAffordance, + }, + toolHints: [ + { key: 'Left click', label: 'Pick witness point' }, + { key: 'Enter', label: 'Finish multi-point witnesses' }, + { key: 'Left click', label: 'Place dimension line when needed' }, + { key: 'Backspace', label: 'Remove last witness' }, + { key: 'Esc', label: 'Step back or cancel' }, + ], + + presentation: { + label: 'Construction Dimension', + description: 'Associative linear, curved, circular, angular, or coordinate plan dimension.', + icon: { kind: 'iconify', name: 'lucide:ruler-dimension-line' }, + hidden: true, + actionMenu: false, + }, + + mcp: { + description: + 'An associative construction dimension with linear, curved, circular, angular, and coordinate modes, semantic witness anchors, document notation overrides, and coordinated plan-view presentation.', + }, +} diff --git a/packages/nodes/src/construction-dimension/drawing-coordination.test.ts b/packages/nodes/src/construction-dimension/drawing-coordination.test.ts new file mode 100644 index 00000000..af399572 --- /dev/null +++ b/packages/nodes/src/construction-dimension/drawing-coordination.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, ConstructionDimensionNode } from '@pascal-app/core' +import { resolveConstructionDimensionForDrawing } from './drawing-coordination' + +const foundation = ConstructionDimensionNode.parse({ + id: 'construction-dimension_foundation', + drawingType: 'foundation-plan', + anchors: [ + [0, 0, 0], + [6, 0, 0], + ], + baseline: { origin: [0, 2], direction: [1, 0] }, +}) + +const resolve = ( + node: ConstructionDimensionNode, + nodes: Record, + drawingType: 'floor-plan' | 'foundation-plan', +) => resolveConstructionDimensionForDrawing({ node, nodes, drawingType }) + +describe('resolveConstructionDimensionForDrawing', () => { + test('omits a dimension outside its primary drawing by default', () => { + expect(resolve(foundation, { [foundation.id]: foundation }, 'floor-plan')).toBeNull() + expect(resolve(foundation, { [foundation.id]: foundation }, 'foundation-plan')).toBe(foundation) + }) + + test('applies view-specific suppressed segments without changing physical anchors', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + ], + drawingOverrides: [ + { + drawingType: 'floor-plan', + presentation: 'shown', + suppressedSegmentIndexes: [1], + }, + ], + }) + const resolved = resolve(node, { [node.id]: node }, 'floor-plan') + + expect(resolved).toMatchObject({ + id: node.id, + anchors: node.anchors, + metadata: { suppressedDimensionSegmentIndexes: [1] }, + }) + expect(node.metadata).toEqual({}) + }) + + test('derives linked floor-plan geometry from a controlling foundation dimension', () => { + const floor = ConstructionDimensionNode.parse({ + id: 'construction-dimension_floor', + drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }], + controllingDimensionId: foundation.id, + anchors: [ + [1, 0, 1], + [2, 0, 1], + ], + }) + const nodes = { [floor.id]: floor, [foundation.id]: foundation } as Record + const resolved = resolve(floor, nodes, 'floor-plan') + + expect(resolved).toMatchObject({ + id: floor.id, + anchors: foundation.anchors, + baseline: foundation.baseline, + metadata: { drawingCoordinationLocked: true }, + }) + }) + + test('marks a missing foundation controller as unlinked', () => { + const floor = ConstructionDimensionNode.parse({ + drawingOverrides: [{ drawingType: 'floor-plan', presentation: 'controlled' }], + controllingDimensionId: 'construction-dimension_missing', + prefix: 'TYP · ', + }) + expect(resolve(floor, { [floor.id]: floor }, 'floor-plan')).toMatchObject({ + prefix: 'UNLINKED CONTROL · TYP · ', + }) + }) +}) diff --git a/packages/nodes/src/construction-dimension/drawing-coordination.ts b/packages/nodes/src/construction-dimension/drawing-coordination.ts new file mode 100644 index 00000000..1120993b --- /dev/null +++ b/packages/nodes/src/construction-dimension/drawing-coordination.ts @@ -0,0 +1,76 @@ +import { + type AnyNode, + type ConstructionDimensionNode, + type ConstructionDrawingType, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, +} from '@pascal-app/core' + +export function resolveConstructionDimensionForDrawing(args: { + node: ConstructionDimensionNode + nodes: Record + drawingType: ConstructionDrawingType +}): ConstructionDimensionNode | null { + const { node, nodes, drawingType } = args + const presentation = resolveConstructionDimensionDrawingPresentation(node, drawingType) + if (presentation === 'omit') return null + if (presentation === 'shown') return applyDrawingOverride(node, drawingType) + + const controller = node.controllingDimensionId ? nodes[node.controllingDimensionId] : undefined + if ( + controller?.type !== 'construction-dimension' || + controller.id === node.id || + controller.drawingType !== 'foundation-plan' + ) { + return { + ...node, + metadata: lockedMetadata(node), + prefix: `UNLINKED CONTROL · ${node.prefix}`, + } + } + + return resolveControlledDimension(node, controller) +} + +function resolveControlledDimension( + node: ConstructionDimensionNode, + controller: ConstructionDimensionNode, +): ConstructionDimensionNode { + const overridden = applyDrawingOverride(node, 'floor-plan') + return { + ...overridden, + metadata: lockedMetadata(overridden), + anchors: controller.anchors, + baseline: controller.baseline, + chainMode: controller.chainMode, + mode: controller.mode, + showCenterMark: controller.showCenterMark, + } +} + +function applyDrawingOverride( + node: ConstructionDimensionNode, + drawingType: ConstructionDrawingType, +): ConstructionDimensionNode { + const override = resolveConstructionDimensionDrawingOverride(node, drawingType) + if (!override || override.suppressedSegmentIndexes.length === 0) return node + return { + ...node, + metadata: { + ...(typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) + ? node.metadata + : {}), + suppressedDimensionSegmentIndexes: override.suppressedSegmentIndexes, + }, + } +} + +function lockedMetadata(node: ConstructionDimensionNode): ConstructionDimensionNode['metadata'] { + const metadata = + typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) + ? node.metadata + : {} + return { ...metadata, drawingCoordinationLocked: true } +} diff --git a/packages/nodes/src/construction-dimension/floorplan-affordances.test.ts b/packages/nodes/src/construction-dimension/floorplan-affordances.test.ts new file mode 100644 index 00000000..b19445fd --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan-affordances.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + ConstructionDimensionNode, + nodeRegistry, + registerNode, + useLiveNodeOverrides, + useScene, + WallNode, +} from '@pascal-app/core' +import { wallDefinition } from '../wall/definition' +import { moveConstructionDimensionWitnessAffordance } from './floorplan-affordances' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +const MODIFIERS = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false } + +function seedScene() { + const levelId = 'level_construction-dimension-affordance' as AnyNodeId + const wall = WallNode.parse({ + id: 'wall_dimension-target', + start: [0, 0], + end: [4, 0], + parentId: levelId, + }) + const dimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_drag-witness', + parentId: levelId, + anchors: [ + { + kind: 'feature', + reference: { + nodeId: wall.id, + featureId: 'wall:centerline', + parameters: { t: 0.25 }, + }, + fallback: [1, 0, 0], + }, + [4, 0, 0], + ], + }) + const level = { + id: levelId, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + level: 0, + parentId: null, + children: [wall.id, dimension.id], + } as unknown as AnyNode + const nodes = { [levelId]: level, [wall.id]: wall, [dimension.id]: dimension } as Record< + AnyNodeId, + AnyNode + > + + useScene.setState({ nodes: nodes as never }) + return { dimension, nodes, wall } +} + +describe('moveConstructionDimensionWitnessAffordance', () => { + beforeEach(() => { + nodeRegistry._reset() + registerNode(wallDefinition) + useLiveNodeOverrides.getState().clearAll() + }) + + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + nodeRegistry._reset() + }) + + test('reassociates a dragged witness to a nearby semantic wall feature', () => { + const { dimension, nodes, wall } = seedScene() + const session = moveConstructionDimensionWitnessAffordance.start({ + node: dimension, + payload: { witnessIndex: 0 }, + nodes, + initialPlanPoint: [1, 0], + gridSnapStep: 0.1, + }) + + session.apply({ planPoint: [3, 0.04], modifiers: MODIFIERS }) + expect(session.canCommit()).toBe(true) + session.commit?.() + + const updated = useScene.getState().nodes[dimension.id] as typeof dimension + const anchor = updated.anchors[0] + expect(Array.isArray(anchor)).toBe(false) + if (!Array.isArray(anchor)) { + expect(anchor.reference.nodeId).toBe(wall.id) + expect(anchor.reference.featureId).toMatch(/^wall:/) + expect(anchor.fallback[0]).toBeCloseTo(3) + } + }) + + test('detaches a dragged witness as an explicit free point when Alt bypasses association', () => { + const { dimension, nodes } = seedScene() + const session = moveConstructionDimensionWitnessAffordance.start({ + node: dimension, + payload: { witnessIndex: 0 }, + nodes, + initialPlanPoint: [1, 0], + gridSnapStep: 0.1, + }) + + session.apply({ planPoint: [3, 2], modifiers: { ...MODIFIERS, altKey: true } }) + expect(session.canCommit()).toBe(true) + session.commit?.() + + const updated = useScene.getState().nodes[dimension.id] as typeof dimension + expect(updated.anchors[0]).toEqual([3, 0, 2]) + }) +}) diff --git a/packages/nodes/src/construction-dimension/floorplan-affordances.ts b/packages/nodes/src/construction-dimension/floorplan-affordances.ts new file mode 100644 index 00000000..f288894c --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan-affordances.ts @@ -0,0 +1,154 @@ +import { + ConstructionDimensionNode, + type ConstructionDimensionNode as ConstructionDimensionNodeType, + type FloorplanAffordance, + type FloorplanAffordanceSession, + type MeasurementAnchor, + type MeasurementPoint, + resolveLevelId, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + isGridSnapActive, + isMagneticSnapActive, + resolveSurfacePlanPointSnap, + useEditor, +} from '@pascal-app/editor' +import { matchMeasurementFeatureForNode, resolveMeasurementAnchor } from '../measurement/resolve' + +const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2 +const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012 + +function semanticWitnessAnchor( + point: MeasurementPoint, + wallIds: readonly string[], + nodes: Parameters[1], + maxDistance: number, +): MeasurementAnchor { + const matches = wallIds.flatMap((id) => { + const node = nodes[id] + if (!node) return [] + const match = matchMeasurementFeatureForNode( + node, + (nodeId) => nodes[nodeId], + point, + maxDistance, + ) + return match ? [{ match, node }] : [] + }) + const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0] + if (!closest) return point + return { + kind: 'feature', + reference: { + nodeId: closest.node.id, + featureId: closest.match.feature.id, + parameters: closest.match.parameters, + }, + fallback: closest.match.point, + } +} + +function withRefreshedFallbacks( + node: ConstructionDimensionNodeType, + nodes: Parameters[1], +): ConstructionDimensionNodeType['anchors'] { + return node.anchors.map((anchor) => { + if (Array.isArray(anchor)) return anchor + const resolved = resolveMeasurementAnchor(anchor, (id) => nodes[id]) + return { ...anchor, fallback: resolved.point } + }) +} + +export const moveConstructionDimensionWitnessAffordance: FloorplanAffordance = + { + start({ node, nodes, payload }): FloorplanAffordanceSession { + const witnessIndex = (payload as { witnessIndex?: unknown }).witnessIndex + const originalAnchors = withRefreshedFallbacks(node, nodes) + const levelId = resolveLevelId(node, nodes) + let latest: ConstructionDimensionNodeType['anchors'] | null = null + + if (!Number.isInteger(witnessIndex)) { + return { + affectedIds: [node.id], + apply() {}, + canCommit: () => false, + } + } + + return { + affectedIds: [node.id], + apply({ planPoint, modifiers }) { + const forceFree = modifiers.altKey === true + const gridStep = !forceFree && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const fallbackPoint: [number, number] = + gridStep > 0 + ? [ + Math.round(planPoint[0] / gridStep) * gridStep, + Math.round(planPoint[1] / gridStep) * gridStep, + ] + : [planPoint[0], planPoint[1]] + const magnetic = !forceFree && isMagneticSnapActive() + const snapped = resolveSurfacePlanPointSnap({ + rawPoint: [planPoint[0], planPoint[1]], + fallbackPoint, + excludeId: node.id, + levelId, + movingId: node.id, + nodes, + magnetic, + }) + const point: MeasurementPoint = [snapped.point[0], 0, snapped.point[1]] + const nextAnchor = semanticWitnessAnchor( + point, + snapped.wallIds, + nodes, + magnetic ? SEMANTIC_FEATURE_SNAP_DISTANCE : SEMANTIC_FEATURE_BYPASS_DISTANCE, + ) + const anchors = originalAnchors.map((anchor, index) => + index === witnessIndex ? nextAnchor : anchor, + ) + if (!ConstructionDimensionNode.safeParse({ ...node, anchors }).success) { + latest = null + useLiveNodeOverrides.getState().clear(node.id) + return + } + latest = anchors + useLiveNodeOverrides.getState().set(node.id, { anchors }) + }, + canCommit: () => latest !== null, + commit() { + const anchors = latest + useLiveNodeOverrides.getState().clear(node.id) + if (anchors) useScene.getState().updateNode(node.id, { anchors }) + }, + } + }, + } + +export const moveConstructionDimensionBaselineAffordance: FloorplanAffordance = + { + start({ node }) { + let latest: [number, number] | null = null + return { + affectedIds: [node.id], + apply({ planPoint }) { + const origin: [number, number] = [planPoint[0], planPoint[1]] + const baseline = { ...node.baseline, origin } + if (!ConstructionDimensionNode.safeParse({ ...node, baseline }).success) return + latest = origin + useLiveNodeOverrides.getState().set(node.id, { baseline }) + }, + canCommit: () => latest !== null, + commit() { + useLiveNodeOverrides.getState().clear(node.id) + if (latest) { + useScene.getState().updateNode(node.id, { + baseline: { ...node.baseline, origin: latest }, + }) + } + }, + } + }, + } diff --git a/packages/nodes/src/construction-dimension/floorplan-tool.test.ts b/packages/nodes/src/construction-dimension/floorplan-tool.test.ts new file mode 100644 index 00000000..4c96cf71 --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan-tool.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'bun:test' +import { type MeasurementPoint, WallNode } from '@pascal-app/core' +import { + buildConstructionDimensionPreviewGeometries, + buildCurvedWallConstructionDimensionDraft, + constructionDimensionUsesBaseline, + normalizeConstructionDimensionChainMode, + normalizeConstructionDimensionMode, + resolveConstructionDimensionDraftDirection, +} from './floorplan-tool' + +describe('continuous construction-dimension drafting', () => { + test('derives a stable baseline direction from the first witness pair', () => { + expect( + resolveConstructionDimensionDraftDirection([ + [1, 0, 2], + [4, 0, 6], + [8, 0, 7], + ]), + ).toEqual([0.6, 0.8]) + expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull() + }) + + test('previews one adjacent dimension for every witness interval', () => { + const geometry = buildConstructionDimensionPreviewGeometries( + [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + [9, 0, 0], + ], + [0, 0, 2], + 'metric', + ) + + expect(geometry).toHaveLength(3) + expect(geometry.map((segment) => segment.text)).toEqual(['2m', '3m', '4m']) + expect(geometry[1]).toMatchObject({ + start: [2, 0], + end: [5, 0], + dimensionStart: [2, 2], + dimensionEnd: [5, 2], + }) + }) + + test('normalizes unknown tool defaults to the point-to-point workflow', () => { + expect(normalizeConstructionDimensionChainMode('continuous')).toBe('continuous') + expect(normalizeConstructionDimensionChainMode('unknown')).toBe('point-to-point') + }) + + test('normalizes curved and circular construction-dimension modes', () => { + expect(normalizeConstructionDimensionMode('radius')).toBe('radius') + expect(normalizeConstructionDimensionMode('arc-length')).toBe('arc-length') + expect(normalizeConstructionDimensionMode('unknown')).toBe('linear') + }) + + test('previews radius and diameter notation before commit', () => { + const points: MeasurementPoint[] = [ + [0, 0, 0], + [2, 0, 0], + ] + expect( + buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0], + ).toMatchObject({ text: 'R 2m' }) + expect( + buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0], + ).toMatchObject({ text: 'Ø 2m' }) + expect( + buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'angular'), + ).toEqual([]) + }) + + test('previews the arc value leader while placing the fourth point', () => { + const preview = buildConstructionDimensionPreviewGeometries( + [ + [2, 0, 0], + [0, 0, 0], + [0, 0, 2], + ], + [3, 0, 3], + 'metric', + 'arc-length', + ) + + expect(preview).toHaveLength(1) + expect(preview[0]).toMatchObject({ + kind: 'group', + children: expect.arrayContaining([ + expect.objectContaining({ kind: 'path' }), + expect.objectContaining({ kind: 'line', x2: 3, y2: 3 }), + expect.objectContaining({ kind: 'dimension-label', text: 'ARC 3.14m' }), + ]), + }) + }) + + test('previews the angular arc and value while placing the fourth point', () => { + const preview = buildConstructionDimensionPreviewGeometries( + [ + [2, 0, 0], + [0, 0, 0], + [0, 0, 2], + ], + [1.5, 0, 0.5], + 'metric', + 'angular', + ) + + expect(preview).toHaveLength(1) + expect(preview[0]).toMatchObject({ + kind: 'group', + children: expect.arrayContaining([ + expect.objectContaining({ kind: 'path' }), + expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 }), + expect.objectContaining({ + kind: 'dimension-label', + cx: 1.5, + cy: 0.5, + text: '∠ 90°', + }), + ]), + }) + }) + + test('only requests a label baseline for modes that use one', () => { + expect(constructionDimensionUsesBaseline('linear')).toBe(true) + expect(constructionDimensionUsesBaseline('radius')).toBe(true) + expect(constructionDimensionUsesBaseline('angular')).toBe(true) + expect(constructionDimensionUsesBaseline('diameter')).toBe(false) + expect(constructionDimensionUsesBaseline('center-mark')).toBe(false) + expect(constructionDimensionUsesBaseline('coordinate')).toBe(false) + }) + + test('derives associative radius, chord, and center drafts from one curved wall', () => { + const wall = WallNode.parse({ + id: 'wall_curve', + start: [0, 0], + end: [4, 0], + curveOffset: 1, + }) + + expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({ + anchors: [ + { reference: { nodeId: wall.id, featureId: 'wall:curve:center' } }, + { reference: { nodeId: wall.id, featureId: 'wall:midpoint' } }, + ], + points: [ + [2, 0, 1.5], + [2, 0, -1], + ], + }) + expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([ + { reference: { featureId: 'wall:start' } }, + { reference: { featureId: 'wall:end' } }, + ]) + expect(buildCurvedWallConstructionDimensionDraft(wall, 'center-mark')?.anchors).toHaveLength(2) + }) + + test('keeps arc length in the manual start-center-end and baseline workflow', () => { + const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 }) + + expect(buildCurvedWallConstructionDimensionDraft(curved, 'arc-length')).toBeNull() + expect(constructionDimensionUsesBaseline('arc-length')).toBe(true) + }) + + test('keeps angular dimensions in the manual ray-center-ray and baseline workflow', () => { + const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 }) + + expect(buildCurvedWallConstructionDimensionDraft(curved, 'angular')).toBeNull() + expect(constructionDimensionUsesBaseline('angular')).toBe(true) + }) + + test('keeps manual point drafting for straight walls and unsupported modes', () => { + const straight = WallNode.parse({ start: [0, 0], end: [4, 0] }) + const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 }) + + expect(buildCurvedWallConstructionDimensionDraft(straight, 'radius')).toBeNull() + expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull() + expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull() + }) +}) diff --git a/packages/nodes/src/construction-dimension/floorplan-tool.tsx b/packages/nodes/src/construction-dimension/floorplan-tool.tsx new file mode 100644 index 00000000..6cfb16a2 --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan-tool.tsx @@ -0,0 +1,726 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type ConstructionDimensionChainMode, + type ConstructionDimensionMode, + ConstructionDimensionNode, + closestMeasurementFeatureBinding, + constructionDimensionRequiredAnchorCount, + type FloorplanGeometry, + type GeometryContext, + getWallArcData, + getWallCurveFrameAt, + type MeasurementAnchor, + type MeasurementFeatureAnchor, + type MeasurementPoint, + nodeRegistry, + type WallNode, +} from '@pascal-app/core' +import { + buildSvgArcPath, + clearSurfacePlanSnapFeedback, + FloorplanGeometryRenderer, + type FloorplanToolContext, + formatLinearMeasurement, + getArcPlanPoint, + isGridSnapActive, + isMagneticSnapActive, + markToolCancelConsumed, + resolveSurfacePlanPointSnap, + triggerSFX, + useDrawingView, + useFloorplanRender, + useInteractionScope, +} from '@pascal-app/editor' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { resolveCircularConstructionDimensionLayout } from './geometry' + +const SEMANTIC_SNAP_DISTANCE = 0.2 +const SEMANTIC_BYPASS_DISTANCE = 0.012 +const MIN_DIMENSION_LENGTH = 0.001 +const MIN_ARC_SWEEP = 1e-6 + +type Draft = { + anchors: MeasurementAnchor[] + points: MeasurementPoint[] + stage: 'witnesses' | 'baseline' +} + +type AssociatedPoint = { + anchor: MeasurementAnchor + point: MeasurementPoint + semantic: boolean + targetNodeId: string | null +} + +const emptyDraft = (): Draft => ({ anchors: [], points: [], stage: 'witnesses' }) + +function geometryContext(node: AnyNode, nodes: Record): GeometryContext { + const resolve: GeometryContext['resolve'] = (id: AnyNodeId) => + nodes[id] as N | undefined + const childIds = + 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : [] + const children = childIds + .map((id) => nodes[id]) + .filter((child): child is AnyNode => child !== undefined) + const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null + const siblings = + parent && 'children' in parent && Array.isArray(parent.children) + ? (parent.children as AnyNodeId[]) + .map((id) => nodes[id]) + .filter( + (sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type, + ) + : [] + return { resolve, children, parent, siblings } +} + +function associatePoint( + point: MeasurementPoint, + targetNodeId: string | null, + maxDistance: number, + nodes: Record, +): AssociatedPoint { + if (!targetNodeId) return { anchor: point, point, semantic: false, targetNodeId: null } + const node = nodes[targetNodeId as AnyNodeId] + const contribution = node ? nodeRegistry.get(node.type)?.measurement : undefined + if (!(node && contribution)) return { anchor: point, point, semantic: false, targetNodeId } + const context = geometryContext(node, nodes) + const features = contribution.features(node, context) + const match = + contribution.match?.(node, context, point, maxDistance) ?? + closestMeasurementFeatureBinding(features, point, maxDistance) + if (!match) return { anchor: point, point, semantic: false, targetNodeId } + + const reference = { + nodeId: node.id, + featureId: match.featureId, + parameters: match.parameters, + } + const anchor: MeasurementFeatureAnchor = { + kind: 'feature', + reference, + fallback: match.point, + } + return { anchor, point: match.point, semantic: true, targetNodeId } +} + +function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number) { + const matrix = group.getScreenCTM() + if (!matrix) return null + const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()) + return [local.x, 0, local.y] satisfies MeasurementPoint +} + +function registryTargetNodeId(target: EventTarget | null): string | null { + if (!(target instanceof Element)) return null + return ( + target.closest('.floorplan-registry-entry[data-node-id]')?.dataset.nodeId ?? null + ) +} + +export function resolveConstructionDimensionDraftDirection( + points: readonly MeasurementPoint[], +): [number, number] | null { + if (points.length < 2) return null + const dx = points[1]![0] - points[0]![0] + const dz = points[1]![2] - points[0]![2] + const magnitude = Math.hypot(dx, dz) + return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude] +} + +export function buildConstructionDimensionPreviewGeometries( + points: readonly MeasurementPoint[], + baselinePoint: MeasurementPoint, + unit: 'metric' | 'imperial', + mode: ConstructionDimensionMode = 'linear', + metricNotation: 'meters' | 'millimeters' = 'meters', +): FloorplanGeometry[] { + if (mode === 'arc-length' || mode === 'angular') { + const layout = resolveCircularConstructionDimensionLayout(mode, points) + if (!(layout?.end && Math.abs(layout.sweep) > MIN_ARC_SWEEP)) return [] + const center = { x: layout.center[0], y: layout.center[1] } + const end = getArcPlanPoint(center, layout.radius, layout.endAngle) + const arcMid = getArcPlanPoint(center, layout.radius, layout.startAngle + layout.sweep / 2) + const stroke = '#06b6d4' + const lineStyle = { + fill: 'none', + pointerEvents: 'none' as const, + stroke, + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke' as const, + } + if (mode === 'angular') { + const endRadius = Math.hypot(layout.end[0] - center.x, layout.end[1] - center.y) + const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9) + const requestedRadius = Math.hypot(baselinePoint[0] - center.x, baselinePoint[2] - center.y) + const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius)) + const midAngle = layout.startAngle + layout.sweep / 2 + const arcMid = getArcPlanPoint(center, arcRadius, midAngle) + const startRayEnd = getArcPlanPoint( + center, + Math.max(layout.radius, arcRadius + 0.12), + layout.startAngle, + ) + const endRayEnd = getArcPlanPoint( + center, + Math.max(endRadius, arcRadius + 0.12), + layout.endAngle, + ) + const degrees = (Math.abs(layout.sweep) * 180) / Math.PI + const formattedDegrees = Number.parseFloat(degrees.toFixed(degrees < 10 ? 1 : 0)) + return [ + { + kind: 'group', + children: [ + { + kind: 'line', + x1: center.x, + y1: center.y, + x2: startRayEnd.x, + y2: startRayEnd.y, + ...lineStyle, + }, + { + kind: 'line', + x1: center.x, + y1: center.y, + x2: endRayEnd.x, + y2: endRayEnd.y, + ...lineStyle, + }, + { + kind: 'path', + d: buildSvgArcPath( + center, + arcRadius, + layout.startAngle, + layout.startAngle + layout.sweep, + ), + ...lineStyle, + }, + { + kind: 'line', + x1: arcMid.x, + y1: arcMid.y, + x2: baselinePoint[0], + y2: baselinePoint[2], + strokeDasharray: '6 5', + ...lineStyle, + }, + { + kind: 'dimension-label', + cx: baselinePoint[0], + cy: baselinePoint[2], + text: `∠ ${formattedDegrees}°`, + angle: 0, + screenUpright: true, + appearance: 'outlined', + }, + ], + }, + ] + } + return [ + { + kind: 'group', + children: [ + { + kind: 'path', + d: buildSvgArcPath( + center, + layout.radius, + layout.startAngle, + layout.startAngle + layout.sweep, + ), + ...lineStyle, + }, + { + kind: 'line', + x1: layout.center[0], + y1: layout.center[1], + x2: layout.start[0], + y2: layout.start[1], + strokeDasharray: '6 5', + ...lineStyle, + }, + { + kind: 'line', + x1: layout.center[0], + y1: layout.center[1], + x2: end.x, + y2: end.y, + strokeDasharray: '6 5', + ...lineStyle, + }, + { + kind: 'line', + x1: arcMid.x, + y1: arcMid.y, + x2: baselinePoint[0], + y2: baselinePoint[2], + strokeDasharray: '6 5', + ...lineStyle, + }, + { + kind: 'dimension-label', + cx: baselinePoint[0], + cy: baselinePoint[2], + text: `ARC ${formatLinearMeasurement(layout.arcLength, unit, metricNotation)}`, + angle: 0, + screenUpright: true, + appearance: 'outlined', + }, + ], + }, + ] + } + if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return [] + const direction = resolveConstructionDimensionDraftDirection(points) + if (!direction) return [] + const normal: [number, number] = [-direction[1], direction[0]] + const project = (point: MeasurementPoint): [number, number] => { + const along = + (point[0] - baselinePoint[0]) * direction[0] + (point[2] - baselinePoint[2]) * direction[1] + return [baselinePoint[0] + along * direction[0], baselinePoint[2] + along * direction[1]] + } + const dimensionPoints = points.map(project) + return points.slice(0, -1).map((start, index) => { + const end = points[index + 1]! + const dx = end[0] - start[0] + const dz = end[2] - start[2] + const value = Math.abs(dx * direction[0] + dz * direction[1]) + const rawText = formatLinearMeasurement(value, unit, metricNotation) + const text = + mode === 'radius' + ? `R ${rawText}` + : mode === 'diameter' + ? `Ø ${rawText}` + : mode === 'chord' + ? `CH ${rawText}` + : rawText + return { + kind: 'dimension', + start: [start[0], start[2]], + end: [end[0], end[2]], + dimensionStart: dimensionPoints[index]!, + dimensionEnd: dimensionPoints[index + 1]!, + offsetNormal: normal, + offsetDistance: 0, + extensionOvershoot: 0.12, + text, + stroke: '#06b6d4', + } + }) +} + +export function normalizeConstructionDimensionChainMode( + value: unknown, +): ConstructionDimensionChainMode { + return value === 'continuous' ? 'continuous' : 'point-to-point' +} + +export function normalizeConstructionDimensionMode(value: unknown): ConstructionDimensionMode { + return [ + 'radius', + 'diameter', + 'center-mark', + 'chord', + 'arc-length', + 'angular', + 'coordinate', + ].includes(value as string) + ? (value as ConstructionDimensionMode) + : 'linear' +} + +export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean { + return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode) +} + +function wallFeatureAnchor( + wall: WallNode, + featureId: string, + fallback: MeasurementPoint, +): MeasurementFeatureAnchor { + return { + kind: 'feature', + reference: { nodeId: wall.id, featureId }, + fallback, + } +} + +export function buildCurvedWallConstructionDimensionDraft( + wall: WallNode, + mode: ConstructionDimensionMode, +): Pick | null { + const arc = getWallArcData(wall) + if (!arc) return null + + const center: MeasurementPoint = [arc.center.x, 0, arc.center.y] + const start: MeasurementPoint = [wall.start[0], 0, wall.start[1]] + const end: MeasurementPoint = [wall.end[0], 0, wall.end[1]] + const midpointFrame = getWallCurveFrameAt(wall, 0.5) + const midpoint: MeasurementPoint = [midpointFrame.point.x, 0, midpointFrame.point.y] + const feature = (featureId: string, fallback: MeasurementPoint) => + wallFeatureAnchor(wall, featureId, fallback) + + switch (mode) { + case 'radius': + case 'center-mark': + return { + anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)], + points: [center, midpoint], + } + case 'chord': + return { + anchors: [feature('wall:start', start), feature('wall:end', end)], + points: [start, end], + } + case 'arc-length': + case 'angular': + return null + default: + return null + } +} + +export function FloorplanConstructionDimensionToolLayer({ + activeLevelId, + finishTool, + gridSnapStep, + metricNotation, + sceneApi, + selectNode, + toolDefaults, + unit, +}: FloorplanToolContext) { + const groupRef = useRef(null) + const draftRef = useRef(emptyDraft()) + const [draft, setDraft] = useState(draftRef.current) + const [hover, setHover] = useState(null) + const chainMode = normalizeConstructionDimensionChainMode(toolDefaults?.chainMode) + const dimensionMode = normalizeConstructionDimensionMode(toolDefaults?.mode) + const collectsMany = + dimensionMode === 'coordinate' || (dimensionMode === 'linear' && chainMode === 'continuous') + const usesBaseline = constructionDimensionUsesBaseline(dimensionMode) + const renderContext = useFloorplanRender() + const drawingType = useDrawingView((state) => state.drawingType) + + useEffect(() => { + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'construction-dimension' }) + return () => + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'construction-dimension') + }, []) + + const updateDraft = useCallback((next: Draft) => { + draftRef.current = next + setDraft(next) + }, []) + + useEffect(() => { + updateDraft(emptyDraft()) + setHover(null) + const group = groupRef.current + const svg = group?.ownerSVGElement + if (!(activeLevelId && group && svg)) return + + const consume = (event: Event) => { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + const resolveEvent = (event: MouseEvent | PointerEvent): AssociatedPoint | null => { + const raw = clientToPlanPoint(group, event.clientX, event.clientY) + if (!raw) return null + const forceFree = event.altKey + const gridStep = !forceFree && isGridSnapActive() ? gridSnapStep : 0 + const fallbackPoint: [number, number] = + gridStep > 0 + ? [Math.round(raw[0] / gridStep) * gridStep, Math.round(raw[2] / gridStep) * gridStep] + : [raw[0], raw[2]] + const magnetic = !forceFree && isMagneticSnapActive() + const surface = resolveSurfacePlanPointSnap({ + rawPoint: [raw[0], raw[2]], + fallbackPoint, + levelId: activeLevelId, + align: false, + magnetic, + }) + const point: MeasurementPoint = [surface.point[0], 0, surface.point[1]] + const targetNodeId = surface.wallIds[0] ?? registryTargetNodeId(event.target) + return associatePoint( + point, + targetNodeId, + magnetic ? SEMANTIC_SNAP_DISTANCE : SEMANTIC_BYPASS_DISTANCE, + sceneApi.nodes(), + ) + } + const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => { + const direction = resolveConstructionDimensionDraftDirection(current.points) + const originPoint = baselinePoint ?? current.points.at(-1) + if (!(direction && originPoint)) return false + const node = ConstructionDimensionNode.parse({ + name: + dimensionMode === 'linear' && chainMode === 'continuous' + ? 'Continuous Dimension' + : `${dimensionMode.replaceAll('-', ' ')} Dimension`, + anchors: current.anchors, + baseline: { + origin: [originPoint[0], originPoint[2]], + direction, + }, + chainMode, + mode: dimensionMode, + drawingType, + }) + sceneApi.upsert(node, activeLevelId) + selectNode(node.id) + triggerSFX('sfx:structure-build') + finishTool() + updateDraft(emptyDraft()) + setHover(null) + return true + } + const finishWitnesses = () => { + const current = draftRef.current + const required = constructionDimensionRequiredAnchorCount(dimensionMode) + if (current.stage !== 'witnesses' || current.points.length < required) return false + if (!usesBaseline) return commitDraft(current) + updateDraft({ ...current, stage: 'baseline' }) + triggerSFX('sfx:grid-snap') + return true + } + const removeLastWitness = () => { + const current = draftRef.current + if (current.points.length === 0) return false + updateDraft({ + anchors: current.anchors.slice(0, -1), + points: current.points.slice(0, -1), + stage: 'witnesses', + }) + return true + } + const commitAt = (associated: AssociatedPoint) => { + const current = draftRef.current + if (current.stage === 'baseline') commitDraft(current, associated.point) + } + const onPointerDown = (event: PointerEvent) => { + if (event.button === 0) consume(event) + } + const onPointerMove = (event: PointerEvent) => { + consume(event) + setHover(resolveEvent(event)) + } + const onPointerLeave = () => { + clearSurfacePlanSnapFeedback() + setHover(null) + } + const onClick = (event: MouseEvent) => { + if (event.button !== 0) return + consume(event) + if (event.detail > 1) return + const associated = resolveEvent(event) + if (!associated) return + const current = draftRef.current + if (current.stage === 'baseline') { + commitAt(associated) + return + } + const targetNode = associated.targetNodeId + ? sceneApi.get(associated.targetNodeId as AnyNodeId) + : undefined + const curvedWallDraft = + current.points.length === 0 && targetNode?.type === 'wall' + ? buildCurvedWallConstructionDimensionDraft(targetNode, dimensionMode) + : null + if (curvedWallDraft) { + const next: Draft = { ...curvedWallDraft, stage: 'witnesses' } + updateDraft(next) + triggerSFX('sfx:grid-snap') + if (usesBaseline) updateDraft({ ...next, stage: 'baseline' }) + else commitDraft(next) + return + } + const previous = current.points.at(-1) + if ( + previous && + Math.hypot(associated.point[0] - previous[0], associated.point[2] - previous[2]) <= + MIN_DIMENSION_LENGTH + ) { + return + } + const next: Draft = { + anchors: [...current.anchors, associated.anchor], + points: [...current.points, associated.point], + stage: 'witnesses', + } + updateDraft(next) + triggerSFX('sfx:grid-snap') + if ( + !collectsMany && + next.points.length === constructionDimensionRequiredAnchorCount(dimensionMode) + ) { + if (usesBaseline) updateDraft({ ...next, stage: 'baseline' }) + else commitDraft(next) + } + } + const onDoubleClick = (event: MouseEvent) => { + if (event.button !== 0 || !collectsMany) return + consume(event) + finishWitnesses() + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && collectsMany) { + if (!finishWitnesses()) return + event.preventDefault() + event.stopImmediatePropagation() + return + } + if (event.key === 'Backspace') { + if (!removeLastWitness()) return + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + return + } + if (event.key !== 'Escape') return + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + const current = draftRef.current + if (current.stage === 'baseline') { + updateDraft({ ...current, stage: 'witnesses' }) + return + } + if (removeLastWitness()) return + finishTool() + } + const onBlur = () => clearSurfacePlanSnapFeedback() + + svg.addEventListener('pointerdown', onPointerDown, true) + svg.addEventListener('pointermove', onPointerMove, true) + svg.addEventListener('pointerleave', onPointerLeave, true) + svg.addEventListener('click', onClick, true) + svg.addEventListener('dblclick', onDoubleClick, true) + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('blur', onBlur) + return () => { + clearSurfacePlanSnapFeedback() + svg.removeEventListener('pointerdown', onPointerDown, true) + svg.removeEventListener('pointermove', onPointerMove, true) + svg.removeEventListener('pointerleave', onPointerLeave, true) + svg.removeEventListener('click', onClick, true) + svg.removeEventListener('dblclick', onDoubleClick, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('blur', onBlur) + } + }, [ + activeLevelId, + chainMode, + collectsMany, + dimensionMode, + drawingType, + finishTool, + gridSnapStep, + sceneApi, + selectNode, + updateDraft, + usesBaseline, + ]) + + const preview = useMemo( + () => + draft.stage === 'baseline' && hover + ? buildConstructionDimensionPreviewGeometries( + draft.points, + hover.point, + unit, + dimensionMode, + metricNotation, + ) + : [], + [dimensionMode, draft.points, draft.stage, hover, metricNotation, unit], + ) + const witnessDraftPoints = + draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points + + if (!activeLevelId) return null + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 + const reticleRadius = 10 * unitsPerPixel + const hoverColor = hover?.semantic ? '#22c55e' : '#06b6d4' + + return ( + + {witnessDraftPoints.length >= 2 && (draft.stage === 'witnesses' || preview.length === 0) ? ( + `${point[0]},${point[2]}`).join(' ')} + stroke="#06b6d4" + strokeDasharray="6 5" + strokeWidth={2} + vectorEffect="non-scaling-stroke" + /> + ) : null} + {preview.map((geometry, index) => ( + + ))} + {draft.points.map((point, index) => ( + + ))} + {hover ? ( + + + + + + ) : null} + + ) +} + +export default FloorplanConstructionDimensionToolLayer diff --git a/packages/nodes/src/construction-dimension/floorplan.test.ts b/packages/nodes/src/construction-dimension/floorplan.test.ts new file mode 100644 index 00000000..2df7d1e5 --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan.test.ts @@ -0,0 +1,518 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + ConstructionDimensionNode, + type FloorplanGeometry, + type GeometryContext, + nodeRegistry, + registerNode, + WallNode, +} from '@pascal-app/core' +import { createFloorplanContextExtensions } from '@pascal-app/editor' +import { wallDefinition } from '../wall/definition' +import { buildConstructionDimensionFloorplan } from './floorplan' + +const palette = { + selectedStroke: '#2563eb', + selectedFill: '#dbeafe', + selectedHatch: '#93c5fd', + wallHoverStroke: '#60a5fa', + endpointHandleFill: '#f97316', + endpointHandleStroke: '#ffffff', + endpointHandleHoverStroke: '#fdba74', + endpointHandleActiveFill: '#ea580c', + endpointHandleActiveStroke: '#ffffff', + curveHandleFill: '#14b8a6', + curveHandleStroke: '#ffffff', + curveHandleHoverStroke: '#5eead4', + measurementStroke: '#334155', + measurementLabelBackground: '#ffffff', + measurementLabelText: '#0f172a', +} + +function context( + nodes: Record = {}, + selected = false, + purpose: 'edit' | 'document' = 'edit', + metricNotation?: 'meters' | 'millimeters', +): GeometryContext { + return { + resolve: (id) => nodes[id], + children: [], + siblings: [], + parent: null, + viewState: { + selected, + unit: 'metric', + highlighted: false, + hovered: false, + moving: false, + palette, + }, + extensions: createFloorplanContextExtensions({ metricNotation, purpose }), + } +} + +function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] { + return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry] +} + +function dimensionSegments(geometry: FloorplanGeometry | null): Array<{ + start: readonly [number, number] + end: readonly [number, number] + dimensionStart?: readonly [number, number] + dimensionEnd?: readonly [number, number] + text: string + stroke?: string +}> { + if (!geometry) return [] + return flatten(geometry).flatMap((entry) => { + if (entry.kind === 'dimension') return [entry] + if (entry.kind === 'dimension-string') + return entry.segments.map((segment) => ({ ...segment, stroke: entry.stroke })) + return [] + }) +} + +describe('buildConstructionDimensionFloorplan', () => { + beforeEach(() => { + nodeRegistry._reset() + registerNode(wallDefinition) + }) + + afterEach(() => nodeRegistry._reset()) + + test('projects witness origins onto the placed baseline', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [1, 0, 1], + [4, 0, 2], + ], + baseline: { origin: [0, 5], direction: [1, 0] }, + }) + + const geometry = buildConstructionDimensionFloorplan(node, context()) + const dimension = dimensionSegments(geometry)[0] + + expect(dimension).toMatchObject({ + start: [1, 1], + end: [4, 2], + dimensionStart: [1, 5], + dimensionEnd: [4, 5], + text: '3m', + }) + }) + + test('follows semantic anchors and reports dangling references', () => { + const wall = WallNode.parse({ id: 'wall_target', start: [0, 0], end: [4, 0] }) + const node = ConstructionDimensionNode.parse({ + anchors: [ + { + kind: 'feature', + reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } }, + fallback: [1, 0, 0], + }, + [4, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + }) + + const linked = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall })) + const movedWall = WallNode.parse({ ...wall, start: [2, 0], end: [6, 0] }) + const moved = buildConstructionDimensionFloorplan(node, context({ [wall.id]: movedWall })) + const dangling = buildConstructionDimensionFloorplan(node, context()) + const linkedDimension = dimensionSegments(linked)[0] + const movedDimension = dimensionSegments(moved)[0] + const danglingDimension = dimensionSegments(dangling)[0] + + expect(linkedDimension).toMatchObject({ start: [1, 0], text: '3m' }) + expect(movedDimension).toMatchObject({ start: [3, 0], text: '1m' }) + expect(danglingDimension).toMatchObject({ + start: [1, 0], + text: 'UNLINKED · 3m', + stroke: '#dc2626', + }) + }) + + test('resolves wall anchors against the selected assembly datum', () => { + const wall = WallNode.parse({ + id: 'wall_assembly', + start: [0, 0], + end: [4, 0], + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.1, + datumEligible: ['structural-face'], + }, + { + id: 'exterior-finish', + role: 'exterior-finish', + side: 'exterior', + thickness: 0.03, + datumEligible: ['finish-face'], + }, + ], + }) + const anchor = { + kind: 'feature' as const, + reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } }, + fallback: [1, 0, 0] as [number, number, number], + } + const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') => + buildConstructionDimensionFloorplan( + ConstructionDimensionNode.parse({ + anchors: [anchor, [3, 0, 0]], + baseline: { origin: [0, 1], direction: [1, 0] }, + datumPolicy, + }), + context({ [wall.id]: wall }), + ) + + expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0]) + expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05) + expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08) + expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08) + }) + + test('uses millimetre notation in document output', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [3, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + }) + + expect( + dimensionSegments( + buildConstructionDimensionFloorplan(node, context({}, false, 'document')), + )[0]?.text, + ).toBe('3000') + }) + + test('renders a continuous string as adjacent associative segments', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + [9, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'continuous', + }) + + const geometry = buildConstructionDimensionFloorplan(node, context()) + const dimensions = dimensionSegments(geometry) + + expect(dimensions).toHaveLength(3) + expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '3m', '4m']) + expect(dimensions[1]).toMatchObject({ + start: [2, 0], + end: [5, 0], + dimensionStart: [2, 1], + dimensionEnd: [5, 1], + }) + }) + + test('renders point-to-point strings as independent witness pairs', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + [9, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'point-to-point', + }) + + const geometry = buildConstructionDimensionFloorplan(node, context()) + const dimensions = dimensionSegments(geometry) + + expect(dimensions).toHaveLength(2) + expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m']) + expect(dimensions[1]).toMatchObject({ + start: [5, 0], + end: [9, 0], + dimensionStart: [5, 1], + dimensionEnd: [9, 1], + }) + }) + + test('suppresses view-specific string segments without mutating physical anchors', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + [5, 0, 0], + [9, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + chainMode: 'continuous', + metadata: { suppressedDimensionSegmentIndexes: [1] }, + }) + + const geometry = buildConstructionDimensionFloorplan(node, context()) + const dimensions = dimensionSegments(geometry) + + expect(node.anchors).toHaveLength(4) + expect(dimensions).toHaveLength(2) + expect(dimensions.map((dimension) => dimension.text)).toEqual(['2m', '4m']) + }) + + test('passes persistent dimension standards to linear dimension strings', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + datumPolicy: 'finish-face', + terminator: 'dot', + textPosition: 'centered', + metricNotation: 'millimeters', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + + const geometry = buildConstructionDimensionFloorplan(node, context({}, false, 'document')) + const string = geometry + ? flatten(geometry).find((entry) => entry.kind === 'dimension-string') + : null + + expect(string).toMatchObject({ + terminator: 'dot', + textPosition: 'centered', + extensionStartGap: 0.025, + extensionOvershoot: 0.08, + }) + expect(dimensionSegments(geometry)[0]?.text).toBe('2000') + }) + + test('uses the live metric notation for manual dimensions in edit mode', () => { + const node = ConstructionDimensionNode.parse({ + anchors: [ + [0, 0, 0], + [2, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + }) + + const geometry = buildConstructionDimensionFloorplan( + node, + context({}, false, 'edit', 'millimeters'), + ) + expect(dimensionSegments(geometry)[0]?.text).toBe('2000') + }) + + test('shows witness and baseline handles only while selected', () => { + const node = ConstructionDimensionNode.parse({}) + const idle = buildConstructionDimensionFloorplan(node, context()) + const selected = buildConstructionDimensionFloorplan(node, context({}, true)) + + expect(idle && flatten(idle).filter((entry) => entry.kind === 'endpoint-handle')).toHaveLength( + 0, + ) + const handles = selected + ? flatten(selected).filter((entry) => entry.kind === 'endpoint-handle') + : [] + expect(handles).toHaveLength(3) + expect(handles).toContainEqual( + expect.objectContaining({ + affordance: 'move-construction-dimension-witness', + payload: { witnessIndex: 0 }, + }), + ) + expect(handles).toContainEqual( + expect.objectContaining({ + affordance: 'move-construction-dimension-witness', + payload: { witnessIndex: 1 }, + }), + ) + expect(handles).toContainEqual( + expect.objectContaining({ affordance: 'move-construction-dimension-baseline' }), + ) + }) + + test('keeps linked geometry read-only in a dependent drawing', () => { + const node = ConstructionDimensionNode.parse({ + metadata: { drawingCoordinationLocked: true }, + }) + const geometry = buildConstructionDimensionFloorplan(node, context({}, true)) + + expect( + geometry && flatten(geometry).filter((entry) => entry.kind === 'endpoint-handle'), + ).toHaveLength(0) + }) + + test('renders radius notation with a leader and center mark', () => { + const node = ConstructionDimensionNode.parse({ + mode: 'radius', + anchors: [ + [0, 0, 0], + [2, 0, 0], + ], + baseline: { origin: [3, 1], direction: [1, 0] }, + }) + const geometry = buildConstructionDimensionFloorplan(node, context()) + const entries = geometry ? flatten(geometry) : [] + + expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({ + text: 'R 2m', + cx: 3, + cy: 1, + }) + expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6) + }) + + test('updates an associative curved-wall radius when the host curve changes', () => { + const wall = WallNode.parse({ + id: 'wall_curve', + start: [0, 0], + end: [4, 0], + curveOffset: 1, + }) + const node = ConstructionDimensionNode.parse({ + mode: 'radius', + anchors: [ + { + kind: 'feature', + reference: { nodeId: wall.id, featureId: 'wall:curve:center' }, + fallback: [2, 0, 1.5], + }, + { + kind: 'feature', + reference: { nodeId: wall.id, featureId: 'wall:midpoint' }, + fallback: [2, 0, -1], + }, + ], + baseline: { origin: [2, -1.5], direction: [0, -1] }, + }) + const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 }) + const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall })) + const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall })) + const originalLabel = + original && flatten(original).find((entry) => entry.kind === 'dimension-label') + const reshapedLabel = + reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label') + + expect(originalLabel).toMatchObject({ text: 'R 2.5m' }) + expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' }) + }) + + test('renders diameter and repeated-feature notation', () => { + const node = ConstructionDimensionNode.parse({ + mode: 'diameter', + anchors: [ + [-1, 0, 0], + [1, 0, 0], + ], + featureCount: 6, + prefix: 'TYP · ', + suffix: ' CLR', + }) + const geometry = buildConstructionDimensionFloorplan(node, context()) + const entries = geometry ? flatten(geometry) : [] + + expect(dimensionSegments(geometry)[0]).toMatchObject({ + text: 'TYP · 6 x Ø 2m CLR', + start: [-1, 0], + end: [1, 0], + }) + expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4) + }) + + test('renders a standalone center mark from a center and radius point', () => { + const node = ConstructionDimensionNode.parse({ + mode: 'center-mark', + anchors: [ + [3, 0, 4], + [5, 0, 4], + ], + }) + const geometry = buildConstructionDimensionFloorplan(node, context()) + const entries = geometry ? flatten(geometry) : [] + + expect(entries.filter((entry) => entry.kind === 'line')).toHaveLength(4) + expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false) + expect(dimensionSegments(geometry).length).toBe(0) + }) + + test('renders chord and arc-length dimensions', () => { + const chord = ConstructionDimensionNode.parse({ + mode: 'chord', + anchors: [ + [-1, 0, 0], + [1, 0, 0], + ], + baseline: { origin: [0, 1], direction: [1, 0] }, + }) + const arc = ConstructionDimensionNode.parse({ + mode: 'arc-length', + anchors: [ + [2, 0, 0], + [0, 0, 0], + [0, 0, 2], + ], + baseline: { origin: [2, 2], direction: [1, 0] }, + }) + const chordGeometry = buildConstructionDimensionFloorplan(chord, context()) + const arcGeometry = buildConstructionDimensionFloorplan(arc, context()) + const chordEntries = chordGeometry ? flatten(chordGeometry) : [] + const arcEntries = arcGeometry ? flatten(arcGeometry) : [] + + expect(dimensionSegments(chordGeometry)[0]).toMatchObject({ + text: 'CH 2m', + }) + expect(arcEntries.some((entry) => entry.kind === 'path')).toBe(true) + expect(arcEntries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({ + text: 'ARC 3.14m', + }) + }) + + test('renders angular dimensions with an architectural angle label', () => { + const node = ConstructionDimensionNode.parse({ + mode: 'angular', + anchors: [ + [2, 0, 0], + [0, 0, 0], + [0, 0, 2], + ], + baseline: { origin: [1.5, 0.5], direction: [1, 0] }, + }) + const geometry = buildConstructionDimensionFloorplan(node, context()) + const entries = geometry ? flatten(geometry) : [] + + expect(entries.some((entry) => entry.kind === 'path')).toBe(true) + expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({ + cx: 1.5, + cy: 0.5, + text: '∠ 90°', + screenUpright: true, + }) + expect(entries).toContainEqual(expect.objectContaining({ kind: 'line', x2: 1.5, y2: 0.5 })) + }) + + test('renders signed coordinate labels for repeated circular features', () => { + const node = ConstructionDimensionNode.parse({ + mode: 'coordinate', + anchors: [ + [0, 0, 0], + [2, 0, 3], + [-1, 0, 4], + ], + }) + const geometry = buildConstructionDimensionFloorplan(node, context()) + const labels = geometry + ? flatten(geometry) + .filter((entry) => entry.kind === 'dimension-label') + .map((entry) => entry.text) + : [] + + expect(labels).toEqual(['P1 · X 2m · Y 3m', 'P2 · X -1m · Y 4m']) + }) +}) diff --git a/packages/nodes/src/construction-dimension/floorplan.ts b/packages/nodes/src/construction-dimension/floorplan.ts new file mode 100644 index 00000000..9e8ef2ae --- /dev/null +++ b/packages/nodes/src/construction-dimension/floorplan.ts @@ -0,0 +1,660 @@ +import type { + AnyNodeId, + ConstructionDimensionNode, + FloorplanGeometry, + FloorplanPoint, + FloorplanStyle, + GeometryContext, + MeasurementAnchor, + MeasurementPoint, + WallNode, +} from '@pascal-app/core' +import { + constructionDimensionRequiredAnchorCount, + getWallAssemblyFaceOffsets, + getWallAssemblyThickness, + getWallCurveFrameAt, + resolveWallAssemblyDatumReferences, +} from '@pascal-app/core' +import { + readFloorplanContext, + readFloorplanMetricNotationOverride, + withFloorplanGeometryMetadata, +} from '@pascal-app/editor' +import { resolveMeasurementAnchor } from '../measurement/resolve' +import { + type ConstructionLengthFormatOptions, + type ConstructionLengthProfile, + formatConstructionLength, +} from '../shared/construction-length' +import { buildDimensionStringGeometry } from '../shared/dimension-string' +import { + resolveCircularConstructionDimensionLayout, + resolveConstructionDimensionLayout, +} from './geometry' + +const DEFAULT_STROKE = '#334155' +const DANGLING_STROKE = '#dc2626' +const EPSILON = 1e-6 + +export function buildConstructionDimensionFloorplan( + node: ConstructionDimensionNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.visible === false) return null + + const resolved = node.anchors.map((anchor) => resolveDimensionAnchor(node, anchor, ctx)) + const points = resolved.map((anchor) => anchor.point) as MeasurementPoint[] + if (points.length < constructionDimensionRequiredAnchorCount(node.mode)) return null + + const selected = ctx.viewState?.selected || ctx.viewState?.highlighted + const baseStroke = selected + ? (ctx.viewState?.palette.selectedStroke ?? '#2563eb') + : (ctx.viewState?.palette.measurementStroke ?? DEFAULT_STROKE) + const dangling = resolved.some((anchor) => anchor.dangling) + const stroke = dangling ? DANGLING_STROKE : baseStroke + const unit = ctx.viewState?.unit ?? 'metric' + const floorplanContext = readFloorplanContext(ctx) + const profile: ConstructionLengthProfile = + floorplanContext.purpose === 'document' ? 'document' : 'editor' + const metricNotationOverride = readFloorplanMetricNotationOverride(ctx) + const displayNode = + profile === 'editor' && metricNotationOverride + ? { ...node, metricNotation: metricNotationOverride } + : node + const editable = + ctx.viewState?.selected === true && + !( + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + node.metadata.drawingCoordinationLocked === true + ) + + switch (node.mode) { + case 'linear': + case 'chord': + return withFloorplanGeometryMetadata( + buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'radius': + return withFloorplanGeometryMetadata( + buildRadius(displayNode, points, stroke, dangling, unit, profile, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'diameter': + return withFloorplanGeometryMetadata( + buildDiameter(displayNode, points, stroke, dangling, unit, profile, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'center-mark': + return withFloorplanGeometryMetadata( + buildCenterMarkOnly(displayNode, points, stroke, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'arc-length': + return withFloorplanGeometryMetadata( + buildArcLength(displayNode, points, stroke, dangling, unit, profile, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'angular': + return withFloorplanGeometryMetadata( + buildAngular(displayNode, points, stroke, dangling, editable), + { annotationRole: 'manual-dimension' }, + ) + case 'coordinate': + return withFloorplanGeometryMetadata( + buildCoordinate(displayNode, points, stroke, dangling, unit, profile, editable), + { annotationRole: 'manual-dimension' }, + ) + } +} + +function resolveDimensionAnchor( + node: ConstructionDimensionNode, + anchor: MeasurementAnchor, + ctx: GeometryContext, +): ReturnType { + const resolved = resolveMeasurementAnchor(anchor, (id) => ctx.resolve(id)) + if (Array.isArray(anchor) || resolved.dangling) return resolved + if (!supportsWallDatum(anchor.reference.featureId)) return resolved + + const referenced = ctx.resolve(anchor.reference.nodeId as AnyNodeId) + if (referenced?.type !== 'wall') return resolved + + const t = wallFeatureParameter(anchor.reference.featureId, anchor.reference.parameters?.t) + const frame = getWallCurveFrameAt(referenced, t) + const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame) + const offset = wallDatumOffset(referenced, node.datumPolicy, side) + + return { + ...resolved, + point: [ + frame.point.x + frame.normal.x * offset, + resolved.point[1], + frame.point.y + frame.normal.y * offset, + ], + } +} + +function supportsWallDatum(featureId: string): boolean { + return ( + featureId === 'wall:start' || + featureId === 'wall:end' || + featureId === 'wall:centerline' || + featureId === 'wall:midpoint' || + featureId === 'wall:face:left' || + featureId === 'wall:face:right' || + featureId === 'wall:top-centerline' + ) +} + +function wallFeatureParameter(featureId: string, parameter: unknown): number { + if (featureId === 'wall:start') return 0 + if (featureId === 'wall:end') return 1 + return typeof parameter === 'number' ? Math.max(0, Math.min(1, parameter)) : 0.5 +} + +function wallDatumSide( + node: ConstructionDimensionNode, + featureId: string, + resolved: ReturnType, + frame: ReturnType, +): 1 | -1 { + if (featureId === 'wall:face:left') return 1 + if (featureId === 'wall:face:right') return -1 + + const baselineProjection = + (node.baseline.origin[0] - frame.point.x) * frame.normal.x + + (node.baseline.origin[1] - frame.point.y) * frame.normal.y + if (Math.abs(baselineProjection) > EPSILON) return baselineProjection > 0 ? 1 : -1 + + const resolvedNormal = resolved.normal + if (resolvedNormal) { + const normalProjection = resolvedNormal[0] * frame.normal.x + resolvedNormal[2] * frame.normal.y + if (Math.abs(normalProjection) > EPSILON) return normalProjection > 0 ? 1 : -1 + } + return 1 +} + +function wallDatumOffset( + wall: WallNode, + policy: ConstructionDimensionNode['datumPolicy'], + side: 1 | -1, +): number { + if (policy === 'centerline') return 0 + if (policy === 'wall-face') { + const faces = getWallAssemblyFaceOffsets(wall) + return side > 0 ? faces.exterior : faces.interior + } + + const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face' + const candidates = resolveWallAssemblyDatumReferences(wall) + .filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side) + .map((reference) => reference.offset) + if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side + return side > 0 ? Math.max(...candidates) : Math.min(...candidates) +} + +function buildLinearOrChord( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + editable: boolean, +): FloorplanGeometry { + const layout = resolveConstructionDimensionLayout(node, points) + const children: FloorplanGeometry[] = [] + const suppressedSegments = suppressedDimensionSegmentIndexes(node) + const visibleSegments = layout.segments.filter((_, index) => !suppressedSegments.has(index)) + const dimensionSegments = visibleSegments.map((segment) => { + const baseText = `${node.mode === 'chord' ? 'CH ' : ''}${formatConstructionLength(segment.value, unit, profile, lengthFormatOptions(node))}` + return { + witnessStart: segment.witnessStart, + witnessEnd: segment.witnessEnd, + dimensionStart: segment.dimensionStart, + dimensionEnd: segment.dimensionEnd, + text: notation(node, baseText, dangling), + } + }) + children.push( + ...(dimensionSegments.length > 0 + ? [ + buildDimensionStringGeometry({ + segments: dimensionSegments, + offsetNormal: layout.normal, + offsetDistance: 0, + extensionStartGap: node.extensionStartGap, + extensionOvershoot: node.extensionOvershoot, + terminator: node.terminator, + textPosition: node.textPosition, + stroke, + }), + ] + : []), + ...visibleSegments.map((segment) => hitLine(segment.dimensionStart, segment.dimensionEnd)), + ) + if (editable) + children.push(...witnessHandles(layout.witnessPoints), baselineHandle(layout.midpoint)) + return dimensionGroup(children) +} + +function buildRadius( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + editable: boolean, +): FloorplanGeometry | null { + const layout = resolveCircularConstructionDimensionLayout('radius', points) + if (!layout) return null + const labelPoint: FloorplanPoint = node.baseline.origin + const children: FloorplanGeometry[] = [ + styledPolyline([layout.center, layout.start, labelPoint], stroke), + ...openArrow(layout.start, layout.center, stroke), + labelGeometry( + labelPoint, + notation( + node, + `R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`, + dangling, + ), + angle(layout.start, labelPoint), + ), + ] + if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke)) + if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint)) + return dimensionGroup(children) +} + +function buildDiameter( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + editable: boolean, +): FloorplanGeometry | null { + const layout = resolveCircularConstructionDimensionLayout('diameter', points) + if (!layout?.end) return null + const direction = normalized(layout.start, layout.end) + if (!direction) return null + const normal: FloorplanPoint = [-direction[1], direction[0]] + const children: FloorplanGeometry[] = [ + dimensionGeometry( + node, + layout.start, + layout.end, + layout.start, + layout.end, + normal, + notation( + node, + `Ø ${formatConstructionLength(layout.radius * 2, unit, profile, lengthFormatOptions(node))}`, + dangling, + ), + stroke, + ), + hitLine(layout.start, layout.end), + ] + if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke)) + if (editable) children.push(...anchorHandles(points)) + return dimensionGroup(children) +} + +function buildCenterMarkOnly( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + editable: boolean, +): FloorplanGeometry | null { + const layout = resolveCircularConstructionDimensionLayout('center-mark', points) + if (!layout) return null + const children: FloorplanGeometry[] = centerMark(layout.center, layout.radius, stroke, true) + if (editable) children.push(...anchorHandles(points)) + return dimensionGroup(children) +} + +function buildArcLength( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + editable: boolean, +): FloorplanGeometry | null { + const layout = resolveCircularConstructionDimensionLayout('arc-length', points) + if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null + const projectedEnd = arcPoint(layout.center, layout.radius, layout.endAngle) + const midAngle = layout.startAngle + layout.sweep / 2 + const arcMid = arcPoint(layout.center, layout.radius, midAngle) + const labelPoint: FloorplanPoint = node.baseline.origin + const children: FloorplanGeometry[] = [ + arcGeometry(layout.center, layout.radius, layout.startAngle, layout.sweep, stroke), + styledLine(layout.center, layout.start, stroke, '0.08 0.08'), + styledLine(layout.center, projectedEnd, stroke, '0.08 0.08'), + styledLine(arcMid, labelPoint, stroke, '0.08 0.08'), + ...openArrow( + layout.start, + arcPoint(layout.center, layout.radius, layout.startAngle + layout.sweep * 0.08), + stroke, + ), + ...openArrow( + projectedEnd, + arcPoint(layout.center, layout.radius, layout.endAngle - layout.sweep * 0.08), + stroke, + ), + labelGeometry( + labelPoint, + notation( + node, + `ARC ${formatConstructionLength(layout.arcLength, unit, profile, lengthFormatOptions(node))}`, + dangling, + ), + 0, + true, + ), + ] + if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke)) + if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint)) + return dimensionGroup(children) +} + +function buildAngular( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + editable: boolean, +): FloorplanGeometry | null { + const layout = resolveCircularConstructionDimensionLayout('angular', points) + if (!(layout?.end && Math.abs(layout.sweep) > EPSILON)) return null + const endRadius = distance(layout.center, layout.end) + const maximumRadius = Math.max(0.25, Math.min(layout.radius, endRadius) * 0.9) + const requestedRadius = distance(layout.center, node.baseline.origin) + const arcRadius = Math.min(maximumRadius, Math.max(0.25, requestedRadius)) + const midAngle = layout.startAngle + layout.sweep / 2 + const arcMid = arcPoint(layout.center, arcRadius, midAngle) + const labelPoint: FloorplanPoint = node.baseline.origin + const startRayEnd = arcPoint( + layout.center, + Math.max(layout.radius, arcRadius + 0.12), + layout.startAngle, + ) + const endRayEnd = arcPoint(layout.center, Math.max(endRadius, arcRadius + 0.12), layout.endAngle) + const degrees = (Math.abs(layout.sweep) * 180) / Math.PI + const children: FloorplanGeometry[] = [ + styledLine(layout.center, startRayEnd, stroke), + styledLine(layout.center, endRayEnd, stroke), + arcGeometry(layout.center, arcRadius, layout.startAngle, layout.sweep, stroke), + styledLine(arcMid, labelPoint, stroke, '0.08 0.08'), + labelGeometry(labelPoint, notation(node, `∠ ${formatDegrees(degrees)}`, dangling), 0, true), + ] + if (node.showCenterMark) children.push(...centerMark(layout.center, arcRadius, stroke)) + if (editable) children.push(...anchorHandles(points), baselineHandle(node.baseline.origin)) + return dimensionGroup(children) +} + +function buildCoordinate( + node: ConstructionDimensionNode, + points: MeasurementPoint[], + stroke: string, + dangling: boolean, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + editable: boolean, +): FloorplanGeometry | null { + const datum: FloorplanPoint = [points[0]![0], points[0]![2]] + const features = points.slice(1).map((point): FloorplanPoint => [point[0], point[2]]) + if (features.length === 0) return null + const children: FloorplanGeometry[] = [...centerMark(datum, 0.4, stroke, true)] + features.forEach((feature, index) => { + const dx = feature[0] - datum[0] + const dy = feature[1] - datum[1] + const label = notation( + node, + `P${index + 1} · X ${formatConstructionLength(dx, unit, profile, lengthFormatOptions(node))} · Y ${formatConstructionLength(dy, unit, profile, lengthFormatOptions(node))}`, + dangling, + false, + ) + children.push( + styledLine(datum, feature, stroke, '0.08 0.08'), + labelGeometry(feature, label, 0, true, 10), + ...centerMark(feature, 0.3, stroke, true), + ) + }) + if (editable) children.push(...anchorHandles(points)) + return dimensionGroup(children) +} + +function suppressedDimensionSegmentIndexes(node: ConstructionDimensionNode): ReadonlySet { + const metadata = node.metadata + if (!(typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata))) { + return new Set() + } + const value = metadata.suppressedDimensionSegmentIndexes + if (!Array.isArray(value)) return new Set() + return new Set( + value.filter( + (entry): entry is number => + typeof entry === 'number' && Number.isInteger(entry) && entry >= 0, + ), + ) +} + +function dimensionGroup(children: FloorplanGeometry[]): FloorplanGeometry { + return { kind: 'group', children } +} + +function lengthFormatOptions(node: ConstructionDimensionNode): ConstructionLengthFormatOptions { + return { + imperialPrecision: node.imperialPrecision, + metricNotation: node.metricNotation, + } +} + +function notation( + node: ConstructionDimensionNode, + base: string, + dangling: boolean, + includeFeatureCount = true, +): string { + const repeated = includeFeatureCount && node.featureCount > 1 ? `${node.featureCount} x ` : '' + const content = node.textOverride ?? `${repeated}${base}` + const decorated = `${node.prefix}${content}${node.suffix}` + return dangling ? `UNLINKED · ${decorated}` : decorated +} + +function dimensionGeometry( + node: ConstructionDimensionNode, + start: FloorplanPoint, + end: FloorplanPoint, + dimensionStart: FloorplanPoint, + dimensionEnd: FloorplanPoint, + offsetNormal: FloorplanPoint, + text: string, + stroke: string, +): FloorplanGeometry { + return { + kind: 'dimension', + start, + end, + dimensionStart, + dimensionEnd, + offsetNormal, + offsetDistance: 0, + extensionStartGap: node.extensionStartGap, + extensionOvershoot: node.extensionOvershoot, + terminator: node.terminator, + textPosition: node.textPosition, + text, + stroke, + } +} + +function arcGeometry( + center: FloorplanPoint, + radius: number, + startAngle: number, + sweep: number, + stroke: string, +): FloorplanGeometry { + const start = arcPoint(center, radius, startAngle) + const end = arcPoint(center, radius, startAngle + sweep) + return { + kind: 'path', + d: `M ${start[0]} ${start[1]} A ${radius} ${radius} 0 ${Math.abs(sweep) > Math.PI ? 1 : 0} ${sweep >= 0 ? 1 : 0} ${end[0]} ${end[1]}`, + ...lineStyle(stroke), + } +} + +function styledLine( + start: FloorplanPoint, + end: FloorplanPoint, + stroke: string, + strokeDasharray?: string, +): FloorplanGeometry { + return { + kind: 'line', + x1: start[0], + y1: start[1], + x2: end[0], + y2: end[1], + ...lineStyle(stroke, strokeDasharray), + } +} + +function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry { + return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) } +} + +function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle { + return { + fill: 'none', + stroke, + strokeWidth: 0.9, + strokeDasharray, + vectorEffect: 'non-scaling-stroke', + strokeLinecap: 'butt', + strokeLinejoin: 'miter', + } +} + +function labelGeometry( + point: FloorplanPoint, + text: string, + labelAngle: number, + screenUpright = false, + offsetPx = 0, +): FloorplanGeometry { + return { + kind: 'dimension-label', + cx: point[0], + cy: point[1], + text, + angle: labelAngle, + screenUpright, + offsetPx, + appearance: 'outlined', + } +} + +function centerMark( + center: FloorplanPoint, + radius: number, + stroke: string, + force = false, +): FloorplanGeometry[] { + if (!force && radius <= EPSILON) return [] + const half = Math.min(0.22, Math.max(0.1, radius * 0.18)) + const gap = Math.min(0.045, half * 0.3) + return [ + styledLine([center[0] - half, center[1]], [center[0] - gap, center[1]], stroke), + styledLine([center[0] + gap, center[1]], [center[0] + half, center[1]], stroke), + styledLine([center[0], center[1] - half], [center[0], center[1] - gap], stroke), + styledLine([center[0], center[1] + gap], [center[0], center[1] + half], stroke), + ] +} + +function openArrow( + tip: FloorplanPoint, + toward: FloorplanPoint, + stroke: string, +): FloorplanGeometry[] { + const direction = normalized(tip, toward) + if (!direction) return [] + const length = 0.15 + const halfWidth = 0.055 + const base: FloorplanPoint = [tip[0] + direction[0] * length, tip[1] + direction[1] * length] + const normal: FloorplanPoint = [-direction[1], direction[0]] + return [ + styledLine(tip, [base[0] + normal[0] * halfWidth, base[1] + normal[1] * halfWidth], stroke), + styledLine(tip, [base[0] - normal[0] * halfWidth, base[1] - normal[1] * halfWidth], stroke), + ] +} + +function baselineHandle(point: FloorplanPoint): FloorplanGeometry { + return { + kind: 'endpoint-handle', + point, + state: 'idle', + variant: 'curve', + affordance: 'move-construction-dimension-baseline', + payload: null, + } +} + +function anchorHandles(points: readonly MeasurementPoint[]): FloorplanGeometry[] { + return witnessHandles(points.map((point): FloorplanPoint => [point[0], point[2]])) +} + +function witnessHandles(points: readonly FloorplanPoint[]): FloorplanGeometry[] { + return points.map((point, witnessIndex) => ({ + kind: 'endpoint-handle', + point, + state: 'idle', + affordance: 'move-construction-dimension-witness', + payload: { witnessIndex }, + })) +} + +function hitLine(start: FloorplanPoint, end: FloorplanPoint): FloorplanGeometry { + return { + kind: 'hit-line', + x1: start[0], + y1: start[1], + x2: end[0], + y2: end[1], + strokeWidthPx: 12, + } +} + +function arcPoint(center: FloorplanPoint, radius: number, pointAngle: number): FloorplanPoint { + return [center[0] + Math.cos(pointAngle) * radius, center[1] + Math.sin(pointAngle) * radius] +} + +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 <= EPSILON ? null : [dx / magnitude, dy / magnitude] +} + +function distance(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +function angle(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.atan2(second[1] - first[1], second[0] - first[0]) +} + +function formatDegrees(value: number): string { + return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°` +} diff --git a/packages/nodes/src/construction-dimension/geometry.ts b/packages/nodes/src/construction-dimension/geometry.ts new file mode 100644 index 00000000..b9271d6a --- /dev/null +++ b/packages/nodes/src/construction-dimension/geometry.ts @@ -0,0 +1,154 @@ +import type { + ConstructionDimensionMode, + ConstructionDimensionNode, + FloorplanPoint, + MeasurementPoint, +} from '@pascal-app/core' + +export type ConstructionDimensionSegmentLayout = { + dimensionStart: FloorplanPoint + dimensionEnd: FloorplanPoint + value: number + witnessStart: FloorplanPoint + witnessEnd: FloorplanPoint +} + +export type ConstructionDimensionLayout = { + dimensionPoints: FloorplanPoint[] + direction: FloorplanPoint + midpoint: FloorplanPoint + normal: FloorplanPoint + segments: ConstructionDimensionSegmentLayout[] + witnessPoints: FloorplanPoint[] +} + +const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]] + +export type CircularConstructionDimensionLayout = { + center: FloorplanPoint + start: FloorplanPoint + end: FloorplanPoint | null + radius: number + startAngle: number + endAngle: number + sweep: number + chordLength: number + arcLength: number +} + +export function resolveCircularConstructionDimensionLayout( + mode: ConstructionDimensionMode, + anchors: readonly MeasurementPoint[], +): CircularConstructionDimensionLayout | null { + if (anchors.length < 2) return null + const first = project(anchors[0]!) + const second = project(anchors[1]!) + + if (mode === 'diameter') { + const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2] + const radius = distance(first, second) / 2 + if (radius <= 1e-9) return null + return { + center, + start: first, + end: second, + radius, + startAngle: Math.atan2(first[1] - center[1], first[0] - center[0]), + endAngle: Math.atan2(second[1] - center[1], second[0] - center[0]), + sweep: Math.PI, + chordLength: radius * 2, + arcLength: Math.PI * radius, + } + } + + const usesMiddleCenter = mode === 'arc-length' || mode === 'angular' + const center = usesMiddleCenter ? second : first + const start = usesMiddleCenter ? first : second + const radius = distance(center, start) + if (radius <= 1e-9) return null + const startAngle = Math.atan2(start[1] - center[1], start[0] - center[0]) + const endAnchor = anchors[2] + const end = endAnchor ? project(endAnchor) : null + const endAngle = end ? Math.atan2(end[1] - center[1], end[0] - center[0]) : startAngle + const sweep = end ? normalizedSignedSweep(startAngle, endAngle) : 0 + return { + center, + start, + end, + radius, + startAngle, + endAngle, + sweep, + chordLength: end ? distance(start, end) : radius, + arcLength: Math.abs(sweep) * radius, + } +} + +function normalizedSignedSweep(startAngle: number, endAngle: number): number { + let sweep = endAngle - startAngle + while (sweep > Math.PI) sweep -= Math.PI * 2 + while (sweep <= -Math.PI) sweep += Math.PI * 2 + return sweep +} + +function distance(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +export function resolveConstructionDimensionLayout( + node: Pick, + anchors: readonly MeasurementPoint[], +): ConstructionDimensionLayout { + if (anchors.length < 2) { + throw new Error('Construction dimension layout requires at least two anchors') + } + const magnitude = Math.hypot(node.baseline.direction[0], node.baseline.direction[1]) + const direction: FloorplanPoint = [ + node.baseline.direction[0] / magnitude, + node.baseline.direction[1] / magnitude, + ] + const normal: FloorplanPoint = [-direction[1], direction[0]] + const witnessPoints = anchors.map(project) + const dimensionPoints = witnessPoints.map((point): FloorplanPoint => { + const deltaX = point[0] - node.baseline.origin[0] + const deltaY = point[1] - node.baseline.origin[1] + const distance = deltaX * direction[0] + deltaY * direction[1] + return [ + node.baseline.origin[0] + distance * direction[0], + node.baseline.origin[1] + distance * direction[1], + ] + }) + const segmentIndexes = + node.chainMode === 'continuous' + ? witnessPoints.slice(0, -1).map((_, index) => [index, index + 1] as const) + : Array.from( + { length: Math.floor(witnessPoints.length / 2) }, + (_, index) => [index * 2, index * 2 + 1] as const, + ) + const segments = segmentIndexes.map(([startIndex, endIndex]) => { + const witnessStart = witnessPoints[startIndex]! + const witnessEnd = witnessPoints[endIndex]! + const dimensionStart = dimensionPoints[startIndex]! + const dimensionEnd = dimensionPoints[endIndex]! + return { + dimensionStart, + dimensionEnd, + value: Math.abs( + (witnessEnd[0] - witnessStart[0]) * direction[0] + + (witnessEnd[1] - witnessStart[1]) * direction[1], + ), + witnessStart, + witnessEnd, + } + }) + const first = dimensionPoints[0]! + const last = dimensionPoints.at(-1)! + return { + dimensionPoints, + direction, + midpoint: [(first[0] + last[0]) / 2, (first[1] + last[1]) / 2], + normal, + segments, + witnessPoints, + } +} diff --git a/packages/nodes/src/construction-dimension/index.ts b/packages/nodes/src/construction-dimension/index.ts new file mode 100644 index 00000000..46043047 --- /dev/null +++ b/packages/nodes/src/construction-dimension/index.ts @@ -0,0 +1,6 @@ +export { constructionDimensionDefinition } from './definition' +export { buildConstructionDimensionFloorplan } from './floorplan' +export { + resolveCircularConstructionDimensionLayout, + resolveConstructionDimensionLayout, +} from './geometry' diff --git a/packages/nodes/src/construction-dimension/panel.tsx b/packages/nodes/src/construction-dimension/panel.tsx new file mode 100644 index 00000000..3a1ef55a --- /dev/null +++ b/packages/nodes/src/construction-dimension/panel.tsx @@ -0,0 +1,448 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + type ConstructionDimensionDatumPolicy, + type ConstructionDimensionDrawingPresentation, + type ConstructionDimensionImperialPrecision, + type ConstructionDimensionMetricNotation, + type ConstructionDimensionNode, + type ConstructionDimensionTerminator, + type ConstructionDimensionTextPosition, + type ConstructionDrawingType, + resolveConstructionDimensionDrawingOverride, + resolveConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingPresentation, + setConstructionDimensionDrawingSuppressedSegments, + useScene, +} from '@pascal-app/core' +import { + ActionButton, + ActionGroup, + DRAWING_TYPE_OPTIONS, + PanelSection, + PanelWrapper, + SliderControl, + triggerSFX, + useDrawingView, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Trash2 } from 'lucide-react' +import { useShallow } from 'zustand/react/shallow' + +const MODE_LABELS: Record = { + linear: 'Linear', + radius: 'Radius', + diameter: 'Diameter', + 'center-mark': 'Center mark', + chord: 'Chord', + 'arc-length': 'Arc length', + angular: 'Angular', + coordinate: 'Coordinate', +} + +const DATUM_POLICY_OPTIONS: Array<{ label: string; value: ConstructionDimensionDatumPolicy }> = [ + { label: 'Centerline', value: 'centerline' }, + { label: 'Wall face', value: 'wall-face' }, + { label: 'Structural face', value: 'structural-face' }, + { label: 'Finish face', value: 'finish-face' }, +] + +const TERMINATOR_OPTIONS: Array<{ label: string; value: ConstructionDimensionTerminator }> = [ + { label: 'Architectural tick', value: 'architectural-tick' }, + { label: 'Filled arrow', value: 'filled-arrow' }, + { label: 'Open arrow', value: 'open-arrow' }, + { label: 'Dot', value: 'dot' }, +] + +const TEXT_POSITION_OPTIONS: Array<{ label: string; value: ConstructionDimensionTextPosition }> = [ + { label: 'Above line', value: 'above' }, + { label: 'Centered on line', value: 'centered' }, +] + +const IMPERIAL_PRECISION_OPTIONS: Array<{ + label: string + value: ConstructionDimensionImperialPrecision +}> = [ + { label: 'Nearest inch', value: '1' }, + { label: 'Nearest 1/2 inch', value: '1/2' }, + { label: 'Nearest 1/4 inch', value: '1/4' }, + { label: 'Nearest 1/8 inch', value: '1/8' }, + { label: 'Nearest 1/16 inch', value: '1/16' }, +] + +const METRIC_NOTATION_OPTIONS: Array<{ + label: string + value: ConstructionDimensionMetricNotation +}> = [ + { label: 'Meters', value: 'meters' }, + { label: 'Millimeters', value: 'millimeters' }, +] + +export default function ConstructionDimensionPanel() { + const selectedId = useViewer((state) => state.selection.selectedIds[0]) + const setSelection = useViewer((state) => state.setSelection) + const dimension = useScene((state) => { + const node = selectedId ? state.nodes[selectedId as AnyNodeId] : undefined + return node?.type === 'construction-dimension' ? node : null + }) + const updateNode = useScene((state) => state.updateNode) + const deleteNode = useScene((state) => state.deleteNode) + const activeDrawingType = useDrawingView((state) => state.drawingType) + + if (!(dimension && selectedId)) return null + const update = (patch: Partial) => updateNode(dimension.id, patch) + const supportsCenterMark = ['radius', 'diameter', 'arc-length', 'angular'].includes( + dimension.mode, + ) + const activeDrawingLabel = + DRAWING_TYPE_OPTIONS.find((option) => option.id === activeDrawingType)?.label ?? 'Floor plan' + const activePresentation = resolveConstructionDimensionDrawingPresentation( + dimension, + activeDrawingType, + ) + const activeDrawingOverride = resolveConstructionDimensionDrawingOverride( + dimension, + activeDrawingType, + ) + const suppressedSegmentsText = formatSuppressedSegments( + activeDrawingOverride?.suppressedSegmentIndexes ?? [], + ) + const updateDrawingPresentation = ( + drawingType: ConstructionDrawingType, + presentation: ConstructionDimensionDrawingPresentation, + ) => { + const drawingOverrides = setConstructionDimensionDrawingPresentation( + dimension, + drawingType, + presentation, + ) + const firstFoundationController = + presentation === 'controlled' && !dimension.controllingDimensionId + ? selectFoundationControllers(useScene.getState().nodes, dimension.id)[0] + : undefined + update({ + drawingOverrides, + ...(presentation === 'controlled' && !dimension.controllingDimensionId + ? { controllingDimensionId: firstFoundationController?.id ?? null } + : {}), + }) + } + const updateSuppressedSegments = (value: string) => { + update({ + drawingOverrides: setConstructionDimensionDrawingSuppressedSegments( + dimension, + activeDrawingType, + parseSuppressedSegments(value), + ), + }) + } + + return ( + setSelection({ selectedIds: [] })} + title="Construction Dimension" + width={320} + > + +
+ Mode + {MODE_LABELS[dimension.mode]} +
+ update({ featureCount })} + precision={0} + step={1} + value={dimension.featureCount} + /> + {supportsCenterMark ? ( + + ) : null} +
+ + + + update({ drawingType: drawingType as ConstructionDrawingType }) + } + options={DRAWING_TYPE_OPTIONS.map((option) => ({ + label: option.label, + value: option.id, + }))} + value={dimension.drawingType} + /> + + updateDrawingPresentation( + activeDrawingType, + presentation as ConstructionDimensionDrawingPresentation, + ) + } + options={[ + { label: 'Shown', value: 'shown' }, + { label: 'Omitted', value: 'omit' }, + ...(activeDrawingType === 'floor-plan' + ? [{ label: 'Controlled by foundation', value: 'controlled' }] + : []), + ]} + value={activePresentation} + /> + {activeDrawingType === 'floor-plan' && activePresentation === 'controlled' ? ( + + update({ + controllingDimensionId, + }) + } + value={dimension.controllingDimensionId ?? ''} + /> + ) : null} +

+ Linked dimensions reuse the controller's associative anchors and update with it. +

+ +

+ Segment numbers are one-based and apply only in this drawing view. +

+
+ + + update({ prefix })} + value={dimension.prefix} + /> + update({ suffix })} + value={dimension.suffix} + /> + update({ textOverride: textOverride || null })} + placeholder="Use measured value" + value={dimension.textOverride ?? ''} + /> + + + + + update({ datumPolicy: datumPolicy as ConstructionDimensionDatumPolicy }) + } + options={DATUM_POLICY_OPTIONS} + value={dimension.datumPolicy} + /> + + update({ terminator: terminator as ConstructionDimensionTerminator }) + } + options={TERMINATOR_OPTIONS} + value={dimension.terminator} + /> + + update({ textPosition: textPosition as ConstructionDimensionTextPosition }) + } + options={TEXT_POSITION_OPTIONS} + value={dimension.textPosition} + /> + + update({ + imperialPrecision: imperialPrecision as ConstructionDimensionImperialPrecision, + }) + } + options={IMPERIAL_PRECISION_OPTIONS} + value={dimension.imperialPrecision} + /> + + update({ metricNotation: metricNotation as ConstructionDimensionMetricNotation }) + } + options={METRIC_NOTATION_OPTIONS} + value={dimension.metricNotation} + /> + update({ extensionStartGap })} + precision={3} + step={0.005} + value={dimension.extensionStartGap} + /> + update({ extensionOvershoot })} + precision={3} + step={0.005} + value={dimension.extensionOvershoot} + /> + + + + + } + label="Delete" + onClick={() => { + triggerSFX('sfx:structure-delete') + deleteNode(dimension.id) + setSelection({ selectedIds: [] }) + }} + /> + + +
+ ) +} + +function selectFoundationControllers( + nodes: Record, + excludedId: AnyNodeId, +): ConstructionDimensionNode[] { + return Object.values(nodes).filter( + (candidate): candidate is ConstructionDimensionNode => + candidate.type === 'construction-dimension' && + candidate.id !== excludedId && + candidate.drawingType === 'foundation-plan', + ) +} + +function FoundationControllerField({ + dimensionId, + value, + onChange, +}: { + dimensionId: AnyNodeId + value: string + onChange: (value: NonNullable) => void +}) { + const foundationControllers = useScene( + useShallow((state) => selectFoundationControllers(state.nodes, dimensionId)), + ) + return ( + + onChange( + controllingDimensionId as NonNullable< + ConstructionDimensionNode['controllingDimensionId'] + >, + ) + } + options={foundationControllers.map((controller) => ({ + label: controller.name || 'Foundation dimension', + value: controller.id, + }))} + placeholder="No foundation dimensions" + value={value} + /> + ) +} + +function parseSuppressedSegments(value: string): number[] { + return [ + ...new Set( + value + .split(/[,\s]+/) + .map((part) => Number.parseInt(part, 10)) + .filter((index) => Number.isInteger(index) && index > 0) + .map((index) => index - 1), + ), + ].sort((left, right) => left - right) +} + +function formatSuppressedSegments(indexes: readonly number[]): string { + return indexes.map((index) => index + 1).join(', ') +} + +function SelectField({ + label, + value, + options, + placeholder, + disabled, + onChange, +}: { + label: string + value: string + options: Array<{ label: string; value: string }> + placeholder?: string + disabled?: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +function TextField({ + label, + value, + placeholder, + onCommit, +}: { + label: string + value: string + placeholder?: string + onCommit: (value: string) => void +}) { + return ( + + ) +} diff --git a/packages/nodes/src/construction-dimension/parametrics.ts b/packages/nodes/src/construction-dimension/parametrics.ts new file mode 100644 index 00000000..692dc0da --- /dev/null +++ b/packages/nodes/src/construction-dimension/parametrics.ts @@ -0,0 +1,6 @@ +import type { ConstructionDimensionNode, ParametricDescriptor } from '@pascal-app/core' + +export const constructionDimensionParametrics: ParametricDescriptor = { + groups: [], + customPanel: () => import('./panel'), +} diff --git a/packages/nodes/src/construction-dimension/schema.ts b/packages/nodes/src/construction-dimension/schema.ts new file mode 100644 index 00000000..3bf1d53e --- /dev/null +++ b/packages/nodes/src/construction-dimension/schema.ts @@ -0,0 +1,6 @@ +export { + ConstructionDimensionBaseline, + ConstructionDimensionChainMode, + ConstructionDimensionMode, + ConstructionDimensionNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 4a6f6cc6..4cf20a2b 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -6,9 +6,15 @@ import type { RoofSegmentNode, WallNode, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { + buildDoorFloorplanSchedule, + computeDoorFloorplanLevelData, +} from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { scaleHandleHeight } from './door-math' import { buildDoorFloorplan } from './floorplan' @@ -34,12 +40,6 @@ function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unk return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!door.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(door.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - // Width arrow on the door-local +X (right) or -X (left) side. Drag grows // the door from the anchored OPPOSITE edge; the door's wall-local center // re-centers so the anchored edge stays put. @@ -100,7 +100,7 @@ function doorHeightHandle(): HandleDescriptor { const roofMax = readRoofFaceHeightMax(n, scene, 1) if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax) const bottom = n.position[1] - n.height / 2 - return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) + return Math.max(MIN_DOOR_HEIGHT, readHostWallCeiling(n.wallId, scene) - bottom) }, currentValue: (n) => n.height, onDrag: (node) => publishOpeningResizeGuides(node, false), @@ -169,9 +169,14 @@ export const doorDefinition: NodeDefinition = { kind: 'door', snapProfile: 'item', facingIndicator: true, - schemaVersion: 1, + schemaVersion: 2, schema: DoorNode, category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildDoorFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, surfaceRole: 'joinery', // Leverage the schema's zod `.default()` annotations to compute the @@ -227,6 +232,7 @@ export const doorDefinition: NodeDefinition = { // Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute // direction + perpendicular for the cutout footprint. floorplan: buildDoorFloorplan, + computeFloorplanLevelData: computeDoorFloorplanLevelData, floorplanDependsOnSiblings: true, // Opening symbols position from `ctx.parent` (the host wall); merge the // walls' live drag overrides so the symbol tracks a wall / group drag in diff --git a/packages/nodes/src/door/floorplan.ts b/packages/nodes/src/door/floorplan.ts index f74f15e0..c74e6991 100644 --- a/packages/nodes/src/door/floorplan.ts +++ b/packages/nodes/src/door/floorplan.ts @@ -5,6 +5,11 @@ import type { GeometryContext, WallNode, } from '@pascal-app/core' +import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor' +import { + buildOpeningMarkAnnotation, + type OpeningFloorplanLevelData, +} from '../shared/opening-documentation' import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' /** @@ -722,7 +727,38 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp } } - return { kind: 'group', children } + const markAnnotation = buildOpeningMarkAnnotation( + node, + wall, + ctx.levelData as OpeningFloorplanLevelData | undefined, + { + preferredSide: swingSign === 1 ? -1 : 1, + stroke: showSelectedChrome ? '#f97316' : '#334155', + }, + ) + if (markAnnotation) children.push(markAnnotation) + + return { kind: 'group', children: children.map(markDoorPlanObstacle) } +} + +function markDoorPlanObstacle(geometry: FloorplanGeometry): FloorplanGeometry { + if (geometry.kind === 'group') { + const isOpeningMark = readFloorplanGeometryMetadata(geometry).annotationRole === 'opening-mark' + return isOpeningMark + ? geometry + : { ...geometry, children: geometry.children.map(markDoorPlanObstacle) } + } + if ( + geometry.kind === 'path' || + geometry.kind === 'polygon' || + geometry.kind === 'polyline' || + geometry.kind === 'rect' || + geometry.kind === 'circle' || + geometry.kind === 'line' + ) { + return withFloorplanGeometryMetadata(geometry, { annotationObstacle: 'bounds' }) + } + return geometry } /** diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index c4a5f52f..98423023 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' import { scaleHandleHeight } from './door-math' const doorTypeOptions = [ @@ -254,6 +255,7 @@ export default function DoorPanel() { useScene.temporal.getState().pause() const cloned = structuredClone(node) as any delete cloned.id + delete cloned.mark cloned.metadata = { ...cloned.metadata, isNew: true } const duplicate = DoorNode.parse(cloned) useScene.getState().createNode(duplicate, node.parentId as AnyNodeId) @@ -583,6 +585,21 @@ export default function DoorPanel() { )} + + + + { + test('registers persistent drawing sheets as non-geometric document nodes', () => { + expect(drawingSheetDefinition.kind).toBe('drawing-sheet') + expect(drawingSheetDefinition.bake).toBe('strip') + expect(drawingSheetDefinition.schemaVersion).toBe(4) + expect(drawingSheetDefinition.dirtyTracking).toBe(false) + expect(drawingSheetDefinition.capabilities).toMatchObject({ + deletable: true, + duplicable: true, + presettable: false, + }) + }) + + test('produces schema-valid defaults', () => { + expect( + drawingSheetDefinition.schema.safeParse({ + id: 'drawing-sheet_default', + type: 'drawing-sheet', + ...drawingSheetDefinition.defaults(), + }).success, + ).toBe(true) + }) + + test('contributes drawing-sheet matching through the editor extension', () => { + const sheet = drawingSheetDefinition.schema.parse({ + id: 'drawing-sheet_a101', + placedViews: [ + { + id: 'drawing-view_floor', + levelId: 'level_main', + drawingType: 'floor-plan', + drawingNumber: '1', + title: 'Main floor', + scale: '1:50', + }, + ], + }) + const resolveDrawingSheet = + getFloorplanNodeExtension(drawingSheetDefinition)?.resolveDrawingSheet + + expect( + resolveDrawingSheet?.({ node: sheet, levelId: 'level_main', drawingType: 'floor-plan' }), + ).toBe(sheet) + expect( + resolveDrawingSheet?.({ node: sheet, levelId: 'level_upper', drawingType: 'floor-plan' }), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/drawing-sheet/definition.ts b/packages/nodes/src/drawing-sheet/definition.ts new file mode 100644 index 00000000..a646621f --- /dev/null +++ b/packages/nodes/src/drawing-sheet/definition.ts @@ -0,0 +1,51 @@ +import { DrawingSheetNode as DrawingSheetNodeSchema, type NodeDefinition } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { DrawingSheetNode } from './schema' + +export const drawingSheetDefinition: NodeDefinition = { + kind: 'drawing-sheet', + bake: 'strip', + schemaVersion: 4, + schema: DrawingSheetNode, + category: 'analysis', + extensions: { + 'pascal:editor/floorplan': { + resolveDrawingSheet: ({ node, levelId, drawingType }) => + node.placedViews.some( + (view) => + (view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType, + ) + ? node + : null, + } satisfies FloorplanNodeExtension, + }, + + defaults: () => { + const stub = DrawingSheetNodeSchema.parse({ + id: 'drawing-sheet_default' as never, + type: 'drawing-sheet', + }) + const { id: _id, type: _type, ...rest } = stub + return rest + }, + + capabilities: { + deletable: true, + duplicable: true, + presettable: false, + }, + + dirtyTracking: false, + + presentation: { + label: 'Drawing Sheet', + description: 'A persistent construction-document sheet with placed views and title-block data.', + icon: { kind: 'iconify', name: 'lucide:file-text' }, + hidden: true, + }, + + mcp: { + description: + 'A persistent construction-document sheet containing paper setup, placed drawing views, notes, schedules, and title-block metadata.', + }, +} diff --git a/packages/nodes/src/drawing-sheet/index.ts b/packages/nodes/src/drawing-sheet/index.ts new file mode 100644 index 00000000..87d5ec38 --- /dev/null +++ b/packages/nodes/src/drawing-sheet/index.ts @@ -0,0 +1 @@ +export { drawingSheetDefinition } from './definition' diff --git a/packages/nodes/src/drawing-sheet/schema.ts b/packages/nodes/src/drawing-sheet/schema.ts new file mode 100644 index 00000000..418e13a6 --- /dev/null +++ b/packages/nodes/src/drawing-sheet/schema.ts @@ -0,0 +1,18 @@ +export { + DrawingSheetAnnotationProfile, + DrawingSheetDocumentMarker, + DrawingSheetDocumentMarkerKind, + DrawingSheetGeneralNote, + DrawingSheetGeneralNoteSet, + DrawingSheetKeyedNote, + DrawingSheetKeyedNoteDefinition, + DrawingSheetKeyedNoteInstance, + DrawingSheetNode, + DrawingSheetOrientation, + DrawingSheetPaperSize, + DrawingSheetPlacedView, + DrawingSheetRect, + DrawingSheetScale, + DrawingSheetSchedulePlacement, + DrawingSheetTitleBlock, +} from '@pascal-app/core' diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index e176fa7e..92d523d1 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -9,6 +9,7 @@ import { type GridEvent, getCeilingAt, getCeilingHeightAt, + resolveCeilingHeight, useScene, } from '@pascal-app/core' import { @@ -1132,7 +1133,7 @@ function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) { return pts }, [ceiling.polygon]) if (!geometry) return null - const y = ceiling.height ?? 2.5 + const y = resolveCeilingHeight(ceiling, useScene.getState().nodes) return ( diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index b1b9cafc..c8ef3063 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -2,10 +2,13 @@ import { type AnyNodeId, + type CeilingNode, DuctTerminalNode, emitter, pointInPolygon, + resolveCeilingHeight, resolveLevelId, + resolveSupportSlabPatch, sceneRegistry, useScene, type WallEvent, @@ -33,8 +36,6 @@ import { COLLAR_LENGTH, mountQuaternion } from './ports' const PREVIEW_OPACITY = 0.55 /** R/T yaw step — 45°. */ const ROTATE_STEP_RAD = Math.PI / 4 -/** Fallback height (meters) for a ceiling node that carries no `height`. */ -const DEFAULT_CEILING_HEIGHT = 2.5 /** Snap radius (meters) for mating the collar onto a nearby duct port. */ const PORT_SNAP_RADIUS_M = 0.5 @@ -227,12 +228,8 @@ const DuctTerminalTool = () => { for (const node of Object.values(nodes)) { if (node?.type !== 'ceiling') continue if (resolveLevelId(node, nodes) !== activeLevelId) continue - const ceiling = node as { - height?: number - polygon: Array<[number, number]> - holes?: Array> - } - const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT + const ceiling = node as CeilingNode + const height = resolveCeilingHeight(ceiling, nodes) const hit = hitLocalPlane(nativeEvent, height) if (!hit) continue if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue @@ -289,9 +286,14 @@ const DuctTerminalTool = () => { mount: p.mount, position: p.position, rotation: p.yaw, + parentId: activeLevelId, }) - useScene.getState().createNode(terminal, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [terminal.id] }) + const committedTerminal = DuctTerminalNode.parse({ + ...terminal, + ...resolveSupportSlabPatch(terminal, useScene.getState().nodes), + }) + useScene.getState().createNode(committedTerminal, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedTerminal.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/fence/__tests__/lift.test.ts b/packages/nodes/src/fence/__tests__/lift.test.ts new file mode 100644 index 00000000..53d6f935 --- /dev/null +++ b/packages/nodes/src/fence/__tests__/lift.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, FenceNode, SlabNode } from '@pascal-app/core' +import { resolveFenceLiftElevation } from '../lift' + +const LEVEL_ID = 'level-1' + +function makeDeck(elevation: number, parentId: string | null = LEVEL_ID): SlabNode { + return SlabNode.parse({ + parentId, + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + elevation, + thickness: 0.05, + }) +} + +function makeRailing(supportSlabId: string | undefined, parentId: string | null = LEVEL_ID) { + return FenceNode.parse({ + parentId, + start: [0, 0], + end: [4, 0], + supportSlabId, + }) +} + +function resolverFor(...nodes: AnyNode[]) { + const byId = new Map(nodes.map((node) => [node.id as string, node])) + return (id: string) => byId.get(id) +} + +describe('resolveFenceLiftElevation', () => { + test('lifts onto the host slab walking surface', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(1.25) + }) + + test('unhosted fence stays on the level floor', () => { + const railing = makeRailing(undefined) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('stale host (slab gone) falls back to the floor', () => { + const deck = makeDeck(1.25) + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor())).toBe(0) + }) + + test('host on another level does not lift the fence', () => { + const deck = makeDeck(1.25, 'level-2') + const railing = makeRailing(deck.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck))).toBe(0) + }) + + test('host id resolving to a non-slab node is ignored', () => { + const deck = makeDeck(1.25) + const impostor = makeRailing(undefined) + const railing = makeRailing(impostor.id) + expect(resolveFenceLiftElevation(railing, resolverFor(deck, impostor))).toBe(0) + }) +}) diff --git a/packages/nodes/src/fence/actions/move-endpoint.ts b/packages/nodes/src/fence/actions/move-endpoint.ts index d7d8a9f1..12a6bcde 100644 --- a/packages/nodes/src/fence/actions/move-endpoint.ts +++ b/packages/nodes/src/fence/actions/move-endpoint.ts @@ -6,6 +6,7 @@ import { type DragAction, type FenceNode, resolveAlignment, + resolveFenceSupportSlabPatch, useScene, type WallNode, } from '@pascal-app/core' @@ -111,6 +112,26 @@ function snapshotLinked( return out } +/** + * Re-elect the slab lift host for the given fences from their CURRENT + * store state (call after the endpoint writes). Only writes when the host + * actually changes, so unaffected drags stay patch-free. + */ +function applyFenceSupportPatches( + ids: readonly AnyNodeId[], + scene: { update(id: AnyNodeId, data: Partial): void }, +) { + const nodes = useScene.getState().nodes + for (const id of ids) { + const fence = nodes[id] + if (fence?.type !== 'fence') continue + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, nodes) + if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) { + scene.update(id, patch as Partial) + } + } +} + function linkedCascade( linked: LinkedFenceSnapshot[], origin: FencePlanPoint, @@ -230,6 +251,10 @@ export const moveFenceEndpointDragAction: DragAction) + const patched: AnyNodeId[] = [ctx.fenceId] if (!draft.detached) { for (const linked of draft.linkedUpdates) { scene.update( @@ -259,8 +285,14 @@ export const moveFenceEndpointDragAction: DragAction, ) + patched.push(linked.id as AnyNodeId) } } + // The restoreAll above reverted any live host patch — re-run the + // election against the final endpoints so the committed fence stands + // on (or leaves) its deck. Uncapped: an endpoint drag has no commit + // pointer ray worth trusting, matching the wall move commits. + applyFenceSupportPatches(patched, scene) return true }, diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index b283a2a0..f75baeab 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -261,8 +261,13 @@ export const fenceDefinition: NodeDefinition = { // Stage B: pure geometry function. Generic rebuilds // on dirtyNodes; mounts the empty group. - // `renderer` + `system` fields dropped along with their files. geometry: buildFenceGeometry, + // Dependency tracker only — a hosted railing (`supportSlabId`) renders at + // its slab's elevation, so host elevation edits must re-dirty the fence. + system: { + module: () => import('./system'), + priority: 4, + }, // Stage C: floor-plan rendering. FloorplanRegistryLayer iterates kinds // with `floorplan` set and renders via FloorplanGeometryRenderer. // Legacy `floorplanFenceEntries` short-circuits to [] when fence is diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index bc0b33dc..4482dcc4 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -7,6 +7,7 @@ import { getMaxWallCurveOffset, getWallChordFrame, normalizeWallCurveOffset, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallNode, @@ -320,6 +321,21 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance = { data: { start: u.start, end: u.end }, })), ]) + // Re-elect the slab lift host as the endpoint drags (uncapped max + // election — 2D has no camera ray). This legacy write path commits + // via the dispatcher's snapshot diff, so patching per tick both + // previews the lift and lands it in the committed diff. Fences run + // no per-frame election: `supportSlabId` IS the lift. + const patchedNodes = useScene.getState().nodes + const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => { + const fence = patchedNodes[id] + if (fence?.type !== 'fence') return [] + const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes) + return patch.supportSlabId === (fence as FenceNode).supportSlabId + ? [] + : [{ id, data: patch }] + }) + if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches) }, canCommit() { // Pointer-up always runs canCommit — drop the alignment guide here diff --git a/packages/nodes/src/fence/floorplan-move.ts b/packages/nodes/src/fence/floorplan-move.ts index 5a3db015..9dabf89a 100644 --- a/packages/nodes/src/fence/floorplan-move.ts +++ b/packages/nodes/src/fence/floorplan-move.ts @@ -3,6 +3,7 @@ import { type FenceNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -179,6 +180,35 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node // tracked change. Drop the override AFTER the scene write so // mid-commit reads still see the new position (override wins until // cleared; scene wins after). + // The re-elected slab lift host rides in the same write (uncapped max + // election — 2D has no camera ray): a fence moved onto / off an + // elevated deck must land on the right surface, since fences run no + // per-frame election (`supportSlabId` IS the lift). One updateNodes + // keeps the whole move a single tracked change. Election runs on the + // committed endpoints against the pre-write store (the patch only + // reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes + const supportFor = ( + start: PlanPoint, + end: PlanPoint, + path: PlanPoint[] | undefined, + id: AnyNodeId, + ) => { + const fence = baselineNodes[id] + return fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start, + end, + path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + } const fenceUpdate: { id: AnyNodeId; data: Partial } = isNew ? { id: fenceId, @@ -187,9 +217,18 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node end: lastNextEnd, path: lastNextPath, metadata: { ...originalMetadata, isNew: false }, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), } as Partial, } - : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } } + : { + id: fenceId, + data: { + start: lastNextStart, + end: lastNextEnd, + path: lastNextPath, + ...supportFor(lastNextStart, lastNextEnd, lastNextPath, fenceId), + }, + } const linkedUpdates = linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]), @@ -198,7 +237,12 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node fenceUpdate, ...linkedUpdates.map((u) => ({ id: u.id, - data: { start: u.start, end: u.end, path: u.path }, + data: { + start: u.start, + end: u.end, + path: u.path, + ...supportFor(u.start, u.end, u.path, u.id), + }, })), ]) const overrides = useLiveNodeOverrides.getState() diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index e48f6355..b8bebb8b 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -1,4 +1,4 @@ -import { type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' +import { type AnyNodeId, type GeometryContext, getMaterialPresetByRef } from '@pascal-app/core' import { applyMaterialPresetToMaterials, type ColorPreset, @@ -11,6 +11,7 @@ import { resolveSlotDefaultMaterial, } from '@pascal-app/viewer' import { FrontSide, Group, type Material, Mesh, type Texture } from 'three' +import { resolveFenceLiftElevation } from './lift' import type { FenceNode } from './schema' import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots' @@ -110,6 +111,14 @@ export function buildFenceGeometry( const group = new Group() const geometries = generateFenceSlotGeometries(node) + // A hosted railing (`supportSlabId`) stands on its slab's walking surface. + // The builder emits local-space children, so the lift lives on an inner + // group rather than the registered (React-transformed) root. + const lift = ctx ? resolveFenceLiftElevation(node, (id) => ctx.resolve(id as AnyNodeId)) : 0 + const meshParent = new Group() + meshParent.position.y = lift + group.add(meshParent) + for (const slotId of FENCE_SLOT_ORDER) { const geometry = geometries[slotId] if (geometry.getAttribute('position') === undefined) continue @@ -126,7 +135,7 @@ export function buildFenceGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - group.add(mesh) + meshParent.add(mesh) } return group diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts new file mode 100644 index 00000000..01397d32 --- /dev/null +++ b/packages/nodes/src/fence/lift.ts @@ -0,0 +1,25 @@ +import type { AnyNode, SlabNode } from '@pascal-app/core' +import type { FenceNode } from './schema' + +/** + * Elevation (meters above the level plane) a hosted fence stands at. + * + * A fence carrying `supportSlabId` is a railing on that slab's walking + * surface (drawn onto a deck, or placed by a deck preset). The + * host pins the lift only while it still exists as a slab on the fence's + * own level — a stale host (deleted slab, reparented fence) silently + * falls back to the level floor, mirroring the read-path rules of the + * other `supportSlabId` carriers. Pure so it is unit-testable and + * callable from the geometry builder with `ctx.resolve`. + */ +export function resolveFenceLiftElevation( + node: Pick, + resolve: (id: string) => AnyNode | undefined, +): number { + if (!node.supportSlabId) return 0 + const host = resolve(node.supportSlabId) + if (host?.type !== 'slab') return 0 + if ((host.parentId ?? null) !== (node.parentId ?? null)) return 0 + const elevation = (host as SlabNode).elevation + return Number.isFinite(elevation) ? elevation : 0 +} diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index fcc1f279..4be27ece 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -10,6 +10,7 @@ import { isCurvedWall, isSplineFence, type LevelNode, + resolveFenceSupportSlabPatch, useLiveNodeOverrides, useScene, type WallMoveAxis, @@ -224,11 +225,35 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { path?: [number, number][] }>, ) => { + // Fold the re-elected slab lift host into the same write — a fence + // moved onto / off an elevated deck must land on the right surface + // (fences run no per-frame election; `supportSlabId` IS the lift), + // and one updateNodes keeps the whole move a single undo step. + // Election runs on the committed endpoints against the pre-write + // store (the patch only reads the parent level + the slab grid). + const baselineNodes = useScene.getState().nodes useScene.getState().updateNodes( - updates.map((entry) => ({ - id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end, path: entry.path }, - })), + updates.map((entry) => { + const fence = baselineNodes[entry.id as AnyNodeId] + const support = + fence?.type === 'fence' + ? resolveFenceSupportSlabPatch( + { + start: entry.start, + end: entry.end, + path: entry.path, + curveOffset: (fence as FenceNode).curveOffset, + thickness: (fence as FenceNode).thickness, + parentId: fence.parentId, + }, + baselineNodes, + ) + : {} + return { + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end, path: entry.path, ...support }, + } + }), ) for (const entry of updates) { useScene.getState().markDirty(entry.id as AnyNodeId) diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx new file mode 100644 index 00000000..dedfc96e --- /dev/null +++ b/packages/nodes/src/fence/system.tsx @@ -0,0 +1,50 @@ +'use client' + +import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' +import { useEffect } from 'react' +import { resolveFenceLiftElevation } from './lift' +import type { FenceNode } from './schema' + +/** + * Hosted-railing dependency tracker. A fence with `supportSlabId` renders at + * its host slab's elevation, but a store update only dirties the node that + * changed — editing the slab's elevation (or restoring a deleted host) would + * leave the railing floating at the stale height. Watch each hosted fence's + * resolved lift and dirty the fence when it moves; `GeometrySystem` rebuilds + * through `def.geometry` as usual. (Deleting the host needs no help here: + * `deleteNodesAction` strips `supportSlabId` and dirties the fence itself.) + */ + +function fenceLiftSignatures(nodes: Record): Map { + const signatures = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'fence') continue + const fence = node as FenceNode + if (!fence.supportSlabId) continue + signatures.set( + fence.id, + resolveFenceLiftElevation(fence, (id) => nodes[id]), + ) + } + return signatures +} + +const FenceSystems = () => { + useEffect(() => { + let previous = fenceLiftSignatures(useScene.getState().nodes) + + return useScene.subscribe((state) => { + const current = fenceLiftSignatures(state.nodes) + for (const [fenceId, lift] of current.entries()) { + if (previous.get(fenceId) !== lift) { + state.markDirty(fenceId as AnyNodeId) + } + } + previous = current + }) + }, []) + + return null +} + +export default FenceSystems diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index c8f3d9e4..763e787d 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -18,6 +18,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + clearPlacementSurface, createFenceOnCurrentLevel, createSplineFenceOnCurrentLevel, EDITOR_LAYER, @@ -33,6 +34,8 @@ import { isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapFenceDraftPoint, snapScalarToGrid, @@ -45,12 +48,42 @@ import { } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + BoxGeometry, + BufferGeometry, + type Camera, + DoubleSide, + type Group, + type Mesh, + Vector3, +} from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' const FENCE_PREVIEW_HEIGHT = 1.8 const FENCE_PREVIEW_THICKNESS = 0.08 +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() + +// The walking surface the pointer actually aims at (deck top when over the +// deck, floor/ground underneath it) — only for genuine 3D pointer events. +// The 2D floor plan emits synthetic grid events with no camera ray behind +// them; those keep the uncapped max election and leave the grid plane alone. +function pointedSurfaceFor(camera: Camera, event: GridEvent) { + return event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(camera, event.position) + : null +} /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 // HUD label heights are measured from the top of the preview bar, so they @@ -60,20 +93,6 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 - -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: FencePlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} type DraftMeasurementState = { lengthLabel: string @@ -136,6 +155,7 @@ function toMiterWall(segment: SegmentLike): WallNode { visible: true, metadata: {}, children: [], + assemblyLayers: [], start: segment.start, end: segment.end, thickness: segment.thickness, @@ -460,12 +480,18 @@ const StraightFenceTool: React.FC = () => { previewHeightRef.current = previewHeight const previewThicknessRef = useRef(previewThickness) previewThicknessRef.current = previewThickness + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the live event handlers see the current camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera const cursorRef = useRef(null) const previewRef = useRef(null!) const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) const [draftMeasurement, setDraftMeasurement] = useState(null) + const [axisGuide, setAxisGuide] = useState(null) const measurementColor = isDark ? '#ffffff' : '#111111' const measurementShadowColor = isDark ? '#111111' : '#ffffff' @@ -512,6 +538,7 @@ const StraightFenceTool: React.FC = () => { buildingState.current = 0 previewRef.current.visible = false setDraftMeasurement(null) + setAxisGuide(null) const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setFenceDraftStart(null) draftPreview.setFenceDraftEnd(null) @@ -521,6 +548,18 @@ const StraightFenceTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && previewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the lift the committed fence + // will get. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } const { walls, fences } = getCurrentLevelElements() const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] // While drafting, the segment locks to 15° rays from its start. @@ -546,6 +585,16 @@ const StraightFenceTool: React.FC = () => { draftPreview.setFenceDraftStart([startingPoint.current.x, startingPoint.current.z]) draftPreview.setFenceDraftEnd(snappedLocal) cursorRef.current.position.copy(endingPoint.current) + setAxisGuide({ + origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, + y: startingPoint.current.y, + angleLabel: getNearestAxisAngleLabel( + [startingPoint.current.x, startingPoint.current.z], + snappedLocal, + startingPoint.current.y, + ), + }) const currentFenceEnd: FencePlanPoint = [snappedLocal[0], snappedLocal[1]] if ( previousFenceEnd && @@ -583,6 +632,7 @@ const StraightFenceTool: React.FC = () => { ) cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) setDraftMeasurement(null) + setAxisGuide(null) } } @@ -614,6 +664,12 @@ const StraightFenceTool: React.FC = () => { triggerSFX('sfx:structure-build-start') previewRef.current.visible = true setDraftMeasurement(null) + setAxisGuide({ + origin: snappedStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } else { const angleLocked = isAngleSnapActive() const snappedEnd = alignPoint( @@ -630,9 +686,11 @@ const StraightFenceTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(cameraRef.current, event) const createdFence = createFenceOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdFence) return @@ -663,6 +721,12 @@ const StraightFenceTool: React.FC = () => { previewRef.current.visible = false buildingState.current = 1 setDraftMeasurement(null) + setAxisGuide({ + origin: nextStart, + endOrigin: null, + y: event.localPosition[1], + angleLabel: null, + }) } } @@ -681,6 +745,7 @@ const StraightFenceTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useSegmentDraftChain.getState().clear('fence') useAlignmentGuides.getState().clear() const draftPreview = useFloorplanDraftPreview.getState() @@ -691,6 +756,11 @@ const StraightFenceTool: React.FC = () => { return ( + @@ -738,6 +808,16 @@ const SplineFenceDraft: React.FC = () => { : FENCE_PREVIEW_HEIGHT const [draftPoints, setDraftPoints] = useState([]) const [cursor, setCursor] = useState(null) + // Building-local Y of the grid plane (rides the pointed surface — see + // `pointedSurfaceFor`), so the spline preview draws on the deck top when + // the curve is being laid out on one. + const [liftY, setLiftY] = useState(0) + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + // Pointer cap for the commit (Enter / double-click carry no useful grid + // event of their own) — last resolved on move/click. + const supportCapRef = useRef(null) const draftRef = useRef(draftPoints) draftRef.current = draftPoints @@ -761,7 +841,9 @@ const SplineFenceDraft: React.FC = () => { const commit = () => { const points = draftRef.current if (points.length >= 2) { - const created = createSplineFenceOnCurrentLevel(points) + const created = createSplineFenceOnCurrentLevel(points, undefined, { + supportCap: supportCapRef.current, + }) if (created) { triggerSFX('sfx:item-place') // Once the new curve fence is selected for direct editing, leave @@ -775,11 +857,24 @@ const SplineFenceDraft: React.FC = () => { setCursor(null) } + const trackPointedSurface = (event: GridEvent) => { + const pointed = pointedSurfaceFor(cameraRef.current, event) + if (!pointed) return + supportCapRef.current = pointed.elevation + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + setLiftY(event.localPosition[1]) + } + const onMove = (event: GridEvent) => { + trackPointedSurface(event) setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) } const onClick = (event: GridEvent) => { + trackPointedSurface(event) if (event.nativeEvent.detail >= 2) { commit() return @@ -807,6 +902,7 @@ const SplineFenceDraft: React.FC = () => { emitter.off('grid:move', onMove) emitter.off('grid:click', onClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() window.removeEventListener('keydown', onKeyDown) } }, []) @@ -820,18 +916,18 @@ const SplineFenceDraft: React.FC = () => { SPLINE_PREVIEW_SEGMENTS, ) return new BufferGeometry().setFromPoints( - sampled.map((point) => new Vector3(point.x, previewHeight, point.y)), + sampled.map((point) => new Vector3(point.x, liftY + previewHeight, point.y)), ) - }, [previewHeight, previewPoints]) + }, [liftY, previewHeight, previewPoints]) return ( - {cursor && } + {cursor && } {draftPoints.map((point, index) => ( @@ -854,71 +950,4 @@ const SplineFenceDraft: React.FC = () => { ) } -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default FenceTool diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 4aa874db..678c09a4 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -1,6 +1,12 @@ 'use client' -import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' +import { + emitter, + type GridEvent, + HvacEquipmentNode, + resolveSupportSlabPatch, + useScene, +} from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -76,9 +82,14 @@ const HvacEquipmentTool = () => { name: 'Furnace', position, rotation: yawRef.current, + parentId: activeLevelId, }) - useScene.getState().createNode(unit, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [unit.id] }) + const committedUnit = HvacEquipmentNode.parse({ + ...unit, + ...resolveSupportSlabPatch(unit, useScene.getState().nodes), + }) + useScene.getState().createNode(committedUnit, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedUnit.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index d7cd8eef..69a1aa1b 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -5,10 +5,12 @@ import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' +import { constructionDimensionDefinition } from './construction-dimension' import { cupolaDefinition } from './cupola' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' import { downspoutDefinition } from './downspout' +import { drawingSheetDefinition } from './drawing-sheet' import { ductFittingDefinition } from './duct-fitting' import { ductSegmentDefinition } from './duct-segment' import { ductTerminalDefinition } from './duct-terminal' @@ -38,6 +40,7 @@ import { solarPanelDefinition } from './solar-panel' import { spawnDefinition } from './spawn' import { stairDefinition } from './stair' import { stairSegmentDefinition } from './stair-segment' +import { structuralGridDefinition } from './structural-grid' import { turbineVentDefinition } from './turbine-vent' import { wallDefinition } from './wall' import { windowDefinition } from './window' @@ -90,6 +93,9 @@ export const builtinPlugin: Plugin = { guideDefinition as unknown as AnyNodeDefinition, scanDefinition as unknown as AnyNodeDefinition, measurementDefinition as unknown as AnyNodeDefinition, + constructionDimensionDefinition as unknown as AnyNodeDefinition, + drawingSheetDefinition as unknown as AnyNodeDefinition, + structuralGridDefinition as unknown as AnyNodeDefinition, // Roof-mounted accessories (custom renderer + bespoke roof-event tool). boxVentDefinition as unknown as AnyNodeDefinition, ridgeVentDefinition as unknown as AnyNodeDefinition, @@ -130,10 +136,12 @@ export { export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' +export { constructionDimensionDefinition } from './construction-dimension' export { cupolaDefinition } from './cupola' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' export { downspoutDefinition } from './downspout' +export { drawingSheetDefinition } from './drawing-sheet' export { ductFittingDefinition } from './duct-fitting' export { ductSegmentDefinition } from './duct-segment' export { ductTerminalDefinition } from './duct-terminal' @@ -155,6 +163,35 @@ export { ridgeVentDefinition } from './ridge-vent' export { roofDefinition } from './roof' export { roofSegmentDefinition } from './roof-segment' export { scanDefinition } from './scan' +export { + type BuildClearanceAdvisoriesOptions, + buildClearanceAdvisories, + type ClearanceAdvisory, + type ClearanceAdvisoryCategory, + type ClearanceAdvisorySeverity, + type ClearanceEvidence, + type ClearanceProfile, + type ClearanceRule, + type ClearanceRuleSource, + DEFAULT_CLEARANCE_PROFILES, +} from './shared/clearance-advisories' +export { + type BuildConstructionModuleAdvisoriesOptions, + buildConstructionModuleAdvisories, + type ConstructionModuleAdvisory, + type ConstructionModuleAdvisorySeverity, + type ConstructionModuleMeasurementKind, + type ConstructionModuleProfile, + type ConstructionModuleSystem, + DEFAULT_CONSTRUCTION_MODULE_PROFILES, +} from './shared/construction-module-advisories' +export { + type BuildDimensionCompletenessAuditOptions, + buildDimensionCompletenessAudit, + type DimensionCompletenessIssue, + type DimensionCompletenessIssueKind, + type DimensionCompletenessIssueSeverity, +} from './shared/dimension-completeness-audit' export { shelfDefinition } from './shelf' export { siteDefinition } from './site' export { skylightDefinition } from './skylight' @@ -163,6 +200,7 @@ export { solarPanelDefinition } from './solar-panel' export { spawnDefinition } from './spawn' export { stairDefinition } from './stair' export { stairSegmentDefinition } from './stair-segment' +export { structuralGridDefinition } from './structural-grid' export { turbineVentDefinition } from './turbine-vent' export { wallDefinition } from './wall' export { windowDefinition } from './window' diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx index 53b65455..b3e71846 100644 --- a/packages/nodes/src/item/renderer.tsx +++ b/packages/nodes/src/item/renderer.tsx @@ -29,6 +29,7 @@ import { type RenderShading, resolveCdnUrl, resolveMaterialRef, + stampPascalTextureRef, useItemLightPool, useNodeEvents, useViewer, @@ -38,7 +39,7 @@ import { Clone } from '@react-three/drei/core/Clone' import { useFrame, useLoader, useThree } from '@react-three/fiber' import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three' -import { MathUtils } from 'three' +import { MathUtils, Texture } from 'three' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js' import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' @@ -225,11 +226,85 @@ type LoadedItemGltf = GLTF & { nodes: Record } +const ITEM_TEXTURE_SLOTS = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'emissiveMap', + 'aoMap', + 'alphaMap', + 'lightMap', + 'bumpMap', + 'displacementMap', + 'clearcoatMap', + 'clearcoatNormalMap', + 'clearcoatRoughnessMap', + 'iridescenceMap', + 'iridescenceThicknessMap', + 'transmissionMap', + 'thicknessMap', + 'specularIntensityMap', + 'specularColorMap', + 'sheenRoughnessMap', + 'sheenColorMap', + 'anisotropyMap', +] as const + +function getItemTextureImageIndex(gltf: LoadedItemGltf, texture: Texture): number | null { + const association = gltf.parser?.associations.get(texture) + const textureIndex = association?.textures + if (!Number.isInteger(textureIndex)) return null + + const textureDef = gltf.parser.json.textures?.[textureIndex as number] + const imageIndex = + textureDef?.extensions?.KHR_texture_basisu?.source ?? + textureDef?.extensions?.EXT_texture_webp?.source ?? + textureDef?.extensions?.EXT_texture_avif?.source ?? + textureDef?.source + return Number.isInteger(imageIndex) && imageIndex >= 0 ? imageIndex : null +} + +function stampItemTextureReferences(gltf: LoadedItemGltf, src: string) { + if (!gltf.parser?.associations) return + + const stamped = new Set() + gltf.scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh) return + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) { + const textureMaterial = material as Material & Record + for (const slot of ITEM_TEXTURE_SLOTS) { + const texture = textureMaterial[slot] + if (!(texture instanceof Texture)) continue + if (stamped.has(texture)) continue + const imageIndex = getItemTextureImageIndex(gltf, texture) + if (imageIndex === null) continue + if ( + stampPascalTextureRef(texture, { + kind: 'item-glb', + src, + slot, + imageIndex, + }) + ) { + stamped.add(texture) + } + } + } + }) +} + const useItemGltf = (url: string): LoadedItemGltf => { const renderer = useThree((state) => state.gl) - return useLoader(ItemGLTFLoader, url, (loader) => + const gltf = useLoader(ItemGLTFLoader, url, (loader) => configureItemModelLoader(loader, renderer), ) as LoadedItemGltf + return useMemo(() => { + stampItemTextureReferences(gltf, url) + return gltf + }, [gltf, url]) } type DeferredUnavailableCleanup = { diff --git a/packages/nodes/src/level/definition.ts b/packages/nodes/src/level/definition.ts index 3dc516c3..c3e6fcea 100644 --- a/packages/nodes/src/level/definition.ts +++ b/packages/nodes/src/level/definition.ts @@ -1,4 +1,8 @@ -import { LevelNode as LevelNodeSchema, type NodeDefinition } from '@pascal-app/core' +import { + DEFAULT_LEVEL_HEIGHT, + LevelNode as LevelNodeSchema, + type NodeDefinition, +} from '@pascal-app/core' import { levelParametrics } from './parametrics' import { LevelNode } from './schema' @@ -14,7 +18,11 @@ export const levelDefinition: NodeDefinition = { category: 'site', defaults: () => { - const stub = LevelNodeSchema.parse({ id: 'level_default' as never, type: 'level' }) + const stub = LevelNodeSchema.parse({ + id: 'level_default' as never, + type: 'level', + height: DEFAULT_LEVEL_HEIGHT, + }) const { id: _id, type: _type, ...rest } = stub return rest }, diff --git a/packages/nodes/src/measurement/floorplan.test.ts b/packages/nodes/src/measurement/floorplan.test.ts index c8714d71..7e0531fa 100644 --- a/packages/nodes/src/measurement/floorplan.test.ts +++ b/packages/nodes/src/measurement/floorplan.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core' -import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor' +import { + createFloorplanContextExtensions, + MEASUREMENT_ACTIVE_COLOR, + MEASUREMENT_FLOORPLAN_COLOR, +} from '@pascal-app/editor' import { buildMeasurementFloorplan } from './floorplan' const palette = { @@ -21,7 +25,11 @@ const palette = { measurementLabelText: '#0f172a', } -const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({ +const context = ( + unit: 'metric' | 'imperial', + selected = false, + metricNotation: 'meters' | 'millimeters' = 'meters', +): GeometryContext => ({ resolve: () => undefined, children: [], siblings: [], @@ -34,6 +42,7 @@ const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext moving: false, palette, }, + extensions: createFloorplanContextExtensions({ metricNotation }), }) const labels = (geometry: FloorplanGeometry): string[] => { @@ -69,6 +78,23 @@ describe('buildMeasurementFloorplan', () => { ).toMatchObject({ appearance: 'outlined' }) }) + test('formats metric distance labels in millimeters', () => { + const node = MeasurementNode.parse({ + id: 'measurement_distance_mm', + type: 'measurement', + measurement: { + kind: 'distance', + points: [ + [0, 0, 0], + [3.048, 0, 0], + ], + }, + }) + + const metric = buildMeasurementFloorplan(node, context('metric', false, 'millimeters')) + expect(metric && labels(metric)).toEqual(['3048mm']) + }) + test('uses indigo analysis colors in plan view', () => { const node = MeasurementNode.parse({ id: 'measurement_appearance', diff --git a/packages/nodes/src/measurement/floorplan.ts b/packages/nodes/src/measurement/floorplan.ts index b0e59c57..a3383c7d 100644 --- a/packages/nodes/src/measurement/floorplan.ts +++ b/packages/nodes/src/measurement/floorplan.ts @@ -19,6 +19,8 @@ import { formatVolumeLabel, measurementFloorplanPresentationColor, measurementPolygonLabelAnchor, + readFloorplanContext, + withFloorplanGeometryMetadata, } from '@pascal-app/editor' import { measurementResolvedEditPoints } from './edit' import { resolveMeasurementNode } from './resolve' @@ -46,6 +48,7 @@ export function buildMeasurementFloorplan( if (node.visible === false) return null const unit = ctx.viewState?.unit ?? 'metric' + const metricNotation = readFloorplanContext(ctx).metricNotation const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id)) const measurement = resolved.payload const selected = ctx.viewState?.selected || ctx.viewState?.highlighted @@ -85,77 +88,83 @@ export function buildMeasurementFloorplan( ] : [] - return { - kind: 'group', - children: [ - { kind: 'line', x1, y1, x2, y2, ...style }, - { kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 }, - ...collapsedHitTarget, - { - kind: 'circle', - cx: x1, - cy: y1, - r: 0.045, - fill: stroke, - pointerEvents: 'none', - }, - { - kind: 'circle', - cx: x2, - cy: y2, - r: 0.045, - fill: stroke, - pointerEvents: 'none', - }, - { - kind: 'dimension-label', - appearance: 'outlined', - cx: (x1 + x2) / 2, - cy: (y1 + y2) / 2, - text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`, - angle: Math.atan2(y2 - y1, x2 - x1), - offsetPx: 14, - }, - ...editHandles, - ], - } + return withFloorplanGeometryMetadata( + { + kind: 'group', + children: [ + { kind: 'line', x1, y1, x2, y2, ...style }, + { kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 }, + ...collapsedHitTarget, + { + kind: 'circle', + cx: x1, + cy: y1, + r: 0.045, + fill: stroke, + pointerEvents: 'none', + }, + { + kind: 'circle', + cx: x2, + cy: y2, + r: 0.045, + fill: stroke, + pointerEvents: 'none', + }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: (x1 + x2) / 2, + cy: (y1 + y2) / 2, + text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit, metricNotation)}`, + angle: Math.atan2(y2 - y1, x2 - x1), + offsetPx: 14, + }, + ...editHandles, + ], + }, + { annotationRole: 'measurement' }, + ) } if (measurement.kind === 'angle') { const [start, vertex, end] = measurement.points const angleArc = buildMeasurementAngleArcPoints(start, vertex, end) const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex - return { - kind: 'group', - children: [ - { - kind: 'polyline', - points: [projectPoint(start), projectPoint(vertex), projectPoint(end)], - ...style, - }, - ...(angleArc.length >= 2 - ? [ - { - kind: 'polyline' as const, - points: angleArc.map(projectPoint), - ...style, - strokeWidth: 3, - }, - ] - : []), - { - kind: 'dimension-label', - appearance: 'outlined', - cx: labelPoint[0], - cy: labelPoint[2], - text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`, - angle: 0, - offsetPx: 10, - screenUpright: true, - }, - ...editHandles, - ], - } + return withFloorplanGeometryMetadata( + { + kind: 'group', + children: [ + { + kind: 'polyline', + points: [projectPoint(start), projectPoint(vertex), projectPoint(end)], + ...style, + }, + ...(angleArc.length >= 2 + ? [ + { + kind: 'polyline' as const, + points: angleArc.map(projectPoint), + ...style, + strokeWidth: 3, + }, + ] + : []), + { + kind: 'dimension-label', + appearance: 'outlined', + cx: labelPoint[0], + cy: labelPoint[2], + text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`, + angle: 0, + offsetPx: 10, + screenUpright: true, + }, + ...editHandles, + ], + }, + { annotationRole: 'measurement' }, + ) } if (measurement.kind === 'area' || measurement.kind === 'perimeter') { @@ -163,31 +172,34 @@ export function buildMeasurementFloorplan( const label = measurement.kind === 'area' ? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}` - : `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}` + : `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit, metricNotation)}` - return { - kind: 'group', - children: [ - { - kind: 'polygon', - points: measurement.base.map(projectPoint), - fill: stroke, - fillOpacity: measurement.kind === 'area' ? 0.08 : 0, - pointerEvents: 'all', - ...style, - }, - { - kind: 'dimension-label', - appearance: 'outlined', - cx: centroid[0], - cy: centroid[2], - text: `${statusPrefix}${label}`, - angle: 0, - screenUpright: true, - }, - ...editHandles, - ], - } + return withFloorplanGeometryMetadata( + { + kind: 'group', + children: [ + { + kind: 'polygon', + points: measurement.base.map(projectPoint), + fill: stroke, + fillOpacity: measurement.kind === 'area' ? 0.08 : 0, + pointerEvents: 'all', + ...style, + }, + { + kind: 'dimension-label', + appearance: 'outlined', + cx: centroid[0], + cy: centroid[2], + text: `${statusPrefix}${label}`, + angle: 0, + screenUpright: true, + }, + ...editHandles, + ], + }, + { annotationRole: 'measurement' }, + ) } const volume = measurement @@ -232,5 +244,8 @@ export function buildMeasurementFloorplan( }) children.push(...editHandles) - return { kind: 'group', children } + return withFloorplanGeometryMetadata( + { kind: 'group', children }, + { annotationRole: 'measurement' }, + ) } diff --git a/packages/nodes/src/measurement/index.ts b/packages/nodes/src/measurement/index.ts index 16cd06ac..a6e9224e 100644 --- a/packages/nodes/src/measurement/index.ts +++ b/packages/nodes/src/measurement/index.ts @@ -11,5 +11,6 @@ export { type ResolvedMeasurement, type ResolvedMeasurementPayload, remapMeasurementReferences, + resolveMeasurementAnchor, resolveMeasurementNode, } from './resolve' diff --git a/packages/nodes/src/measurement/resolve.ts b/packages/nodes/src/measurement/resolve.ts index 91cc6f66..59d03cea 100644 --- a/packages/nodes/src/measurement/resolve.ts +++ b/packages/nodes/src/measurement/resolve.ts @@ -170,7 +170,7 @@ export function measurementFeaturePoint( } } -function resolveAnchor( +export function resolveMeasurementAnchor( anchor: MeasurementAnchor, resolve: NodeResolver, ): { @@ -227,7 +227,7 @@ export function resolveMeasurementNode( const dangling: MeasurementFeatureReference[] = [] const anchorNormals: Array = [] const point = (anchor: MeasurementAnchor) => { - const result = resolveAnchor(anchor, resolve) + const result = resolveMeasurementAnchor(anchor, resolve) if (result.dangling) dangling.push(result.dangling) anchorNormals.push(result.normal) return result.point diff --git a/packages/nodes/src/shared/clearance-advisories.test.ts b/packages/nodes/src/shared/clearance-advisories.test.ts new file mode 100644 index 00000000..bc991d53 --- /dev/null +++ b/packages/nodes/src/shared/clearance-advisories.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + CabinetNode, + DoorNode, + ItemNode, + StairNode, + StairSegmentNode, + ZoneNode, +} from '@pascal-app/core' +import { + buildClearanceAdvisories, + type ClearanceProfile, + DEFAULT_CLEARANCE_PROFILES, +} from './clearance-advisories' + +const adaProfile: ClearanceProfile = { + ...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'us-ada-2010-advisory')!, + enabled: true, +} + +const officeProfile: ClearanceProfile = { + ...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'office-residential-advisory')!, + enabled: true, +} + +function nodes(...items: AnyNode[]): Record { + return Object.fromEntries(items.map((item) => [item.id, item])) as Record +} + +describe('clearance advisories', () => { + test('keeps default clearance profiles optional and quiet', () => { + const narrowHall = ZoneNode.parse({ + id: 'zone_hall', + name: 'Hallway', + polygon: [ + [0, 0], + [0.8, 0], + [0.8, 4], + [0, 4], + ], + }) + + expect(buildClearanceAdvisories(nodes(narrowHall))).toEqual([]) + }) + + test('checks circulation, entry, and door clear widths with ADA provenance', () => { + const hall = ZoneNode.parse({ + id: 'zone_hall', + name: 'North Corridor', + polygon: [ + [0, 0], + [0.8, 0], + [0.8, 5], + [0, 5], + ], + }) + const entry = ZoneNode.parse({ + id: 'zone_entry', + name: 'Entry vestibule', + polygon: [ + [0, 0], + [0.86, 0], + [0.86, 2], + [0, 2], + ], + }) + const door = DoorNode.parse({ + id: 'door_narrow', + width: 0.78, + }) + + const advisories = buildClearanceAdvisories(nodes(hall, entry, door), { + profiles: [adaProfile], + }) + + expect(advisories.map((advisory) => advisory.ruleId)).toEqual([ + 'ada-door-clear-opening', + 'ada-entry-clear-width', + 'ada-accessible-route-clear-width', + ]) + expect(advisories.every((advisory) => advisory.source.edition === '2010')).toBe(true) + expect(advisories.every((advisory) => advisory.severity === 'warning')).toBe(true) + }) + + test('reports missing fixture, cabinet, and appliance clearance evidence', () => { + const toilet = ItemNode.parse({ + id: 'item_toilet', + asset: { + id: 'asset_toilet', + category: 'plumbing', + name: 'Accessible Toilet', + thumbnail: '', + src: 'asset://toilet.glb', + tags: ['fixture'], + }, + }) + const sinkCabinet = CabinetNode.parse({ + id: 'cabinet_sink', + stack: [{ id: 'sink', type: 'sink' }], + }) + const applianceCabinet = CabinetNode.parse({ + id: 'cabinet_dishwasher', + stack: [{ id: 'dishwasher', type: 'dishwasher' }], + }) + + const advisories = buildClearanceAdvisories(nodes(toilet, sinkCabinet, applianceCabinet), { + profiles: [adaProfile, officeProfile], + }) + + expect(advisories.map((advisory) => advisory.id)).toEqual([ + 'clearance:office-residential-advisory:cabinet_dishwasher:office-appliance-front-clearance', + 'clearance:office-residential-advisory:cabinet_dishwasher:office-cabinet-front-clearance', + 'clearance:office-residential-advisory:cabinet_sink:office-cabinet-front-clearance', + 'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-depth', + 'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-width', + 'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-depth', + 'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-width', + ]) + expect(advisories.every((advisory) => advisory.measured === null)).toBe(true) + expect(advisories.every((advisory) => advisory.severity === 'info')).toBe(true) + }) + + test('accepts explicit clearance evidence for surrounding cabinet and fixture checks', () => { + const toilet = ItemNode.parse({ + id: 'item_toilet', + asset: { + id: 'asset_toilet', + category: 'plumbing', + name: 'Accessible Toilet', + thumbnail: '', + src: 'asset://toilet.glb', + tags: ['fixture'], + }, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_base', + }) + + const advisories = buildClearanceAdvisories(nodes(toilet, cabinet), { + profiles: [adaProfile, officeProfile], + evidence: { + item_toilet: { + 'ada-fixture-clear-floor-width': 0.9, + 'ada-fixture-clear-floor-depth': 1.0, + }, + cabinet_base: { + 'office-cabinet-front-clearance': 1.0, + }, + }, + }) + + expect(advisories.map((advisory) => advisory.ruleId)).toEqual(['ada-fixture-clear-floor-depth']) + expect(advisories[0]?.measured).toBe(1) + expect(advisories[0]?.severity).toBe('warning') + }) + + test('checks closet depth and stair geometry from modeled dimensions', () => { + const closet = ZoneNode.parse({ + id: 'zone_closet', + name: 'Bedroom Closet', + polygon: [ + [0, 0], + [0.55, 0], + [0.55, 2], + [0, 2], + ], + }) + const stair = StairNode.parse({ + id: 'stair_tall_riser', + width: 0.82, + totalRise: 2.8, + stepCount: 12, + }) + const segment = StairSegmentNode.parse({ + id: 'sseg_shallow_treads', + width: 1, + length: 2.2, + height: 2, + stepCount: 10, + }) + + const advisories = buildClearanceAdvisories(nodes(closet, stair, segment), { + profiles: [officeProfile], + }) + + expect(advisories.map((advisory) => advisory.ruleId)).toEqual([ + 'office-stair-tread-depth', + 'office-stair-riser-height', + 'office-stair-tread-depth', + 'office-stair-width', + 'office-closet-depth', + ]) + expect(advisories.every((advisory) => advisory.source.title.includes('Pascal'))).toBe(true) + }) + + test('can include disabled profiles for profile preview UIs', () => { + const door = DoorNode.parse({ + id: 'door_preview', + width: 0.78, + }) + + const advisories = buildClearanceAdvisories(nodes(door), { + includeDisabled: true, + }) + + expect(advisories.map((advisory) => advisory.profileId)).toEqual(['us-ada-2010-advisory']) + }) +}) diff --git a/packages/nodes/src/shared/clearance-advisories.ts b/packages/nodes/src/shared/clearance-advisories.ts new file mode 100644 index 00000000..286b9a72 --- /dev/null +++ b/packages/nodes/src/shared/clearance-advisories.ts @@ -0,0 +1,513 @@ +import { + type AnyNode, + type CabinetModuleNode, + type CabinetNode, + type DoorNode, + type ItemNode, + resolveStairTotalRise, + type StairNode, + type StairSegmentNode, + type ZoneNode, +} from '@pascal-app/core' +import { formatConstructionLength } from './construction-length' + +export type ClearanceAdvisoryCategory = + | 'circulation' + | 'entry' + | 'door-approach' + | 'fixture' + | 'cabinet' + | 'appliance' + | 'closet' + | 'stair' + +export type ClearanceAdvisorySeverity = 'info' | 'warning' + +export type ClearanceRuleSource = { + title: string + edition: string + section: string + url?: string + note?: string +} + +export type ClearanceRule = { + id: string + category: ClearanceAdvisoryCategory + label: string + measurement: + | 'clear-width' + | 'clear-depth' + | 'clear-floor-width' + | 'clear-floor-depth' + | 'front-clearance' + | 'stair-width' + | 'tread-depth' + | 'riser-height' + minValue: number + source: ClearanceRuleSource +} + +export type ClearanceProfile = { + id: string + label: string + jurisdiction?: string + enabled: boolean + rules: readonly ClearanceRule[] +} + +export type ClearanceEvidence = Readonly< + Record>> +> + +export type BuildClearanceAdvisoriesOptions = { + profiles?: readonly ClearanceProfile[] + includeDisabled?: boolean + evidence?: ClearanceEvidence +} + +export type ClearanceAdvisory = { + id: string + nodeId: string + nodeType: string + profileId: string + profileLabel: string + category: ClearanceAdvisoryCategory + ruleId: string + label: string + measured: number | null + required: number + severity: ClearanceAdvisorySeverity + source: ClearanceRuleSource + message: string +} + +type ClearanceTarget = { + nodeId: string + nodeType: string + category: ClearanceAdvisoryCategory + measurements: Partial> +} + +const ADA_2010: Pick = { + title: '2010 ADA Standards for Accessible Design', + edition: '2010', + url: 'https://www.access-board.gov/ada/', +} + +const OFFICE_STANDARD: Pick = { + title: 'Pascal construction-document advisory profile', + edition: '2026-07-21', +} + +export const DEFAULT_CLEARANCE_PROFILES: readonly ClearanceProfile[] = [ + { + id: 'us-ada-2010-advisory', + label: 'U.S. ADA 2010 advisory checks', + jurisdiction: 'US', + enabled: false, + rules: [ + { + id: 'ada-accessible-route-clear-width', + category: 'circulation', + label: 'accessible route clear width', + measurement: 'clear-width', + minValue: 36 * 0.0254, + source: { + ...ADA_2010, + section: '403.5.1', + note: 'Accessible routes generally require 36 inches minimum clear width.', + }, + }, + { + id: 'ada-entry-clear-width', + category: 'entry', + label: 'entry clear width', + measurement: 'clear-width', + minValue: 36 * 0.0254, + source: { + ...ADA_2010, + section: '403.5.1', + note: 'Entries serving an accessible route are checked against the route clear width.', + }, + }, + { + id: 'ada-door-clear-opening', + category: 'door-approach', + label: 'door clear opening', + measurement: 'clear-width', + minValue: 32 * 0.0254, + source: { + ...ADA_2010, + section: '404.2.3', + note: 'Door openings on accessible routes require 32 inches minimum clear width.', + }, + }, + { + id: 'ada-fixture-clear-floor-width', + category: 'fixture', + label: 'fixture clear floor space width', + measurement: 'clear-floor-width', + minValue: 30 * 0.0254, + source: { + ...ADA_2010, + section: '305.3', + note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.', + }, + }, + { + id: 'ada-fixture-clear-floor-depth', + category: 'fixture', + label: 'fixture clear floor space depth', + measurement: 'clear-floor-depth', + minValue: 48 * 0.0254, + source: { + ...ADA_2010, + section: '305.3', + note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.', + }, + }, + ], + }, + { + id: 'office-residential-advisory', + label: 'Office residential advisory checks', + enabled: false, + rules: [ + { + id: 'office-cabinet-front-clearance', + category: 'cabinet', + label: 'cabinet front working clearance', + measurement: 'front-clearance', + minValue: 0.9, + source: { + ...OFFICE_STANDARD, + section: 'Kitchen working clearances', + note: 'Office drafting convention for cabinet and drawer operation clearance.', + }, + }, + { + id: 'office-appliance-front-clearance', + category: 'appliance', + label: 'appliance front working clearance', + measurement: 'front-clearance', + minValue: 0.9, + source: { + ...OFFICE_STANDARD, + section: 'Kitchen appliance clearances', + note: 'Office drafting convention for appliance door and working clearance.', + }, + }, + { + id: 'office-closet-depth', + category: 'closet', + label: 'closet clear depth', + measurement: 'clear-depth', + minValue: 0.6, + source: { + ...OFFICE_STANDARD, + section: 'Storage clearances', + note: 'Office drafting convention for reach-in closet depth.', + }, + }, + { + id: 'office-stair-width', + category: 'stair', + label: 'stair clear width', + measurement: 'stair-width', + minValue: 0.9, + source: { + ...OFFICE_STANDARD, + section: 'Residential stair geometry', + note: 'Office drafting convention; verify against local stair code before permit use.', + }, + }, + { + id: 'office-stair-tread-depth', + category: 'stair', + label: 'stair tread depth', + measurement: 'tread-depth', + minValue: 0.25, + source: { + ...OFFICE_STANDARD, + section: 'Residential stair geometry', + note: 'Office drafting convention; verify against local stair code before permit use.', + }, + }, + { + id: 'office-stair-riser-height', + category: 'stair', + label: 'stair riser height', + measurement: 'riser-height', + minValue: -0.2, + source: { + ...OFFICE_STANDARD, + section: 'Residential stair geometry', + note: 'Negative minValue means measured riser height must be less than or equal to the absolute value.', + }, + }, + ], + }, +] as const + +export function buildClearanceAdvisories( + nodes: Readonly>, + options: BuildClearanceAdvisoriesOptions = {}, +): ClearanceAdvisory[] { + const profiles = (options.profiles ?? DEFAULT_CLEARANCE_PROFILES).filter( + (profile) => options.includeDisabled === true || profile.enabled, + ) + if (profiles.length === 0) return [] + + const targets = Object.values(nodes).flatMap((node) => clearanceTargets(node, nodes)) + const advisories: ClearanceAdvisory[] = [] + + for (const target of targets) { + for (const profile of profiles) { + for (const rule of profile.rules) { + if (rule.category !== target.category) continue + const measured = + target.measurements[rule.measurement] ?? options.evidence?.[target.nodeId]?.[rule.id] + if (measured === undefined) { + advisories.push(clearanceAdvisory({ target, profile, rule, measured: null })) + continue + } + if (violatesClearanceRule(measured, rule)) { + advisories.push(clearanceAdvisory({ target, profile, rule, measured })) + } + } + } + } + + return advisories.sort((left, right) => left.id.localeCompare(right.id)) +} + +function clearanceTargets( + node: AnyNode, + nodes: Readonly>, +): ClearanceTarget[] { + if (node.type === 'zone') return zoneTargets(node) + if (node.type === 'door') return doorTargets(node) + if (node.type === 'item') return itemTargets(node) + if (node.type === 'cabinet' || node.type === 'cabinet-module') return cabinetTargets(node) + if (node.type === 'stair') return stairTargets(node, nodes) + if (node.type === 'stair-segment') return stairSegmentTargets(node) + return [] +} + +function zoneTargets(zone: ZoneNode): ClearanceTarget[] { + const role = normalizedText([zone.name, zone.occupancy, String(zone.metadata ?? '')]) + const dimensions = zoneClearDimensions(zone) + const targets: ClearanceTarget[] = [] + + if (containsAny(role, ['hall', 'hallway', 'corridor', 'passage', 'circulation'])) { + targets.push({ + nodeId: zone.id, + nodeType: zone.type, + category: 'circulation', + measurements: { 'clear-width': dimensions.minSpan }, + }) + } + + if (containsAny(role, ['entry', 'entrance', 'vestibule', 'foyer'])) { + targets.push({ + nodeId: zone.id, + nodeType: zone.type, + category: 'entry', + measurements: { 'clear-width': dimensions.minSpan }, + }) + } + + if (containsAny(role, ['closet', 'wardrobe'])) { + targets.push({ + nodeId: zone.id, + nodeType: zone.type, + category: 'closet', + measurements: { 'clear-depth': dimensions.minSpan }, + }) + } + + return targets +} + +function doorTargets(door: DoorNode): ClearanceTarget[] { + return [ + { + nodeId: door.id, + nodeType: door.type, + category: 'door-approach', + measurements: { 'clear-width': door.width }, + }, + ] +} + +function itemTargets(item: ItemNode): ClearanceTarget[] { + const text = normalizedText([ + item.asset.name, + item.asset.category, + ...(item.asset.tags ?? []), + ...(item.asset.functionTags ?? []), + ]) + const targets: ClearanceTarget[] = [] + + if (containsAny(text, ['toilet', 'lavatory', 'sink', 'fixture', 'tub', 'shower', 'wc'])) { + targets.push({ + nodeId: item.id, + nodeType: item.type, + category: 'fixture', + measurements: {}, + }) + } + + if (containsAny(text, ['appliance', 'fridge', 'refrigerator', 'oven', 'range', 'dishwasher'])) { + targets.push({ + nodeId: item.id, + nodeType: item.type, + category: 'appliance', + measurements: {}, + }) + } + + return targets +} + +function cabinetTargets(cabinet: CabinetNode | CabinetModuleNode): ClearanceTarget[] { + const targets: ClearanceTarget[] = [ + { + nodeId: cabinet.id, + nodeType: cabinet.type, + category: 'cabinet', + measurements: {}, + }, + ] + + if ((cabinet.stack ?? []).some((compartment) => isApplianceCompartment(compartment.type))) { + targets.push({ + nodeId: cabinet.id, + nodeType: cabinet.type, + category: 'appliance', + measurements: {}, + }) + } + + if ((cabinet.stack ?? []).some((compartment) => compartment.type === 'sink')) { + targets.push({ + nodeId: cabinet.id, + nodeType: cabinet.type, + category: 'fixture', + measurements: {}, + }) + } + + return targets +} + +function stairTargets( + stair: StairNode, + nodes: Readonly>, +): ClearanceTarget[] { + const measurements: ClearanceTarget['measurements'] = { 'stair-width': stair.width } + const totalRise = resolveStairTotalRise(stair, nodes as Record) + if (stair.stepCount > 0 && totalRise > 0) { + measurements['riser-height'] = totalRise / stair.stepCount + } + + return [ + { + nodeId: stair.id, + nodeType: stair.type, + category: 'stair', + measurements, + }, + ] +} + +function stairSegmentTargets(segment: StairSegmentNode): ClearanceTarget[] { + const measurements: ClearanceTarget['measurements'] = { 'stair-width': segment.width } + if (segment.segmentType === 'stair' && segment.stepCount > 0) { + measurements['tread-depth'] = segment.length / segment.stepCount + if (segment.height > 0) measurements['riser-height'] = segment.height / segment.stepCount + } + + return [ + { + nodeId: segment.id, + nodeType: segment.type, + category: 'stair', + measurements, + }, + ] +} + +function clearanceAdvisory(args: { + target: ClearanceTarget + profile: ClearanceProfile + rule: ClearanceRule + measured: number | null +}): ClearanceAdvisory { + const { target, profile, rule, measured } = args + const measuredLabel = + measured === null ? 'not verified' : formatConstructionLength(measured, 'metric') + const requiredLabel = formatConstructionLength(Math.abs(rule.minValue), 'metric') + const comparator = rule.minValue < 0 ? 'at most' : 'at least' + + return { + id: ['clearance', profile.id, target.nodeId, rule.id].join(':'), + nodeId: target.nodeId, + nodeType: target.nodeType, + profileId: profile.id, + profileLabel: profile.label, + category: rule.category, + ruleId: rule.id, + label: rule.label, + measured, + required: Math.abs(rule.minValue), + severity: measured === null ? 'info' : 'warning', + source: rule.source, + message: + measured === null + ? `${titleCase(target.nodeType)} ${target.nodeId} requires ${rule.label} verification (${comparator} ${requiredLabel}) per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.` + : `${titleCase(target.nodeType)} ${target.nodeId} ${rule.label} ${measuredLabel} is below ${requiredLabel} per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`, + } +} + +function violatesClearanceRule(measured: number, rule: ClearanceRule): boolean { + if (!Number.isFinite(measured)) return true + if (rule.minValue < 0) return measured > Math.abs(rule.minValue) + return measured < rule.minValue +} + +function zoneClearDimensions(zone: ZoneNode): { minSpan: number } { + const xs = zone.polygon.map((point) => point[0]) + const zs = zone.polygon.map((point) => point[1]) + if (xs.length === 0 || zs.length === 0) return { minSpan: 0 } + return { + minSpan: Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs)), + } +} + +function normalizedText(parts: readonly string[]): string { + return parts.join(' ').toLowerCase() +} + +function containsAny(text: string, needles: readonly string[]): boolean { + return needles.some((needle) => text.includes(needle)) +} + +function isApplianceCompartment(type: string): boolean { + return [ + 'oven', + 'microwave', + 'dishwasher', + 'cooktop-gas', + 'cooktop-induction', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', + ].includes(type) +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} diff --git a/packages/nodes/src/shared/construction-dimension-standards.ts b/packages/nodes/src/shared/construction-dimension-standards.ts new file mode 100644 index 00000000..b52898fa --- /dev/null +++ b/packages/nodes/src/shared/construction-dimension-standards.ts @@ -0,0 +1,43 @@ +import type { DimensionTerminator, DimensionTextPosition } from '@pascal-app/core' +import type { + ConstructionImperialPrecision, + ConstructionMetricNotation, +} from './construction-length' + +export type ConstructionDimensionDrawingStandard = { + datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face' + intersectionReferencePolicy: 'single' | 'both-faces' + terminator: DimensionTerminator + textPosition: DimensionTextPosition + imperialPrecision: ConstructionImperialPrecision + metricNotation: ConstructionMetricNotation + openingChainOffset: number + wallSpanOffset: number + firstOpeningWidthOffset: number + firstGeneralTierOffset: number + tierSpacing: number + extensionStartGap: number + extensionOvershoot: number +} + +export const DEFAULT_CONSTRUCTION_DIMENSION_STANDARD = { + datumPolicy: 'wall-face', + intersectionReferencePolicy: 'single', + terminator: 'architectural-tick', + textPosition: 'above', + imperialPrecision: '1/16', + metricNotation: 'meters', + openingChainOffset: 0.55, + wallSpanOffset: 1.05, + firstOpeningWidthOffset: 0.62, + firstGeneralTierOffset: 0.55, + tierSpacing: 0.62, + extensionStartGap: 0.075, + extensionOvershoot: 0.12, +} satisfies ConstructionDimensionDrawingStandard + +export function constructionDimensionStandard( + overrides: Partial = {}, +): ConstructionDimensionDrawingStandard { + return { ...DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, ...overrides } +} diff --git a/packages/nodes/src/shared/construction-length.test.ts b/packages/nodes/src/shared/construction-length.test.ts new file mode 100644 index 00000000..70f7ad0a --- /dev/null +++ b/packages/nodes/src/shared/construction-length.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { formatConstructionLength } from './construction-length' + +describe('formatConstructionLength profiles', () => { + test('keeps metre notation for interactive metric dimensions', () => { + expect(formatConstructionLength(3.456, 'metric')).toBe('3.46m') + expect(formatConstructionLength(-0.004, 'metric')).toBe('0m') + }) + + test('uses whole millimetres without a suffix for metric documents', () => { + expect(formatConstructionLength(3.4564, 'metric', 'document')).toBe('3456') + expect(formatConstructionLength(-0.004, 'metric', 'document')).toBe('-4') + }) + + test('keeps architectural imperial notation in document output', () => { + expect(formatConstructionLength(1.524, 'imperial', 'document')).toBe(`5'-0"`) + }) + + test('honors drafting standard precision and notation overrides', () => { + expect( + formatConstructionLength(1.524, 'metric', 'editor', { metricNotation: 'millimeters' }), + ).toBe('1524') + expect( + formatConstructionLength((7 * 12 + 5.25) * 0.0254, 'imperial', 'editor', { + imperialPrecision: '1/2', + }), + ).toBe(`7'-5 1/2"`) + }) +}) diff --git a/packages/nodes/src/shared/construction-length.ts b/packages/nodes/src/shared/construction-length.ts new file mode 100644 index 00000000..07bdf97b --- /dev/null +++ b/packages/nodes/src/shared/construction-length.ts @@ -0,0 +1,77 @@ +const INCHES_PER_METER = 1 / 0.0254 +const IMPERIAL_FRACTION_DENOMINATOR = 16 + +export type ConstructionLinearUnit = 'metric' | 'imperial' +export type ConstructionLengthProfile = 'editor' | 'document' +export type ConstructionMetricNotation = 'meters' | 'millimeters' +export type ConstructionImperialPrecision = '1' | '1/2' | '1/4' | '1/8' | '1/16' + +export type ConstructionLengthFormatOptions = { + metricNotation?: ConstructionMetricNotation + imperialPrecision?: ConstructionImperialPrecision +} + +export function formatConstructionLength( + meters: number, + unit: ConstructionLinearUnit, + profile: ConstructionLengthProfile = 'editor', + options: ConstructionLengthFormatOptions = {}, +): string { + if (!Number.isFinite(meters)) return '--' + + if (unit === 'metric') { + if (profile === 'document' || options.metricNotation === 'millimeters') { + return `${Math.round(meters * 1000)}` + } + + const rounded = Number.parseFloat(Math.abs(meters).toFixed(2)) + const sign = meters < 0 && rounded !== 0 ? '-' : '' + return `${sign}${rounded}m` + } + + const sign = meters < 0 ? '-' : '' + const denominator = imperialPrecisionDenominator(options.imperialPrecision) + const totalFractionUnits = Math.round(Math.abs(meters) * INCHES_PER_METER * denominator) + const unitsPerFoot = 12 * denominator + const feet = Math.floor(totalFractionUnits / unitsPerFoot) + const remainder = totalFractionUnits - feet * unitsPerFoot + const inches = Math.floor(remainder / denominator) + const numerator = remainder - inches * denominator + const fraction = formatFraction(numerator, denominator) + const inchText = fraction ? `${inches} ${fraction}` : `${inches}` + + if (feet === 0) return `${sign}${inchText}"` + return `${sign}${feet}'-${inchText}"` +} + +function imperialPrecisionDenominator(precision?: ConstructionImperialPrecision): number { + switch (precision) { + case '1': + return 1 + case '1/2': + return 2 + case '1/4': + return 4 + case '1/8': + return 8 + default: + return IMPERIAL_FRACTION_DENOMINATOR + } +} + +function formatFraction(numerator: number, denominator: number): string { + if (numerator === 0) return '' + const divisor = greatestCommonDivisor(numerator, denominator) + return `${numerator / divisor}/${denominator / divisor}` +} + +function greatestCommonDivisor(a: number, b: number): number { + let left = Math.abs(a) + let right = Math.abs(b) + while (right !== 0) { + const next = left % right + left = right + right = next + } + return left || 1 +} diff --git a/packages/nodes/src/shared/construction-module-advisories.test.ts b/packages/nodes/src/shared/construction-module-advisories.test.ts new file mode 100644 index 00000000..1acbf675 --- /dev/null +++ b/packages/nodes/src/shared/construction-module-advisories.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, WallNode, WindowNode } from '@pascal-app/core' +import { + buildConstructionModuleAdvisories, + type ConstructionModuleProfile, + DEFAULT_CONSTRUCTION_MODULE_PROFILES, +} from './construction-module-advisories' + +const FOOT = 0.3048 + +const metricProfile: ConstructionModuleProfile = { + ...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'metric-common')!, + enabled: true, +} + +const imperialProfile: ConstructionModuleProfile = { + ...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'imperial-common')!, + enabled: true, +} + +function nodes(...items: AnyNode[]): Record { + return Object.fromEntries(items.map((item) => [item.id, item])) as Record +} + +describe('construction module advisories', () => { + test('keeps default construction module profiles optional and quiet', () => { + const wall = WallNode.parse({ + id: 'wall_off_module', + start: [0, 0], + end: [3.97, 0], + }) + + expect(buildConstructionModuleAdvisories(nodes(wall))).toEqual([]) + }) + + test('reports metric wall lengths that miss the configured construction module', () => { + const compliantWall = WallNode.parse({ + id: 'wall_metric_ok', + start: [0, 0], + end: [4, 0], + }) + const offModuleWall = WallNode.parse({ + id: 'wall_metric_off', + start: [0, 0], + end: [3.97, 0], + }) + + const advisories = buildConstructionModuleAdvisories(nodes(compliantWall, offModuleWall), { + profiles: [metricProfile], + }) + + expect(advisories).toHaveLength(1) + expect(advisories[0]).toMatchObject({ + id: 'construction-module:metric-common:wall_metric_off:wall-length', + nodeId: 'wall_metric_off', + profileId: 'metric-common', + kind: 'wall-length', + module: 0.1, + measured: 3.97, + nearestMultiple: 4, + severity: 'info', + }) + expect(advisories[0]?.deviation).toBeCloseTo(0.03) + expect(advisories[0]?.message).toContain('100 mm construction module') + }) + + test('checks overall level extents at exterior finish faces', () => { + const walls = [ + WallNode.parse({ + id: 'wall_bottom', + parentId: 'level_main', + start: [0, 0], + end: [4.03, 0], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_right', + parentId: 'level_main', + start: [4.03, 0], + end: [4.03, 3], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_top', + parentId: 'level_main', + start: [4.03, 3], + end: [0, 3], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_left', + parentId: 'level_main', + start: [0, 3], + end: [0, 0], + thickness: 0.2, + }), + ] + + const advisories = buildConstructionModuleAdvisories(nodes(...walls), { + profiles: [metricProfile], + }) + + expect(advisories).toContainEqual( + expect.objectContaining({ + id: 'construction-module:metric-common:level_main:level-overall-width', + nodeId: 'level_main', + nodeType: 'level', + kind: 'level-overall-width', + }), + ) + expect( + advisories.find((advisory) => advisory.kind === 'level-overall-width')?.measured, + ).toBeCloseTo(4.23) + expect(advisories).not.toContainEqual(expect.objectContaining({ kind: 'level-overall-depth' })) + }) + + test('reports imperial opening widths that miss common inch modules', () => { + const compliantDoor = DoorNode.parse({ + id: 'door_imperial_ok', + width: 3 * FOOT, + }) + const offModuleDoor = DoorNode.parse({ + id: 'door_imperial_off', + width: 0.95, + }) + + const advisories = buildConstructionModuleAdvisories(nodes(compliantDoor, offModuleDoor), { + profiles: [imperialProfile], + }) + + expect(advisories).toHaveLength(1) + expect(advisories[0]).toMatchObject({ + id: 'construction-module:imperial-common:door_imperial_off:opening-width', + nodeId: 'door_imperial_off', + profileId: 'imperial-common', + kind: 'opening-width', + }) + expect(advisories[0]?.module).toBeCloseTo(12 * 0.0254) + expect(advisories[0]?.message).toContain('1\'-0" construction module') + }) + + test('checks verified rough, masonry, and finish opening widths without inventing them', () => { + const door = DoorNode.parse({ + id: 'door_verified_widths', + width: 1.2, + roughOpeningWidth: 1.23, + masonryOpeningWidth: 1.4, + }) + const window = WindowNode.parse({ + id: 'window_verified_widths', + width: 1.2, + finishOpeningWidth: 1.27, + }) + + const advisories = buildConstructionModuleAdvisories(nodes(door, window), { + profiles: [metricProfile], + }) + + expect(advisories.map((advisory) => advisory.id)).toEqual([ + 'construction-module:metric-common:door_verified_widths:rough-opening-width', + 'construction-module:metric-common:window_verified_widths:finish-opening-width', + ]) + }) + + test('can explicitly include disabled profiles for preflight previews', () => { + const wall = WallNode.parse({ + id: 'wall_preview', + start: [0, 0], + end: [3.97, 0], + }) + + const advisories = buildConstructionModuleAdvisories(nodes(wall), { + includeDisabled: true, + }) + + expect(advisories.map((advisory) => advisory.profileId).sort()).toEqual([ + 'imperial-common', + 'metric-common', + ]) + }) +}) diff --git a/packages/nodes/src/shared/construction-module-advisories.ts b/packages/nodes/src/shared/construction-module-advisories.ts new file mode 100644 index 00000000..3c16294e --- /dev/null +++ b/packages/nodes/src/shared/construction-module-advisories.ts @@ -0,0 +1,326 @@ +import { + type AnyNode, + type DoorNode, + getWallAssemblyFaceOffsets, + type WallNode, + type WindowNode, +} from '@pascal-app/core' +import { formatConstructionLength } from './construction-length' + +const INCH = 0.0254 + +export type ConstructionModuleSystem = 'imperial' | 'metric' +export type ConstructionModuleAdvisorySeverity = 'info' | 'warning' + +export type ConstructionModuleProfile = { + id: string + label: string + system: ConstructionModuleSystem + modules: readonly number[] + tolerance: number + enabled: boolean +} + +export type ConstructionModuleMeasurementKind = + | 'wall-length' + | 'level-overall-width' + | 'level-overall-depth' + | 'opening-width' + | 'rough-opening-width' + | 'masonry-opening-width' + | 'finish-opening-width' + +export type ConstructionModuleAdvisory = { + id: string + nodeId: string + nodeType: string + profileId: string + profileLabel: string + system: ConstructionModuleSystem + kind: ConstructionModuleMeasurementKind + label: string + module: number + measured: number + deviation: number + nearestMultiple: number + severity: ConstructionModuleAdvisorySeverity + message: string +} + +export type BuildConstructionModuleAdvisoriesOptions = { + profiles?: readonly ConstructionModuleProfile[] + includeDisabled?: boolean +} + +type ConstructionModuleMeasurement = { + nodeId: string + nodeType: string + kind: ConstructionModuleMeasurementKind + label: string + measured: number +} + +type ModuleFit = { + module: number + nearestMultiple: number + deviation: number +} + +export const DEFAULT_CONSTRUCTION_MODULE_PROFILES: readonly ConstructionModuleProfile[] = [ + { + id: 'imperial-common', + label: 'Imperial common modules', + system: 'imperial', + modules: [12 * INCH, 16 * INCH, 24 * INCH], + tolerance: 0.25 * INCH, + enabled: false, + }, + { + id: 'metric-common', + label: 'Metric common modules', + system: 'metric', + modules: [0.1, 0.2, 0.4, 0.6], + tolerance: 0.005, + enabled: false, + }, +] as const + +export function buildConstructionModuleAdvisories( + nodes: Readonly>, + options: BuildConstructionModuleAdvisoriesOptions = {}, +): ConstructionModuleAdvisory[] { + const profiles = (options.profiles ?? DEFAULT_CONSTRUCTION_MODULE_PROFILES).filter( + (profile) => options.includeDisabled === true || profile.enabled, + ) + if (profiles.length === 0) return [] + + const measurements = [ + ...Object.values(nodes).flatMap((node) => constructionModuleMeasurements(node)), + ...levelOverallMeasurements(nodes), + ] + const advisories: ConstructionModuleAdvisory[] = [] + + for (const measurement of measurements) { + for (const profile of profiles) { + const fit = bestModuleFit(measurement.measured, profile.modules) + if (!fit || fit.deviation <= profile.tolerance) continue + + advisories.push({ + id: ['construction-module', profile.id, measurement.nodeId, measurement.kind].join(':'), + nodeId: measurement.nodeId, + nodeType: measurement.nodeType, + profileId: profile.id, + profileLabel: profile.label, + system: profile.system, + kind: measurement.kind, + label: measurement.label, + module: fit.module, + measured: measurement.measured, + deviation: fit.deviation, + nearestMultiple: fit.nearestMultiple, + severity: 'info', + message: moduleAdvisoryMessage(measurement, profile, fit), + }) + } + } + + return advisories.sort((left, right) => left.id.localeCompare(right.id)) +} + +function levelOverallMeasurements( + nodes: Readonly>, +): ConstructionModuleMeasurement[] { + const wallsByLevel = new Map() + for (const node of Object.values(nodes)) { + if (node.type !== 'wall' || !node.parentId) continue + if (node.curveOffset !== undefined && Math.abs(node.curveOffset) > 1e-6) continue + const levelWalls = wallsByLevel.get(node.parentId) ?? [] + levelWalls.push(node) + wallsByLevel.set(node.parentId, levelWalls) + } + + const measurements: ConstructionModuleMeasurement[] = [] + for (const [levelId, walls] of wallsByLevel) { + const primaryWall = walls.reduce((longest, wall) => + wallLength(wall) > wallLength(longest) ? wall : longest, + ) + const primaryLength = wallLength(primaryWall) + if ( + walls.length < 2 || + !isUsefulLength(primaryLength) || + !walls.some((wall) => !wallsAreParallel(primaryWall, wall)) + ) { + continue + } + + const footprintPoints = walls.flatMap(wallFootprintPoints) + const direction: [number, number] = [ + (primaryWall.end[0] - primaryWall.start[0]) / primaryLength, + (primaryWall.end[1] - primaryWall.start[1]) / primaryLength, + ] + const normal: [number, number] = [-direction[1], direction[0]] + const along = footprintPoints.map(([x, y]) => x * direction[0] + y * direction[1]) + const across = footprintPoints.map(([x, y]) => x * normal[0] + y * normal[1]) + const width = Math.max(...along) - Math.min(...along) + const depth = Math.max(...across) - Math.min(...across) + + if (isUsefulLength(width)) { + measurements.push({ + nodeId: levelId, + nodeType: 'level', + kind: 'level-overall-width', + label: 'overall plan width', + measured: width, + }) + } + if (isUsefulLength(depth)) { + measurements.push({ + nodeId: levelId, + nodeType: 'level', + kind: 'level-overall-depth', + label: 'overall plan depth', + measured: depth, + }) + } + } + + return measurements +} + +function wallLength(wall: WallNode): number { + return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) +} + +function wallsAreParallel(left: WallNode, right: WallNode): boolean { + const leftLength = wallLength(left) + const rightLength = wallLength(right) + if (!(isUsefulLength(leftLength) && isUsefulLength(rightLength))) return true + const leftDirection = [ + (left.end[0] - left.start[0]) / leftLength, + (left.end[1] - left.start[1]) / leftLength, + ] + const rightDirection = [ + (right.end[0] - right.start[0]) / rightLength, + (right.end[1] - right.start[1]) / rightLength, + ] + return ( + Math.abs(leftDirection[0]! * rightDirection[1]! - leftDirection[1]! * rightDirection[0]!) < 1e-4 + ) +} + +function wallFootprintPoints(wall: WallNode): [number, number][] { + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = wallLength(wall) + if (!isUsefulLength(length)) return [] + + const normal: [number, number] = [-dy / length, dx / length] + const offsets = getWallAssemblyFaceOffsets(wall) + return [offsets.interior, offsets.exterior].flatMap((offset) => [ + [wall.start[0] + normal[0] * offset, wall.start[1] + normal[1] * offset], + [wall.end[0] + normal[0] * offset, wall.end[1] + normal[1] * offset], + ]) +} + +function constructionModuleMeasurements(node: AnyNode): ConstructionModuleMeasurement[] { + if (node.type === 'wall') return wallMeasurements(node) + if (node.type === 'door' || node.type === 'window') return openingMeasurements(node) + return [] +} + +function wallMeasurements(wall: WallNode): ConstructionModuleMeasurement[] { + if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return [] + + const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + if (!isUsefulLength(length)) return [] + + return [ + { + nodeId: wall.id, + nodeType: wall.type, + kind: 'wall-length', + label: 'wall length', + measured: length, + }, + ] +} + +function openingMeasurements(opening: DoorNode | WindowNode): ConstructionModuleMeasurement[] { + return [ + widthMeasurement(opening, 'opening-width', 'nominal width', opening.width), + widthMeasurement( + opening, + 'rough-opening-width', + 'rough opening width', + opening.roughOpeningWidth, + ), + widthMeasurement( + opening, + 'masonry-opening-width', + 'masonry opening width', + opening.masonryOpeningWidth, + ), + widthMeasurement( + opening, + 'finish-opening-width', + 'finish opening width', + opening.finishOpeningWidth, + ), + ].filter((measurement): measurement is ConstructionModuleMeasurement => measurement !== null) +} + +function widthMeasurement( + opening: DoorNode | WindowNode, + kind: ConstructionModuleMeasurementKind, + label: string, + measured: number | undefined, +): ConstructionModuleMeasurement | null { + if (!isUsefulLength(measured)) return null + return { + nodeId: opening.id, + nodeType: opening.type, + kind, + label, + measured, + } +} + +function bestModuleFit(measured: number, modules: readonly number[]): ModuleFit | null { + let best: ModuleFit | null = null + for (const module of modules) { + if (!isUsefulLength(module)) continue + const multiple = Math.max(1, Math.round(measured / module)) + const nearestMultiple = multiple * module + const deviation = Math.abs(measured - nearestMultiple) + if (!best || deviation < best.deviation) { + best = { module, nearestMultiple, deviation } + } + } + return best +} + +function moduleAdvisoryMessage( + measurement: ConstructionModuleMeasurement, + profile: ConstructionModuleProfile, + fit: ModuleFit, +): string { + const unit = profile.system === 'imperial' ? 'imperial' : 'metric' + const measured = formatConstructionLength(measurement.measured, unit) + const module = formatModuleLength(fit.module, profile.system) + const deviation = formatConstructionLength(fit.deviation, unit) + + return `${titleCase(measurement.nodeType)} ${measurement.nodeId} ${measurement.label} ${measured} is ${deviation} off the ${module} construction module.` +} + +function formatModuleLength(module: number, system: ConstructionModuleSystem): string { + if (system === 'metric') return `${Math.round(module * 1000)} mm` + return formatConstructionLength(module, 'imperial') +} + +function isUsefulLength(value: number | undefined): value is number { + return value !== undefined && Number.isFinite(value) && value > 1e-6 +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} diff --git a/packages/nodes/src/shared/dimension-completeness-audit.test.ts b/packages/nodes/src/shared/dimension-completeness-audit.test.ts new file mode 100644 index 00000000..5db30a90 --- /dev/null +++ b/packages/nodes/src/shared/dimension-completeness-audit.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + CabinetNode, + ConstructionDimensionNode, + DoorNode, + StairNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { buildDimensionCompletenessAudit } from './dimension-completeness-audit' + +function nodes(...items: AnyNode[]): Record { + return Object.fromEntries(items.map((item) => [item.id, item])) as Record +} + +function featureAnchor(nodeId: string, fallback: [number, number, number] = [0, 0, 0]) { + return { + kind: 'feature' as const, + reference: { nodeId, featureId: 'center' }, + fallback, + } +} + +describe('dimension completeness audit', () => { + test('reports missing overall exterior wall dimensions and partition references', () => { + const exteriorWall = WallNode.parse({ + id: 'wall_exterior', + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const partitionWall = WallNode.parse({ + id: 'wall_partition', + start: [1, 0], + end: [1, 3], + frontSide: 'interior', + backSide: 'interior', + }) + + const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall)) + + expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([ + 'missing-overall-dimension', + 'missing-partition-reference', + 'undocumented-critical-node', + 'undocumented-critical-node', + ]) + expect(issues).toContainEqual( + expect.objectContaining({ + kind: 'missing-overall-dimension', + nodeId: 'wall_exterior', + severity: 'warning', + }), + ) + expect(issues).toContainEqual( + expect.objectContaining({ + kind: 'missing-partition-reference', + nodeId: 'wall_partition', + severity: 'info', + }), + ) + }) + + test('uses associative construction-dimension anchors as dimension coverage', () => { + const exteriorWall = WallNode.parse({ + id: 'wall_exterior', + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const partitionWall = WallNode.parse({ + id: 'wall_partition', + start: [1, 0], + end: [1, 3], + frontSide: 'interior', + backSide: 'interior', + }) + const dimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_wall_refs', + anchors: [ + featureAnchor(exteriorWall.id, [0, 0, 0]), + featureAnchor(partitionWall.id, [1, 0, 0]), + ], + }) + + expect(buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall, dimension))).toEqual( + [], + ) + }) + + test('can count the automatic wall and opening dimension plan as coverage', () => { + const exteriorWall = WallNode.parse({ + id: 'wall_exterior', + children: ['door_entry'], + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exteriorWall.id, + wallId: exteriorWall.id, + roughOpeningWidth: 0.96, + }) + + expect( + buildDimensionCompletenessAudit(nodes(exteriorWall, door), { + includeAutomaticDimensions: true, + }), + ).toEqual([]) + }) + + test('reports undimensioned exterior openings and missing verified rough openings', () => { + const exteriorWall = WallNode.parse({ + id: 'wall_exterior', + children: ['door_entry', 'window_front'], + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exteriorWall.id, + wallId: exteriorWall.id, + width: 0.9, + }) + const window = WindowNode.parse({ + id: 'window_front', + parentId: exteriorWall.id, + wallId: exteriorWall.id, + roughOpeningWidth: 1.22, + }) + + const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, window)) + + expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([ + 'missing-overall-dimension', + 'missing-verified-rough-opening', + 'undimensioned-exterior-opening', + 'undimensioned-exterior-opening', + 'undocumented-critical-node', + ]) + expect(issues.filter((auditIssue) => auditIssue.nodeId === 'window_front')).toHaveLength(1) + }) + + test('suppresses exterior opening and rough-opening issues when evidence exists', () => { + const exteriorWall = WallNode.parse({ + id: 'wall_exterior', + children: ['door_entry'], + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exteriorWall.id, + wallId: exteriorWall.id, + width: 0.9, + roughOpeningWidth: 0.96, + }) + const openingDimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_door', + anchors: [featureAnchor(door.id, [2, 0, 0]), featureAnchor(door.id, [3, 0, 0])], + }) + + const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, openingDimension)) + + expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([ + 'missing-overall-dimension', + 'undocumented-critical-node', + ]) + }) + + test('can require rough-opening height verification as a stricter profile', () => { + const door = DoorNode.parse({ + id: 'door_entry', + roughOpeningWidth: 0.96, + }) + + expect( + buildDimensionCompletenessAudit(nodes(door), { requireRoughOpeningHeights: true }), + ).toMatchObject([ + { + kind: 'missing-verified-rough-opening', + nodeId: 'door_entry', + }, + ]) + }) + + test('does not require rough openings for masonry openings or frameless openings', () => { + const masonryWindow = WindowNode.parse({ + id: 'window_masonry', + constructionType: 'masonry', + }) + const framelessOpening = DoorNode.parse({ + id: 'door_opening', + openingKind: 'opening', + }) + + expect(buildDimensionCompletenessAudit(nodes(masonryWindow, framelessOpening))).toEqual([]) + }) + + test('detects duplicate and contradictory dimension string overrides', () => { + const wall = WallNode.parse({ + id: 'wall_exterior', + start: [0, 0], + end: [5, 0], + frontSide: 'exterior', + }) + const firstDimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_first', + textOverride: '5.00m', + anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [5, 0, 0])], + }) + const duplicateDimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_duplicate', + textOverride: '5.00 m', + anchors: [featureAnchor('wall_other', [0, 0, 0]), featureAnchor('wall_other', [5, 0, 0])], + }) + const conflictingDimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_conflict', + textOverride: '4.80m', + anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [4.8, 0, 0])], + }) + + const issues = buildDimensionCompletenessAudit( + nodes(wall, firstDimension, duplicateDimension, conflictingDimension), + ) + + expect(issues).toContainEqual( + expect.objectContaining({ + kind: 'duplicate-dimension-string', + nodeId: 'construction-dimension_first', + }), + ) + expect(issues).toContainEqual( + expect.objectContaining({ + kind: 'contradictory-dimension-string', + nodeId: wall.id, + }), + ) + }) + + test('detects continuous dimension segment totals that disagree with the overall string', () => { + const dimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_chain', + chainMode: 'continuous', + textOverride: '3.00m', + anchors: [ + featureAnchor('wall_a', [0, 0, 0]), + featureAnchor('wall_b', [1, 0, 0]), + featureAnchor('wall_c', [2, 0, 0]), + ], + }) + + const issues = buildDimensionCompletenessAudit(nodes(dimension)) + + expect(issues).toEqual([ + expect.objectContaining({ + kind: 'dimension-segment-total-mismatch', + nodeId: 'construction-dimension_chain', + }), + ]) + }) + + test('reports construction-critical nodes without dimensions or schedules', () => { + const undocumentedCabinet = CabinetNode.parse({ + id: 'cabinet_undocumented', + }) + const stair = StairNode.parse({ + id: 'stair_documented', + }) + const stairDimension = ConstructionDimensionNode.parse({ + id: 'construction-dimension_stair', + anchors: [featureAnchor(stair.id, [0, 0, 0]), featureAnchor(stair.id, [1, 0, 0])], + }) + + const issues = buildDimensionCompletenessAudit( + nodes(undocumentedCabinet, stair, stairDimension), + ) + + expect(issues).toEqual([ + expect.objectContaining({ + kind: 'undocumented-critical-node', + nodeId: undocumentedCabinet.id, + }), + ]) + }) + + test('includes unresolved annotation collisions from preflight evidence', () => { + const issues = buildDimensionCompletenessAudit(nodes(), { + preflightIssues: [ + { + id: 'dimension-label_wall_a', + kind: 'unresolved-collision', + severity: 'warning', + message: + 'Wall A dimension label still overlaps another annotation after automatic layout.', + }, + { + id: 'dimension-label_wall_b', + kind: 'short-unreadable-segment', + severity: 'warning', + message: 'Wall B uses an outside label.', + }, + ], + }) + + expect(issues).toEqual([ + expect.objectContaining({ + id: 'dimension-completeness:unresolved-annotation-collision:dimension-label_wall_a', + kind: 'unresolved-annotation-collision', + nodeId: 'dimension-label_wall_a', + nodeType: 'annotation', + }), + ]) + }) + + test('includes clipped sheet content from sheet preflight evidence', () => { + const issues = buildDimensionCompletenessAudit(nodes(), { + preflightIssues: [ + { + message: + 'Scaled plan exceeds the sheet viewport. Review clipped view or annotation content.', + }, + ], + }) + + expect(issues).toEqual([ + expect.objectContaining({ + id: 'dimension-completeness:clipped-sheet-content:sheet', + kind: 'clipped-sheet-content', + nodeId: 'sheet', + nodeType: 'sheet', + severity: 'warning', + }), + ]) + }) +}) diff --git a/packages/nodes/src/shared/dimension-completeness-audit.ts b/packages/nodes/src/shared/dimension-completeness-audit.ts new file mode 100644 index 00000000..fc63905a --- /dev/null +++ b/packages/nodes/src/shared/dimension-completeness-audit.ts @@ -0,0 +1,495 @@ +import { + type AnyNode, + type ConstructionDimensionNode, + type DoorNode, + measurementAnchorReferenceNodeIds, + type WallNode, + type WindowNode, +} from '@pascal-app/core' + +export type DimensionCompletenessIssueKind = + | 'missing-overall-dimension' + | 'undimensioned-exterior-opening' + | 'missing-partition-reference' + | 'missing-verified-rough-opening' + | 'duplicate-dimension-string' + | 'contradictory-dimension-string' + | 'dimension-segment-total-mismatch' + | 'undocumented-critical-node' + | 'unresolved-annotation-collision' + | 'clipped-sheet-content' + +export type DimensionCompletenessIssueSeverity = 'info' | 'warning' + +export type DimensionCompletenessIssue = { + id: string + kind: DimensionCompletenessIssueKind + nodeId: string + nodeType: string + severity: DimensionCompletenessIssueSeverity + message: string +} + +export type BuildDimensionCompletenessAuditOptions = { + includeAutomaticDimensions?: boolean + requireRoughOpeningHeights?: boolean + dimensionValueTolerance?: number + preflightIssues?: readonly DimensionCompletenessPreflightIssue[] +} + +export type DimensionCompletenessPreflightIssue = { + id?: string + kind?: string + severity?: DimensionCompletenessIssueSeverity + message: string +} + +type DimensionCoverage = ReadonlySet +type DocumentationCoverage = { + dimensioned: ReadonlySet + scheduled: ReadonlySet +} +type OpeningNode = DoorNode | WindowNode +type DimensionRecord = { + dimension: ConstructionDimensionNode + referencedNodeIds: readonly string[] + normalizedText: string | null + parsedTextValue: number | null + segmentTotal: number | null +} + +export function buildDimensionCompletenessAudit( + nodes: Readonly>, + options: BuildDimensionCompletenessAuditOptions = {}, +): DimensionCompletenessIssue[] { + const coverage = dimensionCoverage(nodes, options) + const documentation = documentationCoverage(nodes, coverage) + const issues: DimensionCompletenessIssue[] = [] + + issues.push(...dimensionStringIssues(nodes, options)) + issues.push(...preflightCompletenessIssues(options.preflightIssues ?? [])) + + for (const node of Object.values(nodes)) { + if (node.type === 'wall') { + issues.push(...wallDimensionIssues(node, coverage)) + } else if (node.type === 'door' || node.type === 'window') { + issues.push(...openingDimensionIssues(node, nodes, coverage, options)) + } + + if (isConstructionCriticalNode(node, nodes) && !hasDocumentationCoverage(node, documentation)) { + issues.push( + issue( + 'undocumented-critical-node', + node, + 'warning', + `${titleCase(node.type)} ${node.id} has no construction dimension or schedule entry.`, + ), + ) + } + } + + return issues.sort((left, right) => left.id.localeCompare(right.id)) +} + +function dimensionCoverage( + nodes: Readonly>, + options: BuildDimensionCompletenessAuditOptions, +): DimensionCoverage { + const covered = new Set() + for (const node of Object.values(nodes)) { + if (node.type !== 'construction-dimension') continue + + for (const nodeId of measurementAnchorReferenceNodeIds( + (node as ConstructionDimensionNode).anchors, + )) { + covered.add(nodeId) + } + } + if (options.includeAutomaticDimensions === true) { + for (const node of Object.values(nodes)) { + if ( + node.type === 'wall' && + node.visible !== false && + Math.abs(node.curveOffset ?? 0) <= 1e-6 && + (isExteriorWall(node) || isPartitionWall(node)) + ) { + covered.add(node.id) + } + } + for (const node of Object.values(nodes)) { + if (node.type !== 'door' && node.type !== 'window') continue + const host = openingHostWall(node, nodes) + if (host && covered.has(host.id)) covered.add(node.id) + } + } + return covered +} + +function documentationCoverage( + nodes: Readonly>, + dimensioned: DimensionCoverage, +): DocumentationCoverage { + const scheduled = new Set() + + for (const node of Object.values(nodes)) { + if (hasGeneratedScheduleEntry(node)) scheduled.add(node.id) + } + + return { dimensioned, scheduled } +} + +function dimensionStringIssues( + nodes: Readonly>, + options: BuildDimensionCompletenessAuditOptions, +): DimensionCompletenessIssue[] { + const records = Object.values(nodes) + .filter((node): node is ConstructionDimensionNode => node.type === 'construction-dimension') + .map((dimension) => dimensionRecord(dimension)) + const issues: DimensionCompletenessIssue[] = [] + + issues.push(...duplicateDimensionStringIssues(records)) + issues.push(...contradictoryDimensionStringIssues(records)) + issues.push(...segmentTotalMismatchIssues(records, options.dimensionValueTolerance ?? 0.005)) + + return issues +} + +function dimensionRecord(dimension: ConstructionDimensionNode): DimensionRecord { + const normalizedText = normalizedDimensionText(dimension.textOverride) + return { + dimension, + referencedNodeIds: measurementAnchorReferenceNodeIds(dimension.anchors), + normalizedText, + parsedTextValue: normalizedText ? parseDimensionTextValue(normalizedText) : null, + segmentTotal: continuousSegmentTotal(dimension), + } +} + +function duplicateDimensionStringIssues( + records: readonly DimensionRecord[], +): DimensionCompletenessIssue[] { + const byText = new Map() + for (const record of records) { + if (!record.normalizedText) continue + const existing = byText.get(record.normalizedText) + if (existing) existing.push(record) + else byText.set(record.normalizedText, [record]) + } + + const issues: DimensionCompletenessIssue[] = [] + for (const [text, duplicates] of byText) { + if (duplicates.length < 2) continue + const dimension = duplicates[0]?.dimension + if (!dimension) continue + issues.push( + issue( + 'duplicate-dimension-string', + dimension, + 'info', + `Dimension string "${text}" is used by ${duplicates.length} construction dimensions.`, + ), + ) + } + return issues +} + +function contradictoryDimensionStringIssues( + records: readonly DimensionRecord[], +): DimensionCompletenessIssue[] { + const byNode = new Map>() + for (const record of records) { + if (!record.normalizedText) continue + for (const nodeId of record.referencedNodeIds) { + const byText = byNode.get(nodeId) ?? new Map() + const matchingText = byText.get(record.normalizedText) + if (matchingText) matchingText.push(record) + else byText.set(record.normalizedText, [record]) + byNode.set(nodeId, byText) + } + } + + const issues: DimensionCompletenessIssue[] = [] + for (const [nodeId, byText] of byNode) { + if (byText.size < 2) continue + const firstRecord = [...byText.values()][0]?.[0] + if (!firstRecord) continue + issues.push({ + id: ['dimension-completeness', 'contradictory-dimension-string', nodeId].join(':'), + kind: 'contradictory-dimension-string', + nodeId, + nodeType: 'unknown', + severity: 'warning', + message: `Referenced node ${nodeId} has contradictory construction dimension strings: ${[ + ...byText.keys(), + ].join(', ')}.`, + }) + } + return issues +} + +function segmentTotalMismatchIssues( + records: readonly DimensionRecord[], + tolerance: number, +): DimensionCompletenessIssue[] { + return records.flatMap((record) => { + if (record.dimension.chainMode !== 'continuous') return [] + if (record.parsedTextValue === null || record.segmentTotal === null) return [] + if (Math.abs(record.parsedTextValue - record.segmentTotal) <= tolerance) return [] + + return [ + issue( + 'dimension-segment-total-mismatch', + record.dimension, + 'warning', + `Continuous dimension ${record.dimension.id} text ${record.normalizedText} does not match its segment total ${record.segmentTotal.toFixed(3)}m.`, + ), + ] + }) +} + +function preflightCompletenessIssues( + preflightIssues: readonly DimensionCompletenessPreflightIssue[], +): DimensionCompletenessIssue[] { + const issues: DimensionCompletenessIssue[] = [] + for (const preflightIssue of preflightIssues) { + const normalizedKind = preflightIssue.kind?.trim().toLowerCase() + const normalizedMessage = preflightIssue.message.trim().toLowerCase() + + if (normalizedKind === 'unresolved-collision') { + issues.push( + preflightIssueCompletenessIssue( + 'unresolved-annotation-collision', + preflightIssue, + 'annotation', + ), + ) + continue + } + + if ( + normalizedKind === 'clipped-content' || + normalizedKind === 'clipped-sheet-content' || + normalizedMessage.includes('clipped') || + normalizedMessage.includes('exceeds the sheet viewport') + ) { + issues.push(preflightIssueCompletenessIssue('clipped-sheet-content', preflightIssue, 'sheet')) + } + } + return issues +} + +function preflightIssueCompletenessIssue( + kind: Extract< + DimensionCompletenessIssueKind, + 'unresolved-annotation-collision' | 'clipped-sheet-content' + >, + preflightIssue: DimensionCompletenessPreflightIssue, + fallbackNodeId: string, +): DimensionCompletenessIssue { + const nodeId = preflightIssue.id?.trim() || fallbackNodeId + return { + id: ['dimension-completeness', kind, nodeId].join(':'), + kind, + nodeId, + nodeType: fallbackNodeId, + severity: preflightIssue.severity ?? 'warning', + message: preflightIssue.message, + } +} + +function wallDimensionIssues( + wall: WallNode, + coverage: DimensionCoverage, +): DimensionCompletenessIssue[] { + if (coverage.has(wall.id)) return [] + + if (isExteriorWall(wall)) { + return [ + issue( + 'missing-overall-dimension', + wall, + 'warning', + `Exterior wall ${wall.id} has no associative overall construction dimension.`, + ), + ] + } + + if (isPartitionWall(wall)) { + return [ + issue( + 'missing-partition-reference', + wall, + 'info', + `Partition wall ${wall.id} has no associative partition reference dimension.`, + ), + ] + } + + return [] +} + +function openingDimensionIssues( + opening: OpeningNode, + nodes: Readonly>, + coverage: DimensionCoverage, + options: BuildDimensionCompletenessAuditOptions, +): DimensionCompletenessIssue[] { + const issues: DimensionCompletenessIssue[] = [] + const hostWall = openingHostWall(opening, nodes) + + if (hostWall && isExteriorWall(hostWall) && !coverage.has(opening.id)) { + issues.push( + issue( + 'undimensioned-exterior-opening', + opening, + 'warning', + `${titleCase(opening.type)} ${opening.id} is on exterior wall ${hostWall.id} but has no associative opening dimension.`, + ), + ) + } + + if (missingVerifiedRoughOpening(opening, options)) { + issues.push( + issue( + 'missing-verified-rough-opening', + opening, + 'info', + `${titleCase(opening.type)} ${opening.id} has no verified rough-opening ${options.requireRoughOpeningHeights === true ? 'width and height' : 'width'} recorded.`, + ), + ) + } + + return issues +} + +function openingHostWall( + opening: OpeningNode, + nodes: Readonly>, +): WallNode | null { + const hostId = opening.wallId ?? opening.parentId ?? null + if (!hostId) return null + const host = nodes[hostId] + return host?.type === 'wall' ? host : null +} + +function missingVerifiedRoughOpening( + opening: OpeningNode, + options: BuildDimensionCompletenessAuditOptions, +): boolean { + if (opening.openingKind === 'opening') return false + if (opening.constructionType === 'masonry') return false + if (opening.roughOpeningWidth === undefined) return true + return options.requireRoughOpeningHeights === true && opening.roughOpeningHeight === undefined +} + +function isExteriorWall(wall: WallNode): boolean { + return wall.frontSide === 'exterior' || wall.backSide === 'exterior' +} + +function isPartitionWall(wall: WallNode): boolean { + return wall.frontSide === 'interior' || wall.backSide === 'interior' +} + +function hasDocumentationCoverage(node: AnyNode, coverage: DocumentationCoverage): boolean { + return coverage.dimensioned.has(node.id) || coverage.scheduled.has(node.id) +} + +function hasGeneratedScheduleEntry(node: AnyNode): boolean { + if (node.type === 'door' || node.type === 'window') return node.openingKind !== 'opening' + return node.type === 'zone' && node.spaceRole === 'room' +} + +function isConstructionCriticalNode( + node: AnyNode, + nodes: Readonly>, +): boolean { + if (node.type === 'wall') return isExteriorWall(node) || isPartitionWall(node) + if (node.type === 'door' || node.type === 'window') { + const hostWall = openingHostWall(node, nodes) + return hostWall ? isExteriorWall(hostWall) : false + } + if (node.type === 'zone') return node.spaceRole === 'room' + return ( + node.type === 'cabinet' || + node.type === 'cabinet-module' || + node.type === 'stair' || + node.type === 'stair-segment' + ) +} + +function normalizedDimensionText(text: string | null): string | null { + const normalized = text + ?.trim() + .replace(/\s+/g, ' ') + .replace(/(\d)\s+(MM|M|")/gi, '$1$2') + .toUpperCase() + return normalized || null +} + +function parseDimensionTextValue(text: string): number | null { + const metricMatch = text.match(/^([0-9]+(?:\.[0-9]+)?)\s*(MM|M)?$/) + if (metricMatch) { + const value = Number.parseFloat(metricMatch[1] ?? '') + if (!Number.isFinite(value)) return null + return metricMatch[2] === 'MM' ? value / 1000 : value + } + + const imperialMatch = text.match(/^(?:(\d+(?:\.\d+)?)')?(?:-)?(?:(\d+(?:\.\d+)?)")?$/) + if (imperialMatch) { + const feet = Number.parseFloat(imperialMatch[1] ?? '0') + const inches = Number.parseFloat(imperialMatch[2] ?? '0') + const totalInches = feet * 12 + inches + return totalInches > 0 ? totalInches * 0.0254 : null + } + + return null +} + +function continuousSegmentTotal(dimension: ConstructionDimensionNode): number | null { + if (dimension.chainMode !== 'continuous' || dimension.anchors.length < 3) return null + + const directionLength = Math.hypot( + dimension.baseline.direction[0], + dimension.baseline.direction[1], + ) + if (directionLength <= 1e-9) return null + const dirX = dimension.baseline.direction[0] / directionLength + const dirZ = dimension.baseline.direction[1] / directionLength + + let total = 0 + for (let index = 1; index < dimension.anchors.length; index += 1) { + const previousAnchor = dimension.anchors[index - 1] + const currentAnchor = dimension.anchors[index] + if (!previousAnchor || !currentAnchor) return null + const previous = anchorFallbackPoint(previousAnchor) + const current = anchorFallbackPoint(currentAnchor) + total += Math.abs((current[0] - previous[0]) * dirX + (current[2] - previous[2]) * dirZ) + } + return total +} + +function anchorFallbackPoint( + anchor: ConstructionDimensionNode['anchors'][number], +): [number, number, number] { + return Array.isArray(anchor) ? anchor : anchor.fallback +} + +function issue( + kind: DimensionCompletenessIssueKind, + node: Pick, + severity: DimensionCompletenessIssueSeverity, + message: string, +): DimensionCompletenessIssue { + return { + id: ['dimension-completeness', kind, node.id].join(':'), + kind, + nodeId: node.id, + nodeType: node.type, + severity, + message, + } +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} diff --git a/packages/nodes/src/shared/dimension-string.test.ts b/packages/nodes/src/shared/dimension-string.test.ts new file mode 100644 index 00000000..55b0cb1d --- /dev/null +++ b/packages/nodes/src/shared/dimension-string.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'bun:test' +import { buildDimensionStringGeometry } from './dimension-string' + +describe('buildDimensionStringGeometry', () => { + test('expands a logical dimension string into renderable dimension segments', () => { + const geometry = buildDimensionStringGeometry({ + offsetNormal: [0, 1], + offsetDistance: 0, + extensionStartGap: 0.04, + extensionOvershoot: 0.12, + terminator: 'dot', + textPosition: 'centered', + stroke: '#334155', + segments: [ + { + witnessStart: [0, 0], + witnessEnd: [2, 0], + dimensionStart: [0, 1], + dimensionEnd: [2, 1], + text: '2m', + }, + { + witnessStart: [2, 0], + witnessEnd: [5, 0], + dimensionStart: [2, 1], + dimensionEnd: [5, 1], + text: '3m', + }, + ], + }) + + expect(geometry).toEqual( + expect.objectContaining({ + kind: 'dimension-string', + offsetNormal: [0, 1], + offsetDistance: 0, + extensionStartGap: 0.04, + extensionOvershoot: 0.12, + terminator: 'dot', + textPosition: 'centered', + stroke: '#334155', + 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', + }, + ], + }), + ) + }) +}) diff --git a/packages/nodes/src/shared/dimension-string.ts b/packages/nodes/src/shared/dimension-string.ts new file mode 100644 index 00000000..5c70301d --- /dev/null +++ b/packages/nodes/src/shared/dimension-string.ts @@ -0,0 +1,47 @@ +import type { + DimensionTerminator, + DimensionTextPosition, + FloorplanGeometry, + FloorplanPoint, +} from '@pascal-app/core' + +export type DimensionStringSegment = { + witnessStart: FloorplanPoint + witnessEnd: FloorplanPoint + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint + text: string +} + +export type DimensionStringGeometryInput = { + segments: readonly DimensionStringSegment[] + offsetNormal: FloorplanPoint + offsetDistance?: number + extensionStartGap?: number + extensionOvershoot?: number + terminator?: DimensionTerminator + textPosition?: DimensionTextPosition + stroke?: string +} + +export function buildDimensionStringGeometry( + input: DimensionStringGeometryInput, +): FloorplanGeometry { + return { + kind: 'dimension-string', + segments: input.segments.map((segment) => ({ + start: segment.witnessStart, + end: segment.witnessEnd, + dimensionStart: segment.dimensionStart, + dimensionEnd: segment.dimensionEnd, + text: segment.text, + })), + offsetNormal: input.offsetNormal, + offsetDistance: input.offsetDistance ?? 0, + extensionStartGap: input.extensionStartGap, + extensionOvershoot: input.extensionOvershoot ?? 0, + terminator: input.terminator, + textPosition: input.textPosition, + stroke: input.stroke, + } +} diff --git a/packages/nodes/src/shared/draft-axis-guides.tsx b/packages/nodes/src/shared/draft-axis-guides.tsx new file mode 100644 index 00000000..50b52f84 --- /dev/null +++ b/packages/nodes/src/shared/draft-axis-guides.tsx @@ -0,0 +1,253 @@ +import { + EDITOR_LAYER, + formatAngleRadians, + getAngleArcToSegmentReference, + getAngleToSegmentReference, + type SegmentAngleReference, + type WallPlanPoint, +} from '@pascal-app/editor' +import { Html } from '@react-three/drei' +import { useMemo } from 'react' +import { BufferGeometry, Vector3 } from 'three' + +/** + * Axis guide lines + axis-angle readout shown while drafting linear segments + * (walls, fences). An X/Z cross of long thin boxes is drawn through the + * segment start so it can be aligned against the world axes; the moving + * endpoint gets a single long line PERPENDICULAR to the draft segment (a + * second cross there would overlap the start cross whenever the segment is + * axis-aligned). The angle to the nearest axis is shown as an arc + label + * anchored at the start point only (duplicating it at the endpoint is + * visual clutter). + */ +const DRAFT_AXIS_GUIDE_LENGTH = 2000 +const DRAFT_AXIS_GUIDE_WIDTH = 0.035 +const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 +const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 +const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 +const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 +const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 +const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 +const DRAFT_ANGLE_ARC_SEGMENTS = 24 +const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ + { vector: [1, 0], orientation: 'axis' }, + { vector: [0, 1], orientation: 'axis' }, +] + +export type DraftAngleLabel = { + id: string + label: string + position: [number, number, number] + arc: { + center: WallPlanPoint + radius: number + startAngle: number + endAngle: number + y: number + } +} + +export type DraftAxisGuideState = { + origin: WallPlanPoint + endOrigin: WallPlanPoint | null + y: number + angleLabel: DraftAngleLabel | null +} | null + +type AxisAngleCandidate = { + angle: number + arc: { + startAngle: number + endAngle: number + midAngle: number + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +export function getNearestAxisAngleLabel( + start: WallPlanPoint, + end: WallPlanPoint, + y: number, +): DraftAngleLabel | null { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < 0.01) return null + + const draftVector: WallPlanPoint = [dx, dz] + const axisCandidates: AxisAngleCandidate[] = [] + for (const reference of AXIS_ANGLE_REFERENCES) { + const angle = getAngleToSegmentReference(draftVector, reference) + const arc = getAngleArcToSegmentReference(draftVector, reference) + if (!(angle === null || arc === null)) { + axisCandidates.push({ angle, arc }) + } + } + const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] + if (!nearestAxisAngle) return null + + const radius = clamp( + length * 0.22, + DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, + DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, + ) + const { angle, arc } = nearestAxisAngle + + return { + id: 'axis', + label: formatAngleRadians(angle), + position: [ + start[0] + Math.cos(arc.midAngle) * (radius + 0.16), + y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, + start[1] + Math.sin(arc.midAngle) * (radius + 0.16), + ], + arc: { + center: start, + radius, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, + }, + } +} + +export function DraftAxisGuides({ + guide, + labelColor, + labelShadowColor, +}: { + guide: DraftAxisGuideState + labelColor: string + labelShadowColor: string +}) { + if (!guide) return null + + const [x, z] = guide.origin + + // Single long line through the endpoint, perpendicular to the draft + // segment (a full axis cross there would collide with the start cross + // whenever the segment is axis-aligned). + let endRotationY: number | null = null + if (guide.endOrigin) { + const dx = guide.endOrigin[0] - x + const dz = guide.endOrigin[1] - z + if (dx * dx + dz * dz >= 0.01 * 0.01) { + endRotationY = Math.atan2(-dx, -dz) + } + } + + return ( + <> + + + + + {guide.endOrigin && endRotationY !== null && ( + + + + )} + {guide.angleLabel && ( + <> + + + + )} + + ) +} + +function DraftAxisGuideLine({ axis, rotationY }: { axis?: 'x' | 'z'; rotationY?: number }) { + const y = rotationY ?? (axis === 'z' ? Math.PI / 2 : 0) + return ( + + + + + ) +} + +export function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { + const geometry = useMemo(() => { + const segmentCount = Math.max( + 8, + Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), + ) + + const points = Array.from({ length: segmentCount + 1 }, (_, index) => { + const t = index / segmentCount + const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t + + return new Vector3( + arc.center[0] + Math.cos(angle) * arc.radius, + arc.y, + arc.center[1] + Math.sin(angle) * arc.radius, + ) + }) + + return new BufferGeometry().setFromPoints(points) + }, [arc]) + + return ( + // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. + + + + ) +} + +export function DraftMeasurementLabel({ + color, + label, + position, + shadowColor, +}: { + color: string + label: string + position: [number, number, number] + shadowColor: string +}) { + return ( + +
+ {label} +
+ + ) +} diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 388cc85d..c03f17c2 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -10,6 +10,7 @@ import { type RoofNode, type RoofSegmentNode, resolveAlignment, + resolveSupportSlabPatch, type StairNode, type StairSegmentNode, sceneRegistry, @@ -404,23 +405,38 @@ export const MoveRoofTool: React.FC<{ useAlignmentGuides.getState().clear() wasCommitted = true + const position: [number, number, number] = [localX, movingNode.position[1], localZ] + const effectiveNode = { + ...movingNode, + position, + rotation: pendingRotation, + } as typeof movingNode + const supportPatch = isFloorPlaced + ? resolveSupportSlabPatch(effectiveNode, { + ...useScene.getState().nodes, + [movingNode.id]: effectiveNode, + }) + : {} + let committedId = movingNode.id as AnyNodeId if (isNew) { committedId = commitFreshPlacementSubtree(movingNode.id as AnyNodeId, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, visible: true, + ...supportPatch, }) ?? committedId } else { // The store still holds the original values (we didn't update during drag). // Resume temporal and apply the final state as a single undoable step. useScene.temporal.getState().resume() useScene.getState().updateNode(movingNode.id, { - position: [localX, movingNode.position[1], localZ], + position, rotation: pendingRotation, metadata: committedMeta, + ...supportPatch, }) useScene.temporal.getState().pause() } diff --git a/packages/nodes/src/shared/opening-documentation-fields.tsx b/packages/nodes/src/shared/opening-documentation-fields.tsx new file mode 100644 index 00000000..83a724be --- /dev/null +++ b/packages/nodes/src/shared/opening-documentation-fields.tsx @@ -0,0 +1,199 @@ +'use client' + +import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' + +type OpeningDocumentationPatch = { + mark?: string + constructionType?: 'framed' | 'masonry' + dimensionReference?: 'nominal' | 'rough-opening' | 'masonry-opening' | 'finish-opening' + roughOpeningWidth?: number + roughOpeningHeight?: number + masonryOpeningWidth?: number + masonryOpeningHeight?: number + finishOpeningWidth?: number + finishOpeningHeight?: number +} + +export function OpeningDocumentationFields({ + mark, + constructionType = 'framed', + dimensionReference = 'nominal', + roughOpeningWidth, + roughOpeningHeight, + masonryOpeningWidth, + masonryOpeningHeight, + finishOpeningWidth, + finishOpeningHeight, + onChange, +}: OpeningDocumentationPatch & { + onChange: (patch: OpeningDocumentationPatch) => void +}) { + return ( +
+ +
+ + +
+
+ onChange({ roughOpeningWidth: value })} + value={roughOpeningWidth} + /> + onChange({ roughOpeningHeight: value })} + value={roughOpeningHeight} + /> +
+
+ onChange({ masonryOpeningWidth: value })} + value={masonryOpeningWidth} + /> + onChange({ masonryOpeningHeight: value })} + value={masonryOpeningHeight} + /> +
+
+ onChange({ finishOpeningWidth: value })} + value={finishOpeningWidth} + /> + onChange({ finishOpeningHeight: value })} + value={finishOpeningHeight} + /> +
+

+ Leave RO, MO, and FO values blank until verified by the applicable manufacturer or trade. +

+
+ ) +} + +function OptionalMeterInput({ + label, + value, + onChange, +}: { + label: string + value?: number + onChange: (value: number | undefined) => void +}) { + const unit = useViewer((state) => state.unit) + const displayValue = value === undefined ? '' : roundForInput(metersToLinearUnit(value, unit)) + + return ( + + ) +} + +function roundForInput(value: number): number { + return Math.round(value * 1000) / 1000 +} diff --git a/packages/nodes/src/shared/opening-documentation.test.ts b/packages/nodes/src/shared/opening-documentation.test.ts new file mode 100644 index 00000000..c1cd790d --- /dev/null +++ b/packages/nodes/src/shared/opening-documentation.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, LevelNode, WallNode, WindowNode } from '@pascal-app/core' +import { + buildDoorFloorplanSchedule, + buildOpeningMarkAnnotation, + buildWindowFloorplanSchedule, + computeDoorFloorplanLevelData, + computeWindowFloorplanLevelData, + resolveOpeningDimensionDocumentation, +} from './opening-documentation' + +const FOOT = 0.3048 + +function fixture(levelNumber = 0) { + const level = LevelNode.parse({ + id: 'level_main', + level: levelNumber, + children: ['wall_main'], + }) + const wall = WallNode.parse({ + id: 'wall_main', + parentId: level.id, + children: ['door_a', 'door_b', 'window_a', 'window_b'], + start: [0, 0], + end: [10, 0], + thickness: 0.2, + frontSide: 'exterior', + backSide: 'interior', + }) + const doorA = DoorNode.parse({ + id: 'door_a', + parentId: wall.id, + wallId: wall.id, + position: [3, 3.5 * FOOT, 0], + width: 3 * FOOT, + height: 7 * FOOT, + }) + const doorB = DoorNode.parse({ + id: 'door_b', + parentId: wall.id, + wallId: wall.id, + position: [6, 3.5 * FOOT, 0], + width: 3 * FOOT, + height: 7 * FOOT, + }) + const windowA = WindowNode.parse({ + id: 'window_a', + parentId: wall.id, + wallId: wall.id, + position: [2, 5 * FOOT, 0], + width: 4 * FOOT, + height: 4 * FOOT, + }) + const windowB = WindowNode.parse({ + id: 'window_b', + parentId: wall.id, + wallId: wall.id, + position: [8, 5 * FOOT, 0], + width: 4 * FOOT, + height: 4 * FOOT, + }) + const nodes = Object.fromEntries( + [level, wall, doorA, doorB, windowA, windowB].map((node) => [node.id, node]), + ) as Record + + return { doorA, doorB, level, nodes, wall, windowA, windowB } +} + +describe('opening construction documentation', () => { + test('assigns deterministic level-based door marks and skips explicit marks', () => { + const { doorA, doorB, nodes } = fixture() + const explicit = DoorNode.parse({ ...doorA, mark: '101' }) + const marks = computeDoorFloorplanLevelData({ siblings: [explicit, doorB], nodes }) + + expect(marks.markById.get(explicit.id)).toBe('101') + expect(marks.markById.get(doorB.id)).toBe('102') + + const upperFixture = fixture(1) + const upperMarks = computeDoorFloorplanLevelData({ + siblings: [upperFixture.doorA], + nodes: upperFixture.nodes, + }) + expect(upperMarks.markById.get(upperFixture.doorA.id)).toBe('201') + }) + + test('assigns stable window marks in level order', () => { + const { nodes, windowA, windowB } = fixture() + const marks = computeWindowFloorplanLevelData({ + siblings: [windowA, windowB], + nodes, + }) + + expect(marks.markById.get(windowA.id)).toBe('W01') + expect(marks.markById.get(windowB.id)).toBe('W02') + }) + + test('builds U.S. door schedule dimensions without inventing a rough opening', () => { + const { doorA, level, nodes } = fixture() + const schedule = buildDoorFloorplanSchedule({ + siblings: [doorA], + nodes, + levelId: level.id, + unit: 'imperial', + }) + + expect(schedule?.rows[0]?.cells).toMatchObject({ + mark: '101', + size: `3'-0" x 7'-0"`, + roughOpening: 'VERIFY', + }) + }) + + test('includes verified window rough opening, sill, and head heights', () => { + const { level, nodes, windowA } = fixture() + const documented = WindowNode.parse({ + ...windowA, + roughOpeningWidth: 4.1 * FOOT, + roughOpeningHeight: 4.2 * FOOT, + }) + const schedule = buildWindowFloorplanSchedule({ + siblings: [documented], + nodes, + levelId: level.id, + unit: 'imperial', + }) + + expect(schedule?.rows[0]?.cells).toMatchObject({ + mark: 'W01', + roughOpening: `4'-1 3/16" x 4'-2 3/8"`, + sill: `3'-0"`, + head: `7'-0"`, + }) + }) + + test('resolves explicit opening dimension documentation without inventing missing values', () => { + const { doorA, windowA } = fixture() + const roughDoor = DoorNode.parse({ + ...doorA, + dimensionReference: 'rough-opening', + roughOpeningWidth: 3.1 * FOOT, + roughOpeningHeight: 7.1 * FOOT, + }) + const missingRoughDoor = DoorNode.parse({ + ...doorA, + id: 'door_missing_ro', + dimensionReference: 'rough-opening', + }) + const masonryWindow = WindowNode.parse({ + ...windowA, + constructionType: 'masonry', + masonryOpeningWidth: 4.25 * FOOT, + masonryOpeningHeight: 4.25 * FOOT, + }) + + expect(resolveOpeningDimensionDocumentation(roughDoor)).toMatchObject({ + constructionType: 'framed', + reference: 'rough-opening', + locationPolicy: 'centerline', + prefix: 'RO', + verified: true, + width: 3.1 * FOOT, + }) + expect(resolveOpeningDimensionDocumentation(missingRoughDoor)).toMatchObject({ + reference: 'rough-opening', + prefix: 'RO', + verified: false, + width: null, + }) + expect(resolveOpeningDimensionDocumentation(masonryWindow)).toMatchObject({ + constructionType: 'masonry', + reference: 'masonry-opening', + locationPolicy: 'edge-to-edge', + prefix: 'MO', + verified: true, + width: 4.25 * FOOT, + }) + }) + + test('warns about duplicate manually assigned marks', () => { + const { doorA, doorB, level, nodes } = fixture() + const schedule = buildDoorFloorplanSchedule({ + siblings: [ + DoorNode.parse({ ...doorA, mark: 'A1' }), + DoorNode.parse({ ...doorB, mark: 'a1' }), + ], + nodes, + levelId: level.id, + unit: 'imperial', + }) + + expect(schedule?.issues).toEqual(['Duplicate door mark A1 (2 instances)']) + }) + + test('places the opening mark tag on the interior face of an exterior wall', () => { + const { doorA, nodes, wall } = fixture() + const levelData = computeDoorFloorplanLevelData({ siblings: [doorA], nodes }) + const annotation = buildOpeningMarkAnnotation(doorA, wall, levelData) + + expect(annotation?.kind).toBe('group') + if (annotation?.kind !== 'group') return + const tag = annotation.children.find((child) => child.kind === 'text') + expect(tag).toMatchObject({ kind: 'text', text: '101', x: 3 }) + expect(tag?.kind === 'text' ? tag.y : null).toBeLessThan(0) + }) +}) diff --git a/packages/nodes/src/shared/opening-documentation.ts b/packages/nodes/src/shared/opening-documentation.ts new file mode 100644 index 00000000..71d7b4a9 --- /dev/null +++ b/packages/nodes/src/shared/opening-documentation.ts @@ -0,0 +1,413 @@ +import type { + AnyNode, + DoorNode, + FloorplanGeometry, + LevelNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { type FloorplanSchedule, withFloorplanGeometryMetadata } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionLinearUnit, + formatConstructionLength, +} from './construction-length' + +type OpeningNode = DoorNode | WindowNode +type OpeningKind = OpeningNode['type'] + +export type OpeningConstructionType = 'framed' | 'masonry' +export type OpeningDimensionReference = + | 'nominal' + | 'rough-opening' + | 'masonry-opening' + | 'finish-opening' + +export type OpeningDimensionDocumentation = { + constructionType: OpeningConstructionType + reference: OpeningDimensionReference + locationPolicy: 'centerline' | 'edge-to-edge' + width: number | null + height: number | null + prefix: string + verified: boolean +} + +export type OpeningFloorplanLevelData = { + markById: ReadonlyMap +} + +type MarkResolution = OpeningFloorplanLevelData & { + issues: readonly string[] +} + +export function computeDoorFloorplanLevelData(args: { + siblings: ReadonlyArray + nodes: Record +}): OpeningFloorplanLevelData { + return resolveOpeningMarks(args.siblings, args.nodes, 'door') +} + +export function computeWindowFloorplanLevelData(args: { + siblings: ReadonlyArray + nodes: Record +}): OpeningFloorplanLevelData { + return resolveOpeningMarks(args.siblings, args.nodes, 'window') +} + +export function buildDoorFloorplanSchedule(args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: string + unit: ConstructionLinearUnit + profile?: ConstructionLengthProfile +}): FloorplanSchedule | null { + if (args.siblings.length === 0) return null + const marks = resolveOpeningMarks(args.siblings, args.nodes, 'door', args.levelId) + return { + id: 'doors', + title: 'DOOR SCHEDULE', + columns: [ + { key: 'mark', label: 'MARK', weight: 0.65 }, + { key: 'type', label: 'TYPE', weight: 1.25 }, + { key: 'size', label: 'NOMINAL SIZE', weight: 1.35 }, + { key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 }, + { key: 'operation', label: 'OPERATION', weight: 1.35 }, + { key: 'frame', label: 'FRAME T / D', weight: 1.25 }, + { key: 'hardware', label: 'HARDWARE', weight: 1.35 }, + ], + rows: args.siblings.map((door) => ({ + id: door.id, + cells: { + mark: marks.markById.get(door.id) ?? '—', + type: door.openingKind === 'opening' ? 'Opening' : titleCase(door.doorType), + size: formatSize(door.width, door.height, args.unit, args.profile ?? 'document'), + roughOpening: formatRoughOpening(door, args.unit, args.profile ?? 'document'), + operation: doorOperation(door), + frame: `${formatConstructionLength(door.frameThickness, args.unit, args.profile ?? 'document')} / ${formatConstructionLength(door.frameDepth, args.unit, args.profile ?? 'document')}`, + hardware: doorHardware(door), + }, + })), + issues: marks.issues, + } +} + +export function buildWindowFloorplanSchedule(args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: string + unit: ConstructionLinearUnit + profile?: ConstructionLengthProfile +}): FloorplanSchedule | null { + if (args.siblings.length === 0) return null + const marks = resolveOpeningMarks(args.siblings, args.nodes, 'window', args.levelId) + return { + id: 'windows', + title: 'WINDOW SCHEDULE', + columns: [ + { key: 'mark', label: 'MARK', weight: 0.65 }, + { key: 'type', label: 'TYPE', weight: 1.2 }, + { key: 'size', label: 'NOMINAL SIZE', weight: 1.35 }, + { key: 'roughOpening', label: 'ROUGH OPENING', weight: 1.35 }, + { key: 'sill', label: 'SILL', weight: 0.9 }, + { key: 'head', label: 'HEAD', weight: 0.9 }, + { key: 'operation', label: 'OPERATION', weight: 1.35 }, + ], + rows: args.siblings.map((window) => ({ + id: window.id, + cells: { + mark: marks.markById.get(window.id) ?? '—', + type: window.openingKind === 'opening' ? 'Opening' : titleCase(window.windowType), + size: formatSize(window.width, window.height, args.unit, args.profile ?? 'document'), + roughOpening: formatRoughOpening(window, args.unit, args.profile ?? 'document'), + sill: formatConstructionLength( + Math.max(0, window.position[1] - window.height / 2), + args.unit, + args.profile ?? 'document', + ), + head: formatConstructionLength( + window.position[1] + window.height / 2, + args.unit, + args.profile ?? 'document', + ), + operation: windowOperation(window), + }, + })), + issues: marks.issues, + } +} + +export function buildOpeningMarkAnnotation( + opening: OpeningNode, + wall: WallNode, + levelData: OpeningFloorplanLevelData | undefined, + { + preferredSide = -1, + stroke = '#334155', + }: { + preferredSide?: -1 | 1 + stroke?: string + } = {}, +): FloorplanGeometry | null { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength < 1e-6) return null + + const dirX = dx / wallLength + const dirZ = dz / wallLength + const normalX = -dirZ + const normalZ = dirX + const side = interiorSide(wall, preferredSide) + const openingCenterX = wall.start[0] + dirX * opening.position[0] + const openingCenterZ = wall.start[1] + dirZ * opening.position[0] + const halfDepth = (wall.thickness ?? 0.1) / 2 + const bubbleOffset = halfDepth + 0.5 + const bubbleX = openingCenterX + normalX * bubbleOffset * side + const bubbleZ = openingCenterZ + normalZ * bubbleOffset * side + const explicitMark = opening.mark?.trim() + const mark = levelData?.markById.get(opening.id) ?? (explicitMark || fallbackMark(opening)) + const bubbleWidth = Math.max(0.38, mark.length * 0.105 + 0.18) + const bubbleHeight = 0.32 + const leaderEndOffset = bubbleOffset - bubbleHeight / 2 + + return withFloorplanGeometryMetadata( + { + kind: 'group', + children: [ + { + kind: 'line', + x1: openingCenterX + normalX * halfDepth * side, + y1: openingCenterZ + normalZ * halfDepth * side, + x2: openingCenterX + normalX * leaderEndOffset * side, + y2: openingCenterZ + normalZ * leaderEndOffset * side, + stroke, + strokeWidth: 0.018, + }, + { + kind: 'rect', + x: bubbleX - bubbleWidth / 2, + y: bubbleZ - bubbleHeight / 2, + width: bubbleWidth, + height: bubbleHeight, + rx: bubbleHeight / 2, + ry: bubbleHeight / 2, + fill: '#ffffff', + stroke, + strokeWidth: 0.02, + }, + { + kind: 'text', + x: bubbleX, + y: bubbleZ, + text: mark, + fontSize: 0.15, + fill: stroke, + fontWeight: 700, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + textAnchor: 'middle', + dominantBaseline: 'middle', + upright: true, + }, + ], + }, + { annotationRole: 'opening-mark' }, + ) +} + +export function resolveOpeningDimensionDocumentation( + opening: OpeningNode, +): OpeningDimensionDocumentation { + const constructionType = opening.constructionType ?? 'framed' + const requestedReference = + constructionType === 'masonry' && + opening.dimensionReference === 'nominal' && + opening.masonryOpeningWidth !== undefined + ? 'masonry-opening' + : (opening.dimensionReference ?? 'nominal') + + const dimensions = openingDocumentationDimensions(opening, requestedReference) + + return { + constructionType, + reference: requestedReference, + locationPolicy: constructionType === 'masonry' ? 'edge-to-edge' : 'centerline', + width: dimensions.width, + height: dimensions.height, + prefix: openingDimensionPrefix(requestedReference), + verified: requestedReference === 'nominal' || dimensions.width !== null, + } +} + +function resolveOpeningMarks( + openings: ReadonlyArray, + nodes: Readonly>, + kind: OpeningKind, + explicitLevelId?: string, +): MarkResolution { + const markById = new Map() + const explicitMarks = new Map() + const used = new Set() + + for (const opening of openings) { + const mark = opening.mark?.trim() + if (!mark) continue + markById.set(opening.id, mark) + used.add(mark.toLocaleUpperCase()) + const normalized = mark.toLocaleUpperCase() + const ids = explicitMarks.get(normalized) + if (ids) ids.push(opening.id) + else explicitMarks.set(normalized, [opening.id]) + } + + const level = resolveLevel(openings[0], nodes, explicitLevelId) + let sequence = 1 + for (const opening of openings) { + if (markById.has(opening.id)) continue + let candidate = automaticMark(kind, level?.level ?? 0, sequence) + while (used.has(candidate.toLocaleUpperCase())) { + sequence++ + candidate = automaticMark(kind, level?.level ?? 0, sequence) + } + markById.set(opening.id, candidate) + used.add(candidate.toLocaleUpperCase()) + sequence++ + } + + const issues = [...explicitMarks.entries()] + .filter(([, ids]) => ids.length > 1) + .map(([mark, ids]) => `Duplicate ${kind} mark ${mark} (${ids.length} instances)`) + + return { markById, issues } +} + +function resolveLevel( + opening: OpeningNode | undefined, + nodes: Readonly>, + explicitLevelId?: string, +): LevelNode | undefined { + const explicit = explicitLevelId ? nodes[explicitLevelId] : undefined + if (explicit?.type === 'level') return explicit + + let current: AnyNode | undefined = opening + const visited = new Set() + while (current?.parentId && !visited.has(current.parentId)) { + visited.add(current.parentId) + current = nodes[current.parentId] + if (current?.type === 'level') return current + } + return undefined +} + +function automaticMark(kind: OpeningKind, level: number, sequence: number): string { + if (kind === 'door') return String((Math.max(0, level) + 1) * 100 + sequence) + return `W${String(sequence).padStart(2, '0')}` +} + +function fallbackMark(opening: OpeningNode): string { + return opening.type === 'door' ? 'D?' : 'W?' +} + +function interiorSide(wall: WallNode, fallback: -1 | 1): -1 | 1 { + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return -1 + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return 1 + return fallback +} + +function formatSize( + width: number, + height: number, + unit: ConstructionLinearUnit, + profile: ConstructionLengthProfile, +): string { + return `${formatConstructionLength(width, unit, profile)} x ${formatConstructionLength(height, unit, profile)}` +} + +function formatRoughOpening( + opening: OpeningNode, + unit: ConstructionLinearUnit, + profile: ConstructionLengthProfile, +): string { + if (opening.roughOpeningWidth === undefined || opening.roughOpeningHeight === undefined) { + return 'VERIFY' + } + return formatSize(opening.roughOpeningWidth, opening.roughOpeningHeight, unit, profile) +} + +function openingDocumentationDimensions( + opening: OpeningNode, + reference: OpeningDimensionReference, +): { width: number | null; height: number | null } { + switch (reference) { + case 'nominal': + return { width: opening.width, height: opening.height } + case 'rough-opening': + return { + width: opening.roughOpeningWidth ?? null, + height: opening.roughOpeningHeight ?? null, + } + case 'masonry-opening': + return { + width: opening.masonryOpeningWidth ?? null, + height: opening.masonryOpeningHeight ?? null, + } + case 'finish-opening': + return { + width: opening.finishOpeningWidth ?? null, + height: opening.finishOpeningHeight ?? null, + } + } +} + +function openingDimensionPrefix(reference: OpeningDimensionReference): string { + switch (reference) { + case 'nominal': + return '' + case 'rough-opening': + return 'RO' + case 'masonry-opening': + return 'MO' + case 'finish-opening': + return 'FO' + } +} + +function doorOperation(door: DoorNode): string { + if (door.openingKind === 'opening') return 'None' + if (door.doorType === 'hinged') + return `${titleCase(door.hingesSide)} / ${titleCase(door.swingDirection)}` + if (door.doorType === 'sliding' || door.doorType === 'pocket' || door.doorType === 'barn') { + return `Slide ${titleCase(door.slideDirection)}` + } + return titleCase(door.doorType) +} + +function doorHardware(door: DoorNode): string { + if (door.openingKind === 'opening') return 'None' + const hardware = [] + if (door.doorCloser) hardware.push('Closer') + if (door.panicBar) hardware.push('Panic bar') + if (door.threshold) hardware.push('Threshold') + return hardware.length > 0 ? hardware.join(', ') : 'Standard' +} + +function windowOperation(window: WindowNode): string { + if (window.openingKind === 'opening') return 'None' + if (window.windowType === 'fixed') return 'Fixed' + if (window.windowType === 'casement') { + return window.casementStyle === 'french' + ? 'French casement' + : `${titleCase(window.hingesSide)} hinge` + } + if (window.windowType === 'awning' || window.windowType === 'hopper') { + return titleCase(window.awningDirection) + } + return titleCase(window.windowType) +} + +function titleCase(value: string): string { + return value + .split('-') + .map((part) => part.charAt(0).toLocaleUpperCase() + part.slice(1)) + .join(' ') +} diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index 1729e923..8d73eca7 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -15,6 +15,7 @@ import { type WallNode, } from '@pascal-app/core' import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' // Parity with `snapLocalXToNeighbors`' along-wall threshold. const SILL_SNAP_THRESHOLD_M = 0.08 @@ -94,7 +95,7 @@ export function publishOpeningGuides3D(args: { }): void { const { wall, centerS, centerY, width, toWorld } = args const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) - const wallHeight = wall.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wall, args.nodes) const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) const guides = computeOpeningGuides({ moving: { id: args.movingId, centerS, width, centerY, height: args.height }, diff --git a/packages/nodes/src/shared/opening-placement-dimensions.test.ts b/packages/nodes/src/shared/opening-placement-dimensions.test.ts new file mode 100644 index 00000000..36ac4b1a --- /dev/null +++ b/packages/nodes/src/shared/opening-placement-dimensions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core' +import { buildOpeningPlacementDimensions } from './opening-placement-dimensions' + +function dimensionTexts(geometry: FloorplanGeometry[]): string[] { + return geometry.flatMap((entry) => (entry.kind === 'dimension' ? [entry.text] : [])) +} + +function context(unit: 'metric' | 'imperial'): { + door: DoorNode + ctx: GeometryContext +} { + const door = DoorNode.parse({ + id: 'door_entry', + parentId: 'wall_main', + position: [1.8288, 1.05, 0], + width: 0.6096, + }) + const wall = WallNode.parse({ + id: 'wall_main', + parentId: 'level_main', + children: [door.id], + start: [0, 0], + end: [3.6576, 0], + thickness: 0.2, + }) + + return { + door, + ctx: { + resolve: (id) => (id === door.id ? door : undefined), + children: [door], + siblings: [], + parent: wall, + viewState: { + selected: true, + unit, + highlighted: false, + hovered: false, + moving: true, + palette: { + selectedStroke: '#f97316', + hoveredStroke: '#fb923c', + wallHoverStroke: '#fb923c', + handleFill: '#ffffff', + handleStroke: '#f97316', + }, + }, + }, + } +} + +describe('buildOpeningPlacementDimensions', () => { + test('formats temporary placement clearances using the live metric preference', () => { + const { door, ctx } = context('metric') + + expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual(['1.52m', '1.52m']) + }) + + test('formats temporary placement clearances using the live imperial preference', () => { + const { door, ctx } = context('imperial') + + expect(dimensionTexts(buildOpeningPlacementDimensions(door, ctx))).toEqual([`5'-0"`, `5'-0"`]) + }) +}) diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 08fedd62..807728a1 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -8,9 +8,13 @@ import { type GeometryContext, isCurvedWall, type OpeningSpan, + useScene, type WallNode, type WindowNode, } from '@pascal-app/core' +import { readFloorplanContext } from '@pascal-app/editor' +import { formatConstructionLength } from './construction-length' +import { resolveWallOpeningCeiling } from './wall-opening-ceiling' /** * Build placement-measurement dimension lines for a door / window @@ -64,7 +68,8 @@ export function buildOpeningPlacementDimensions( z1 + dirZ * along + outwardNormal[1] * halfThickness, ] const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along] - const round = (value: number) => Number.parseFloat(value.toFixed(2)) + const unit = ctx.viewState?.unit ?? 'metric' + const metricNotation = readFloorplanContext(ctx).metricNotation // This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes // same-kind nodes; doors and windows need each other, so resolve the wall's @@ -94,7 +99,10 @@ export function buildOpeningPlacementDimensions( height: opening.height, }, siblings, - wall: { length: wallLength, height: wall.height ?? 2.5 }, + wall: { + length: wallLength, + height: resolveWallOpeningCeiling(wall, useScene.getState().nodes), + }, // The 2D plan is top-down: sill/head height and vertical alignment aren't // representable here — those belong to the 3D viewport. includeVertical: false, @@ -113,7 +121,7 @@ export function buildOpeningPlacementDimensions( offsetNormal: outwardNormal, offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, extensionOvershoot: 0.12, - text: `${round(gap.distance)}m`, + text: formatConstructionLength(gap.distance, unit, 'editor', { metricNotation }), stroke: '#f97316', }) } @@ -121,7 +129,9 @@ export function buildOpeningPlacementDimensions( // Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline. if (guides.equalSpacing) { const wallAngle = Math.atan2(dz, dx) - const text = `${round(guides.equalSpacing.gap)}m` + const text = formatConstructionLength(guides.equalSpacing.gap, unit, 'editor', { + metricNotation, + }) for (const seg of guides.equalSpacing.segments) { out.push({ kind: 'equal-spacing-badge', diff --git a/packages/nodes/src/shared/wall-opening-ceiling.ts b/packages/nodes/src/shared/wall-opening-ceiling.ts new file mode 100644 index 00000000..e6465b96 --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-ceiling.ts @@ -0,0 +1,64 @@ +import { + type AnyNode, + type AnyNodeId, + getWallPlaneTop, + resolveWallEffectiveHeight, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' + +/** + * Structural subset of `SceneApi` the opening-cap readers need — matches + * both handle-descriptor callbacks (which receive the full SceneApi) and + * tools holding a nodes snapshot. + */ +export type WallCeilingSceneReader = { + get: (id: AnyNodeId) => unknown + nodes: () => Readonly> +} + +/** + * Available wall-local Y span for an opening hosted on `wall`: the wall's + * resolved top (storey plane for plane-bound walls, stored height for + * explicit ones) minus the wall's elected slab base. Wall-local Y = 0 sits + * at the elected base (where the viewer positions the wall mesh), so this + * is the ceiling an opening's top edge must stay under. + * + * Uses the same slab election as the viewer's WallSystem + * (`spatialGridManager.getSlabSupportForWall`) so the cap agrees with the + * rendered wall; headless callers with an empty spatial grid elect base 0 + * and fall back to the full storey height. + */ +export function resolveWallOpeningCeiling( + wall: WallNode, + nodes: Readonly>, +): number { + const levelId = wall.parentId ?? 'default' + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wall.start, + wall.end, + wall.curveOffset ?? 0, + wall.thickness, + wall.supportSlabId, + ) + // Covering-clamped plane: openings cap under a flush/thick slab from the + // level above, matching the shortened wall body. + const planeTop = getWallPlaneTop(wall, levelId, nodes as Record) + return resolveWallEffectiveHeight(wall, planeTop, support.elevation) +} + +/** + * Height cap for a wall-hosted opening's resize handles. Infinity only when + * the opening is unhosted (no wallId, or the wall is gone) — roof-hosted + * openings clamp elsewhere. + */ +export function readHostWallCeiling( + wallId: string | null | undefined, + scene: WallCeilingSceneReader, +): number { + if (!wallId) return Number.POSITIVE_INFINITY + const wall = scene.get(wallId as AnyNodeId) as WallNode | undefined + if (!wall) return Number.POSITIVE_INFINITY + return resolveWallOpeningCeiling(wall, scene.nodes()) +} diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index de4dc94d..11300729 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, ShelfNode, useScene, } from '@pascal-app/core' @@ -132,9 +133,14 @@ const ShelfTool = () => { name: 'Shelf', position, rotation: [0, 0, 0], + parentId: activeLevelId, }) - useScene.getState().createNode(shelf, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [shelf.id] }) + const committedShelf = ShelfNode.parse({ + ...shelf, + ...resolveSupportSlabPatch(shelf, useScene.getState().nodes), + }) + useScene.getState().createNode(committedShelf, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [committedShelf.id] }) triggerSFX('sfx:item-place') useAlignmentGuides.getState().clear() if (useEditor.getState().getContinuation('point') === 'repeat') { diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts index 92f28a7d..92bff797 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.test.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.test.ts @@ -9,6 +9,7 @@ describe('getRecessedSlabGroundHoles', () => { id: 'slab_ground-holes', parentId, elevation: -0.15, + recessed: true, polygon: [ [0, 0], [2, 0], @@ -51,4 +52,21 @@ describe('getRecessedSlabGroundHoles', () => { expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) }) + + test('keys on the recessed flag, not the elevation sign', () => { + // A below-plane SOLID (deck underside) must not punch a ground hole. + const slab = SlabNode.parse({ + id: 'slab_ground-holes-below-plane', + elevation: -0.15, + thickness: 0.3, + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + + expect(getRecessedSlabGroundHoles({ [slab.id]: slab })).toEqual([]) + }) }) diff --git a/packages/nodes/src/site/recessed-slab-ground-holes.ts b/packages/nodes/src/site/recessed-slab-ground-holes.ts index e14a38f5..77f5e6c3 100644 --- a/packages/nodes/src/site/recessed-slab-ground-holes.ts +++ b/packages/nodes/src/site/recessed-slab-ground-holes.ts @@ -36,10 +36,7 @@ export function getRecessedSlabGroundHoles( return nodeList .filter( (node): node is SlabNode => - node.type === 'slab' && - node.visible && - node.polygon.length >= 3 && - (node.elevation ?? 0.05) < 0, + node.type === 'slab' && node.visible && node.polygon.length >= 3 && node.recessed === true, ) .filter((slab) => { if (!Number.isFinite(lowestLevelIndex)) return true diff --git a/packages/nodes/src/slab/__tests__/definition.test.ts b/packages/nodes/src/slab/__tests__/definition.test.ts index e66434f0..b625c187 100644 --- a/packages/nodes/src/slab/__tests__/definition.test.ts +++ b/packages/nodes/src/slab/__tests__/definition.test.ts @@ -45,7 +45,7 @@ describe('slabDefinition handles', () => { expect(pointInPolygon2D([x, z], slab.holes[0]!, { includeBoundary: true })).toBe(false) }) - test('allows the elevation arrow to cross zero into a recessed slab', () => { + test('routes the elevation arrow through adaptive slab top changes', () => { const slab = SlabNode.parse({ elevation: 0.05, polygon: [ @@ -58,6 +58,24 @@ describe('slabDefinition handles', () => { const heightHandle = getHeightHandle(slab) expect(heightHandle.min).toBe(-1) - expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ elevation: -0.15 }) + // Crossing zero flips the recessed intent in the same patch; coming back + // above the plane clears it. + expect(heightHandle.apply(slab, -0.15, {} as never)).toEqual({ + elevation: -0.15, + recessed: true, + }) + expect(heightHandle.apply(slab, 0.1, {} as never)).toEqual({ + elevation: 0.1, + thickness: 0.1, + recessed: false, + }) + // The arrow is the drag surface: past SLAB_UNSTICK_THRESHOLD a + // grounded slab pops to the default deck thickness instead of + // stretching further. + expect(heightHandle.apply(slab, 0.6, {} as never)).toEqual({ + elevation: 0.6, + thickness: 0.05, + recessed: false, + }) }) }) diff --git a/packages/nodes/src/slab/__tests__/elevation-limit.test.ts b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts new file mode 100644 index 00000000..7a829488 --- /dev/null +++ b/packages/nodes/src/slab/__tests__/elevation-limit.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test' +import { SlabNode } from '@pascal-app/core' +import { + applySlabElevationPreset, + applySlabTopChange, + SLAB_UNSTICK_THRESHOLD, +} from '../elevation-limit' + +function slab(overrides: Partial = {}): SlabNode { + return SlabNode.parse({ polygon: [], ...overrides }) +} + +const drag = (node: SlabNode, newTop: number) => applySlabTopChange(node, newTop, { mode: 'drag' }) +const panel = (node: SlabNode, newTop: number) => + applySlabTopChange(node, newTop, { mode: 'panel' }) + +describe('applySlabTopChange — drag (viewport arrow)', () => { + test('stretches a grounded slab up to the unstick threshold', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.25)).toEqual({ + elevation: 0.25, + thickness: 0.25, + recessed: false, + }) + expect(drag(grounded, 0.04)).toEqual({ + elevation: 0.04, + thickness: 0.04, + recessed: false, + }) + // The threshold itself still stretches — unstick starts strictly past it. + expect(drag(grounded, SLAB_UNSTICK_THRESHOLD)).toEqual({ + elevation: SLAB_UNSTICK_THRESHOLD, + thickness: SLAB_UNSTICK_THRESHOLD, + recessed: false, + }) + }) + + test('unsticks past the threshold: pops to the default deck thickness', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(drag(grounded, 0.55)).toEqual({ + elevation: 0.55, + thickness: 0.05, + recessed: false, + }) + }) + + test('crosses a grounded slab into a pool and back out', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = drag(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(drag(pool, 0.08)).toEqual({ + elevation: 0.08, + recessed: false, + }) + }) + + test('moves a floating deck and clamps its underside to ground', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(drag(floating, 0.4)).toEqual({ + elevation: 0.4, + recessed: false, + }) + + const landedChange = drag(floating, 0.1) + expect(landedChange).toEqual({ elevation: 0.2, recessed: false }) + + // Landed (underside 0) → grounded again: below the threshold the way + // back up stretches, past it the slab unsticks to the default deck. + const landed = { ...floating, ...landedChange } + expect(drag(landed, 0.3)).toEqual({ + elevation: 0.3, + thickness: 0.3, + recessed: false, + }) + expect(drag(landed, 0.5)).toEqual({ + elevation: 0.5, + thickness: 0.05, + recessed: false, + }) + }) + + test('keeps a recessed pool thickness unchanged', () => { + const pool = slab({ elevation: -0.15, thickness: 0.08, recessed: true }) + + expect(drag(pool, -0.3)).toEqual({ + elevation: -0.3, + recessed: true, + }) + }) + + test('allows a grounded stretch below the edit-time minimum thickness', () => { + const grounded = slab({ elevation: 0.01, thickness: 0.01 }) + + expect(drag(grounded, 0.015)).toEqual({ + elevation: 0.015, + thickness: 0.015, + recessed: false, + }) + }) +}) + +describe('applySlabTopChange — panel (pure placement)', () => { + test('moves a grounded slab without coupling thickness', () => { + // The panel never stretches: raising a grounded slab lifts the body + // (thickness preserved by omission) instead of thickening it. + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + + expect(panel(grounded, 0.3)).toEqual({ elevation: 0.3, recessed: false }) + expect(panel(grounded, 0.55)).toEqual({ elevation: 0.55, recessed: false }) + }) + + test('clamps a grounded slab at underside 0 instead of shrinking it', () => { + const grounded = slab({ elevation: 0.2, thickness: 0.2 }) + + expect(panel(grounded, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('moves a floating deck preserving thickness and clamps its underside', () => { + const floating = slab({ elevation: 0.5, thickness: 0.2 }) + + expect(panel(floating, 0.4)).toEqual({ elevation: 0.4, recessed: false }) + expect(panel(floating, 0.1)).toEqual({ elevation: 0.2, recessed: false }) + }) + + test('keeps the pool cross-zero gesture', () => { + const grounded = slab({ elevation: 0.1, thickness: 0.1 }) + const intoPool = panel(grounded, -0.15) + + expect(intoPool).toEqual({ elevation: -0.15, recessed: true }) + + const pool = { ...grounded, ...intoPool } + expect(panel(pool, -0.3)).toEqual({ elevation: -0.3, recessed: true }) + expect(panel(pool, 0.08)).toEqual({ elevation: 0.08, recessed: false }) + }) +}) + +test('slab elevation presets keep their explicit writes', () => { + expect(applySlabElevationPreset(-0.15)).toEqual({ elevation: -0.15, recessed: true }) + expect(applySlabElevationPreset(0)).toEqual({ + elevation: 0, + thickness: 0, + recessed: false, + }) + expect(applySlabElevationPreset(0.05)).toEqual({ + elevation: 0.05, + thickness: 0.05, + recessed: false, + }) + expect(applySlabElevationPreset(0.15)).toEqual({ + elevation: 0.15, + thickness: 0.15, + recessed: false, + }) +}) diff --git a/packages/nodes/src/slab/__tests__/geometry.test.ts b/packages/nodes/src/slab/__tests__/geometry.test.ts index 411d051f..82ca0814 100644 --- a/packages/nodes/src/slab/__tests__/geometry.test.ts +++ b/packages/nodes/src/slab/__tests__/geometry.test.ts @@ -28,4 +28,31 @@ describe('buildSlabGeometry', () => { expect(Array.from(uv2.array)).toEqual(Array.from(uv.array)) } }) + + test('solid slab meshes stay at the level plane; recessed meshes sink to the elevation', () => { + const polygon: Array<[number, number]> = [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ] + + const solid = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon }) + const solidGroup = buildSlabGeometry(solid, undefined, 'solid', false) + for (const mesh of solidGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + )) { + expect(mesh.position.y).toBe(0) + } + + const recessed = SlabNode.parse({ elevation: -0.2, recessed: true, polygon }) + const recessedGroup = buildSlabGeometry(recessed, undefined, 'solid', false) + const recessedMeshes = recessedGroup.children.filter( + (child): child is Mesh => child instanceof Mesh, + ) + expect(recessedMeshes.length).toBeGreaterThan(0) + for (const mesh of recessedMeshes) { + expect(mesh.position.y).toBeCloseTo(-0.2) + } + }) }) diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index 73684664..d44d2b07 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -5,6 +5,7 @@ import { type SlabNode as SlabNodeType, } from '@pascal-app/core' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' +import { applySlabTopChange, slabElevationUpperBound } from './elevation-limit' import { buildSlabFloorplan } from './floorplan' import { slabAddVertexAffordance, @@ -91,19 +92,23 @@ function slabHandleAnchor(slab: SlabNodeType): [number, number] { return best ?? fallback } -// Slab height arrow — vertical chevron on solid slab surface near the -// polygon center. Drags elevation through zero: positive values extrude -// upward from ground while negative values create a recessed floor whose -// depth follows the pointer. Same registry-handle pipeline as the column -// height arrow, so live override + commit-on-release come for free. +// Slab elevation arrow — vertical chevron on solid slab surface near the +// polygon center. The shared top-change policy stretches grounded slabs up +// to SLAB_UNSTICK_THRESHOLD (past it the slab pops to a thin floating +// deck), moves floating slabs, and preserves the drag-through-zero pool +// gesture. Same registry-handle pipeline as the column height arrow, so +// live override + commit-on-release come for free. `max` clamps the drag +// under the storey plane while plane-bound walls elect this slab as their +// base. function slabHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', axis: 'y', anchor: 'min', min: MIN_SLAB_ELEVATION, + max: (n, sceneApi) => slabElevationUpperBound(sceneApi.nodes(), n), currentValue: (n) => n.elevation ?? 0.05, - apply: (_n, newValue) => ({ elevation: newValue }), + apply: (n, newValue) => applySlabTopChange(n, newValue, { mode: 'drag' }), placement: { position: (n) => { const [cx, cz] = slabHandleAnchor(n) @@ -151,6 +156,8 @@ export const slabDefinition: NodeDefinition = { holes: [], holeMetadata: [], elevation: 0.05, + thickness: 0.05, + recessed: false, autoFromWalls: false, }), diff --git a/packages/nodes/src/slab/elevation-limit.ts b/packages/nodes/src/slab/elevation-limit.ts new file mode 100644 index 00000000..bc335b68 --- /dev/null +++ b/packages/nodes/src/slab/elevation-limit.ts @@ -0,0 +1,119 @@ +import { + type AnyNode, + type AnyNodeId, + clampSlabElevationForWalls, + getSlabElevationUpperBound, + getStoredLevelHeight, + type LevelNode, + type SlabElevationClamp, + type SlabNode, + type WallNode, +} from '@pascal-app/core' + +type SlabLevelContext = { + storeyHeight: number + walls: WallNode[] + slabs: SlabNode[] +} + +const GROUNDED_SLAB_EPSILON = 1e-3 +/** Deck thickness an unsticking slab pops to — the schema default. */ +const UNSTUCK_DECK_THICKNESS = 0.05 +/** + * Grounded-stretch ceiling for the 3D elevation arrow (m) — above any + * plausible step/platform height. While grounded, dragging the top up to + * here stretches the body; dragging past it unsticks the slab into a + * thin floating deck and the drag continues as pure placement. + */ +export const SLAB_UNSTICK_THRESHOLD = 0.4 + +export type SlabTopChangeMode = 'drag' | 'panel' + +/** + * The one owner of the slab vertical-editing rules. Both edit surfaces + * route through it: the viewport arrow as `mode: 'drag'`, the panel + * elevation input as `mode: 'panel'`. + * + * Hysteresis-free state machine (pure in current state + newTop): + * - recessed → move the pool floor; rising to ≥ 0 un-recesses. + * - grounded, newTop ≤ 0 → pool gesture (both modes). + * - grounded drag, newTop ≤ {@link SLAB_UNSTICK_THRESHOLD} → stretch + * (elevation and thickness move together, underside stays at 0). + * - grounded drag past the threshold → unstick: pop to the default deck + * thickness and continue as placement. + * - otherwise (floating, or any panel edit) → placement: move the body + * preserving thickness, clamping the underside to the level plane — + * landing re-grounds the slab, so the way back up stretches again + * below the threshold. + */ +export function applySlabTopChange( + slab: SlabNode, + newTop: number, + options: { mode: SlabTopChangeMode }, +): Partial { + if (slab.recessed) return { elevation: newTop, recessed: newTop < 0 } + + const grounded = Math.abs(slab.elevation - slab.thickness) < GROUNDED_SLAB_EPSILON + if (grounded && newTop <= 0) return { elevation: newTop, recessed: true } + + if (grounded && options.mode === 'drag') { + return newTop <= SLAB_UNSTICK_THRESHOLD + ? { elevation: newTop, thickness: newTop, recessed: false } + : { elevation: newTop, thickness: UNSTUCK_DECK_THICKNESS, recessed: false } + } + + return { elevation: Math.max(newTop, slab.thickness), recessed: false } +} + +export function applySlabElevationPreset(newTop: number): Partial { + return newTop < 0 + ? { elevation: newTop, recessed: true } + : { elevation: newTop, thickness: Math.max(newTop, 0), recessed: false } +} + +function resolveSlabLevelContext( + nodes: Readonly>, + slab: SlabNode, +): SlabLevelContext | null { + const parent = slab.parentId ? nodes[slab.parentId as AnyNodeId] : undefined + if (parent?.type !== 'level') return null + const level = parent as LevelNode + const children = level.children.map((childId) => nodes[childId as AnyNodeId]) + return { + storeyHeight: getStoredLevelHeight(level), + walls: children.filter((child): child is WallNode => child?.type === 'wall'), + slabs: children.filter((child): child is SlabNode => child?.type === 'slab'), + } +} + +/** + * Level-context wrapper over the pure core clamp: a slab under + * plane-bound walls may not rise past the storey plane minus the + * minimum wall height. Slabs outside a level (no parent) are + * unconstrained. + */ +export function clampSlabElevation( + nodes: Readonly>, + slab: SlabNode, + proposedElevation: number, +): SlabElevationClamp { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return { elevation: proposedElevation, clamped: false } + return clampSlabElevationForWalls( + proposedElevation, + slab, + context.walls, + context.slabs, + context.storeyHeight, + ) +} + +/** Drag-time upper bound for the slab height arrow; +Infinity when unconstrained. */ +export function slabElevationUpperBound( + nodes: Readonly>, + slab: SlabNode, +): number { + const context = resolveSlabLevelContext(nodes, slab) + if (!context) return Number.POSITIVE_INFINITY + return getSlabElevationUpperBound(slab, context.walls, context.slabs, context.storeyHeight) +} diff --git a/packages/nodes/src/slab/geometry.ts b/packages/nodes/src/slab/geometry.ts index 69d8e1fa..59195788 100644 --- a/packages/nodes/src/slab/geometry.ts +++ b/packages/nodes/src/slab/geometry.ts @@ -209,7 +209,10 @@ export function buildSlabGeometry( mesh.castShadow = true mesh.receiveShadow = true mesh.userData.slotId = slotId - if (elevation < 0) mesh.position.y = elevation + // Solid slabs bake [elevation − thickness, elevation] into the geometry; + // recessed shells are authored at local Y=0 and sink so the recess floor + // sits at `elevation` (< 0) with the shell rim on the level plane. + if (node.recessed) mesh.position.y = elevation group.add(mesh) } return group diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index 6587a589..cb73a8be 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, MIN_SLAB_THICKNESS, type SlabNode, useScene } from '@pascal-app/core' import { ActionButton, ActionGroup, @@ -16,6 +16,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect, useRef } from 'react' +import { applySlabElevationPreset, applySlabTopChange, clampSlabElevation } from './elevation-limit' /** * Phase 5 Stage E — slab inspector (kind-owned). @@ -30,6 +31,7 @@ import { useCallback, useEffect, useRef } from 'react' */ export function SlabPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const unit = useViewer((s) => s.unit) const setSelection = useViewer((s) => s.setSelection) const editingHole = useEditingHole() const setMovingNode = useEditor((s) => s.setMovingNode) @@ -52,6 +54,33 @@ export function SlabPanel() { [selectedId], ) + const handleElevationChange = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabTopChange(current, elevation, { mode: 'panel' })) + }, + [handleUpdate], + ) + + const handleThicknessChange = useCallback( + (proposed: number) => { + handleUpdate({ thickness: Math.max(MIN_SLAB_THICKNESS, proposed) }) + }, + [handleUpdate], + ) + + const handleElevationPreset = useCallback( + (proposed: number) => { + const current = nodeRef.current + if (!current) return + const { elevation } = clampSlabElevation(useScene.getState().nodes, current, proposed) + handleUpdate(applySlabElevationPreset(elevation)) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) useInteractionScope @@ -162,6 +191,23 @@ export function SlabPanel() { const area = calculateArea(node.polygon) + // Clean preset values per display system; imperial stores exact meters + // for whole-inch offsets. + const elevationPresets = + unit === 'imperial' + ? [ + { label: 'Sunken (-6")', elevation: -0.1524 }, + { label: 'Ground (0")', elevation: 0 }, + { label: 'Raised (+2")', elevation: 0.0508 }, + { label: 'Step (+6")', elevation: 0.1524 }, + ] + : [ + { label: 'Sunken (-15cm)', elevation: -0.15 }, + { label: 'Ground (0m)', elevation: 0 }, + { label: 'Raised (+5cm)', elevation: 0.05 }, + { label: 'Step (+15cm)', elevation: 0.15 }, + ] + return ( handleUpdate({ elevation: v })} + onChange={handleElevationChange} precision={3} step={0.01} unit="m" value={Math.round(node.elevation * 1000) / 1000} /> + {!node.recessed && ( + + )} +
- handleUpdate({ elevation: -0.15 })} /> - handleUpdate({ elevation: 0 })} /> - handleUpdate({ elevation: 0.05 })} /> - handleUpdate({ elevation: 0.15 })} /> + {elevationPresets.map((preset) => ( + handleElevationPreset(preset.elevation)} + /> + ))}
diff --git a/packages/nodes/src/slab/parametrics.ts b/packages/nodes/src/slab/parametrics.ts index 292e0073..622a7098 100644 --- a/packages/nodes/src/slab/parametrics.ts +++ b/packages/nodes/src/slab/parametrics.ts @@ -1,4 +1,4 @@ -import type { ParametricDescriptor } from '@pascal-app/core' +import { MIN_SLAB_THICKNESS, type ParametricDescriptor } from '@pascal-app/core' import type { SlabNode } from './schema' /** @@ -15,7 +15,18 @@ export const slabParametrics: ParametricDescriptor = { groups: [ { label: 'Elevation', - fields: [{ key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }], + fields: [ + { key: 'elevation', kind: 'number', unit: 'm', min: -1, max: 1, step: 0.01 }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: MIN_SLAB_THICKNESS, + max: 0.5, + step: 0.01, + visibleIf: (n) => !n.recessed, + }, + ], }, ], customPanel: () => import('./panel'), diff --git a/packages/nodes/src/slab/placement-ownership.test.ts b/packages/nodes/src/slab/placement-ownership.test.ts new file mode 100644 index 00000000..4fca8f39 --- /dev/null +++ b/packages/nodes/src/slab/placement-ownership.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' +import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership' + +function slabCreatorCount(viewMode: '2d' | '3d' | 'split', trigger: SlabCompletionTrigger): number { + const floorplanCommits = viewMode === '2d' && trigger === 'grid' + const registryCommits = shouldRegistryCommitSlab(viewMode, trigger) + return Number(floorplanCommits) + Number(registryCommits) +} + +describe('slab placement ownership', () => { + test.each([ + ['2d', 'grid'], + ['2d', 'keyboard'], + ['3d', 'grid'], + ['split', 'grid'], + ] as const)('commits one slab in %s from %s completion', (viewMode, trigger) => { + expect(slabCreatorCount(viewMode, trigger)).toBe(1) + }) +}) diff --git a/packages/nodes/src/slab/placement-ownership.ts b/packages/nodes/src/slab/placement-ownership.ts new file mode 100644 index 00000000..31cdb3d3 --- /dev/null +++ b/packages/nodes/src/slab/placement-ownership.ts @@ -0,0 +1,8 @@ +export type SlabCompletionTrigger = 'grid' | 'keyboard' + +export function shouldRegistryCommitSlab( + viewMode: '2d' | '3d' | 'split', + trigger: SlabCompletionTrigger, +): boolean { + return trigger === 'keyboard' || viewMode !== '2d' +} diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 660db4e0..ba54c3c1 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -24,6 +24,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' +import { type SlabCompletionTrigger, shouldRegistryCommitSlab } from './placement-ownership' import { SlabNode } from './schema' /** @@ -140,8 +141,10 @@ export const SlabTool: React.FC = () => { Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 ) { - const slabId = commitSlabDrawing(currentLevelId, points) - setSelection({ selectedIds: [slabId] }) + if (shouldRegistryCommitSlab(useEditor.getState().viewMode, 'grid')) { + const slabId = commitSlabDrawing(currentLevelId, points) + setSelection({ selectedIds: [slabId] }) + } setPoints([]) clearSlabSnapFeedback() } else { @@ -154,16 +157,18 @@ export const SlabTool: React.FC = () => { // Finish the polygon (Enter or double-click): commit once there are enough // vertices. Closing near the first vertex (in onGridClick) is the third way. - const finishDrawing = () => { + const finishDrawing = (trigger: SlabCompletionTrigger) => { if (points.length < 3) return - const slabId = commitSlabDrawing(currentLevelId, points) - setSelection({ selectedIds: [slabId] }) + if (shouldRegistryCommitSlab(useEditor.getState().viewMode, trigger)) { + const slabId = commitSlabDrawing(currentLevelId, points) + setSelection({ selectedIds: [slabId] }) + } setPoints([]) clearSlabSnapFeedback() } const onGridDoubleClick = (_event: GridEvent) => { - finishDrawing() + finishDrawing('grid') } const onCancel = () => { @@ -175,7 +180,7 @@ export const SlabTool: React.FC = () => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() - finishDrawing() + finishDrawing('keyboard') } } document.addEventListener('keydown', onKeyDown) diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index c68a5dbc..ce196c29 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -4,6 +4,7 @@ import { collectAlignmentAnchors, emitter, type GridEvent, + resolveSupportSlabPatch, SpawnNode, useScene, } from '@pascal-app/core' @@ -105,10 +106,18 @@ const SpawnTool = () => { let placedId: SpawnNode['id'] if (existingSpawnId) { + const live = useScene.getState().nodes[existingSpawnId] + const effectiveSpawn = SpawnNode.parse({ + ...live, + parentId: activeLevelId, + position: next, + rotation: 0, + }) useScene.getState().updateNode(existingSpawnId, { parentId: activeLevelId, position: next, rotation: 0, + ...resolveSupportSlabPatch(effectiveSpawn, useScene.getState().nodes), }) if (duplicates.length > 0) { useScene.getState().deleteNodes(duplicates) @@ -119,9 +128,14 @@ const SpawnTool = () => { name: 'Spawn Point', position: next, rotation: 0, + parentId: activeLevelId, }) - useScene.getState().createNode(spawn, activeLevelId) - placedId = spawn.id + const committedSpawn = SpawnNode.parse({ + ...spawn, + ...resolveSupportSlabPatch(spawn, useScene.getState().nodes), + }) + useScene.getState().createNode(committedSpawn, activeLevelId) + placedId = committedSpawn.id } useViewer.getState().setSelection({ selectedIds: [placedId] }) diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index d5431ceb..f3b34f53 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -1,12 +1,16 @@ import { + type AnyNodeId, type HandleDescriptor, type NodeDefinition, + resolveStairTotalRise, type SceneApi, StairNode as StairNodeSchema, type StairNode as StairNodeType, type StairSegmentNode, stairFootprintAABB, + useScene, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' const MIN_CURVED_RISE = 0.3 const MIN_CURVED_WIDTH = 0.4 @@ -53,10 +57,14 @@ type StairMoveBounds = { height: number } +function readTotalRise(node: StairNodeType): number { + return Math.max(resolveStairTotalRise(node, useScene.getState().nodes), 0.1) +} + function readCurvedStairGeometry(node: StairNodeType): CurvedStairGeom { const isSpiral = node.stairType === 'spiral' const stepCount = Math.max(2, Math.round(node.stepCount ?? 10)) - const totalRise = Math.max(node.totalRise ?? 2.5, 0.1) + const totalRise = readTotalRise(node) const width = Math.max(node.width ?? 1, MIN_CURVED_WIDTH) const minInnerRadius = isSpiral ? MIN_CURVED_INNER_RADIUS_SPIRAL : MIN_CURVED_INNER_RADIUS_CURVED const innerRadius = Math.max(minInnerRadius, node.innerRadius ?? 0.9) @@ -96,7 +104,7 @@ function fallbackStraightStairMoveBounds(node: StairNodeType): StairMoveBounds { maxX: width / 2, minZ: 0, maxZ: depth, - height: Math.max(node.totalRise ?? 2.5, 0.1), + height: readTotalRise(node), } } @@ -161,7 +169,7 @@ function curvedRiseHandle(): HandleDescriptor { axis: 'y', anchor: 'min', min: MIN_CURVED_RISE, - currentValue: (n) => Math.max(n.totalRise ?? 2.5, 0.1), + currentValue: readTotalRise, apply: (_n, newRise) => ({ totalRise: newRise }), placement: { position: (n) => { @@ -307,7 +315,7 @@ function stairRotateGizmoPosition(n: StairNodeType): [number, number, number] { return [radius * Math.cos(angle), g.totalRise / 2, radius * Math.sin(angle)] } const width = Math.max(n.width ?? 1, MIN_CURVED_WIDTH) - const yMid = Math.max(n.totalRise ?? 2.5, 0.1) / 2 + const yMid = readTotalRise(n) / 2 return [width / 2 + STAIR_ROTATE_CORNER_OFFSET, yMid, -STAIR_ROTATE_CORNER_OFFSET] } @@ -345,7 +353,7 @@ function stairRotateHandle(): HandleDescriptor { STAIR_ROTATE_RING_OFFSET ) }, - y: (n) => Math.max(n.totalRise ?? 2.5, 0.1) / 2, + y: (n) => readTotalRise(n) / 2, }, } } @@ -421,6 +429,12 @@ export const stairDefinition: NodeDefinition = { schemaVersion: 1, schema: StairNode, category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + linkedLevelIds: (node) => + node.toLevelId && node.toLevelId !== node.parentId ? [node.toLevelId as AnyNodeId] : [], + } satisfies FloorplanNodeExtension, + }, snapProfile: 'structural', // A footprint with a clear front: you approach a stair from the low end, // which sits on the -Z side of the run (the run ascends along +Z). Show the diff --git a/packages/nodes/src/stair/destination.test.ts b/packages/nodes/src/stair/destination.test.ts new file mode 100644 index 00000000..331a7daf --- /dev/null +++ b/packages/nodes/src/stair/destination.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'bun:test' +import { LevelNode, SlabNode, StairNode } from '@pascal-app/core' +import { getStairDestinationUpdates } from './destination' + +function makeStair(overrides: Record = {}) { + return StairNode.parse({ + id: 'stair_1', + type: 'stair', + position: [0, 0, 0], + slabOpeningMode: 'destination', + ...overrides, + }) +} + +const deck = SlabNode.parse({ + id: 'slab_deck', + type: 'slab', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + elevation: 1.25, + thickness: 0.05, +}) + +const level = LevelNode.parse({ + id: 'level_2', + type: 'level', + level: 1, + children: [], +}) + +describe('getStairDestinationUpdates', () => { + it('attaching to a deck disables the auto cutout and clears the custom rise', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, deck, deck.id) + expect(updates.deckSlabId).toBe(deck.id) + expect(updates.slabOpeningMode).toBe('none') + // The key must be PRESENT with an undefined value so the store merge + // clears the field. + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('detaching back to a level restores the placement-default cutout and follows mode', () => { + const stair = makeStair({ deckSlabId: deck.id, slabOpeningMode: 'none' }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) + + it('a plain level-to-level switch leaves rise and opening mode alone', () => { + const stair = makeStair({ totalRise: 2.0 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.toLevelId).toBe(level.id) + expect('deckSlabId' in updates && updates.deckSlabId === undefined).toBe(true) + expect('totalRise' in updates).toBe(false) + expect('slabOpeningMode' in updates).toBe(false) + }) + + it('detaching a stale deck reference still resets to follows mode', () => { + const stair = makeStair({ deckSlabId: 'slab_gone', totalRise: 1.4 }) + const updates = getStairDestinationUpdates(stair, level, level.id) + expect(updates.slabOpeningMode).toBe('destination') + expect('totalRise' in updates && updates.totalRise === undefined).toBe(true) + }) +}) diff --git a/packages/nodes/src/stair/destination.ts b/packages/nodes/src/stair/destination.ts new file mode 100644 index 00000000..41b6148e --- /dev/null +++ b/packages/nodes/src/stair/destination.ts @@ -0,0 +1,30 @@ +import type { AnyNode, StairNode } from '@pascal-app/core' + +/** + * Computes the stair patch for a destination ("To") switch. + * + * Attaching to a deck clears any explicit custom rise (the rise follows the + * deck's elevation from now on) and disables the auto cutout — a deck stair + * lands ON its destination slab, not through it. Switching a deck-attached + * stair back to a level clears both again so the rise re-derives from the + * storey height, and restores `slabOpeningMode: 'destination'` — the + * placement default (the schema default is 'none', but the stair tool places + * ordinary stairs with 'destination', so that is what a level-destination + * stair regains). Plain level-to-level switches leave rise and opening mode + * alone. + */ +export function getStairDestinationUpdates( + stair: StairNode, + target: AnyNode | undefined, + targetId: string, +): Partial { + if (target?.type === 'slab') { + return { deckSlabId: targetId, totalRise: undefined, slabOpeningMode: 'none' } + } + const updates: Partial = { toLevelId: targetId, deckSlabId: undefined } + if (stair.deckSlabId) { + updates.totalRise = undefined + updates.slabOpeningMode = 'destination' + } + return updates +} diff --git a/packages/nodes/src/stair/documentation.test.ts b/packages/nodes/src/stair/documentation.test.ts new file mode 100644 index 00000000..dad14907 --- /dev/null +++ b/packages/nodes/src/stair/documentation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from 'bun:test' +import { + type FloorplanGeometry, + type GeometryContext, + LevelNode, + StairNode, + StairSegmentNode, +} from '@pascal-app/core' +import { + buildFloorplanStairEntry, + createFloorplanContextExtensions, + readFloorplanGeometryMetadata, +} from '@pascal-app/editor' +import { + buildStairDocumentation, + resolveStairPlanDirection, + resolveStraightStairDirectionArrow, + stairPlanBreakStep, +} from './documentation' + +function context(levelId = 'level_ground', unit: 'metric' | 'imperial' = 'metric') { + return { + resolve: () => undefined, + children: [], + siblings: [], + parent: LevelNode.parse({ id: levelId }), + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit, + palette: { measurementStroke: '#123456' } as NonNullable< + GeometryContext['viewState'] + >['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext +} + +function straightFixture() { + const segment = StairSegmentNode.parse({ + id: 'sseg_flight', + segmentType: 'stair', + width: 1.2, + length: 3, + height: 2.5, + stepCount: 10, + }) + const stair = StairNode.parse({ + id: 'stair_main', + parentId: 'level_ground', + fromLevelId: 'level_ground', + toLevelId: 'level_upper', + stairType: 'straight', + railingMode: 'both', + railingHeight: 0.92, + children: [segment.id], + }) + const entry = buildFloorplanStairEntry(stair, [segment])! + return { entry, segment, stair } +} + +function annotationTexts(geometry: FloorplanGeometry[]) { + return geometry.flatMap((entry) => + entry.kind === 'text' && + readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation' + ? [entry.text] + : [], + ) +} + +describe('stair construction documentation', () => { + test('derives straight-flight direction, riser, tread, width, rail, and break annotations', () => { + const { entry, stair } = straightFixture() + const geometry = buildStairDocumentation(stair, entry, context()) + + expect(annotationTexts(geometry)).toEqual([ + 'UP', + '10 R @ 0.25m · T 0.3m · CLR W 1.2m', + 'RAIL BOTH @ 0.92m', + ]) + expect( + geometry.some( + (entry) => + entry.kind === 'polyline' && + readFloorplanGeometryMetadata(entry).annotationRole === 'stair-annotation', + ), + ).toBe(true) + }) + + test('uses DN and reverses the direction arrow on the destination level', () => { + const { entry, stair } = straightFixture() + const downArrow = resolveStraightStairDirectionArrow(entry, 'down') + + expect(resolveStairPlanDirection(stair, 'level_ground')).toBe('up') + expect(resolveStairPlanDirection(stair, 'level_upper')).toBe('down') + expect(annotationTexts(buildStairDocumentation(stair, entry, context('level_upper')))[0]).toBe( + 'DN', + ) + expect(downArrow?.polyline.at(-1)).toEqual(entry.arrow?.polyline[0]) + expect(downArrow?.head[0]).toEqual(entry.arrow?.polyline[0]) + }) + + test('derives curved-stair tread depth at the walking line', () => { + const stair = StairNode.parse({ + id: 'stair_curved', + parentId: 'level_ground', + stairType: 'curved', + width: 1.2, + innerRadius: 0.9, + sweepAngle: Math.PI / 2, + totalRise: 3, + stepCount: 12, + railingMode: 'left', + railingHeight: 1, + }) + const entry = buildFloorplanStairEntry(stair, [])! + + expect(annotationTexts(buildStairDocumentation(stair, entry, context()))).toEqual([ + '12 R @ 0.25m · T(CL) 0.2m · CLR W 1.2m', + 'UP', + 'RAIL LEFT @ 1m', + ]) + }) + + test('uses the same construction notation in imperial plans', () => { + const { entry, stair } = straightFixture() + const texts = annotationTexts( + buildStairDocumentation(stair, entry, context('level_ground', 'imperial')), + ) + + expect(texts[1]).toContain(`10 R @ 9 13/16"`) + expect(texts[1]).toContain(`CLR W 3'-11 1/4"`) + }) + + test('aligns tread visibility with the documented break position', () => { + expect(stairPlanBreakStep(10)).toBe(7) + expect(stairPlanBreakStep(15)).toBe(11) + }) +}) diff --git a/packages/nodes/src/stair/documentation.ts b/packages/nodes/src/stair/documentation.ts new file mode 100644 index 00000000..6ee25161 --- /dev/null +++ b/packages/nodes/src/stair/documentation.ts @@ -0,0 +1,349 @@ +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + type Point2D, + resolveStairTotalRise, + type StairNode, + useScene, +} from '@pascal-app/core' +import type { + FloorplanStairArrowEntry, + FloorplanStairEntry, + FloorplanStairSegmentEntry, +} from '@pascal-app/editor' +import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionMetricNotation, + formatConstructionLength, +} from '../shared/construction-length' + +const ANNOTATION_OFFSET = 0.28 +const ANNOTATION_FONT_SIZE = 0.125 +const DIRECTION_FONT_SIZE = 0.16 +const BREAK_POSITION = 0.68 +const BREAK_ZIGZAG = 0.07 +const MIN_ARROW_HEAD = 0.14 +const MAX_ARROW_HEAD = 0.24 + +export type StairPlanDirection = 'up' | 'down' + +export function resolveStairPlanDirection( + stair: StairNode, + activeLevelId: string | null | undefined, +): StairPlanDirection { + if ( + activeLevelId && + stair.toLevelId && + stair.toLevelId !== stair.fromLevelId && + activeLevelId === stair.toLevelId + ) { + return 'down' + } + return 'up' +} + +export function resolveStraightStairDirectionArrow( + entry: FloorplanStairEntry, + direction: StairPlanDirection, +): FloorplanStairArrowEntry | null { + const arrow = entry.arrow + if (!arrow || direction === 'up') return arrow + const polyline = [...arrow.polyline].reverse() + const tip = polyline[polyline.length - 1] + const tail = polyline[polyline.length - 2] + if (!(tip && tail)) return null + + const bodyLength = distance(tail, tip) + if (bodyLength <= Number.EPSILON) return null + const headLength = clamp(bodyLength * 0.72, MIN_ARROW_HEAD, MAX_ARROW_HEAD) + const directionX = (tip.x - tail.x) / bodyLength + const directionY = (tip.y - tail.y) / bodyLength + const base = { + x: tip.x - directionX * headLength, + y: tip.y - directionY * headLength, + } + const halfWidth = headLength * 0.34 + return { + polyline, + head: [ + tip, + { x: base.x - directionY * halfWidth, y: base.y + directionX * halfWidth }, + { x: base.x + directionY * halfWidth, y: base.y - directionX * halfWidth }, + ], + } +} + +export function stairPlanBreakStep(stepCount: number): number { + return Math.max(1, Math.ceil(Math.max(1, Math.round(stepCount)) * BREAK_POSITION)) +} + +export function buildStairDocumentation( + stair: StairNode, + entry: FloorplanStairEntry, + ctx: GeometryContext, +): FloorplanGeometry[] { + const activeLevelId = ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId + const direction = resolveStairPlanDirection(stair, activeLevelId) + const unit = ctx.viewState?.unit ?? 'metric' + const floorplanContext = readFloorplanContext(ctx) + const profile: ConstructionLengthProfile = + floorplanContext.purpose === 'document' ? 'document' : 'editor' + const metricNotation = floorplanContext.metricNotation + const stroke = ctx.viewState?.palette.measurementStroke ?? '#334155' + return stair.stairType === 'straight' + ? buildStraightDocumentation(stair, entry, direction, unit, profile, metricNotation, stroke) + : buildCurvedDocumentation(stair, direction, unit, profile, metricNotation, stroke) +} + +function buildStraightDocumentation( + stair: StairNode, + entry: FloorplanStairEntry, + direction: StairPlanDirection, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const geometries: FloorplanGeometry[] = [] + const arrow = resolveStraightStairDirectionArrow(entry, direction) + const arrowStart = arrow?.polyline[0] + const arrowNext = arrow?.polyline[1] + if (arrowStart && arrowNext) { + const arrowDirection = normalizedDirection(arrowStart, arrowNext) + const labelPoint = arrowDirection + ? { + x: arrowStart.x - arrowDirection.y * 0.18, + y: arrowStart.y + arrowDirection.x * 0.18, + } + : arrowStart + geometries.push( + annotationText(labelPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke), + ) + } + + let railNotePlaced = false + for (const segmentEntry of entry.segments) { + if (segmentEntry.segment.segmentType !== 'stair') continue + const frame = segmentFrame(segmentEntry) + if (!frame) continue + const segment = segmentEntry.segment + const riserCount = Math.max(1, Math.round(segment.stepCount)) + const riserHeight = segment.height / riserCount + const treadDepth = segment.length / riserCount + const rightAnchor = { + x: frame.rightMid.x + frame.widthDirection.x * ANNOTATION_OFFSET, + y: frame.rightMid.y + frame.widthDirection.y * ANNOTATION_OFFSET, + } + geometries.push( + annotationText( + rightAnchor, + `${riserCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(segment.width, unit, profile, { metricNotation })}`, + ANNOTATION_FONT_SIZE, + stroke, + ), + buildStraightBreakLine(segmentEntry, stroke), + ) + + if (!railNotePlaced && stair.railingMode !== 'none') { + const leftAnchor = { + x: frame.leftMid.x - frame.widthDirection.x * ANNOTATION_OFFSET, + y: frame.leftMid.y - frame.widthDirection.y * ANNOTATION_OFFSET, + } + geometries.push( + annotationText( + leftAnchor, + `RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`, + ANNOTATION_FONT_SIZE, + stroke, + ), + ) + railNotePlaced = true + } + } + return geometries +} + +function buildCurvedDocumentation( + stair: StairNode, + direction: StairPlanDirection, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const stairType = stair.stairType === 'spiral' ? 'spiral' : 'curved' + const stepCount = Math.max(stairType === 'spiral' ? 6 : 4, Math.round(stair.stepCount)) + const sweep = normalizedSweep(stair) + const startAngle = -stair.rotation - sweep / 2 + const endAngle = startAngle + sweep + const innerRadius = Math.max(stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius) + const outerRadius = innerRadius + stair.width + const walkingRadius = innerRadius + stair.width / 2 + const riserHeight = resolveStairTotalRise(stair, useScene.getState().nodes) / stepCount + const treadDepth = (Math.abs(sweep) * walkingRadius) / stepCount + const center = { x: stair.position[0], y: stair.position[2] } + const noteAngle = (startAngle + endAngle) / 2 + const notePoint = arcPoint(center, outerRadius + ANNOTATION_OFFSET, noteAngle) + const directionAngle = direction === 'up' ? startAngle + sweep * 0.18 : endAngle - sweep * 0.18 + const directionPoint = arcPoint(center, walkingRadius, directionAngle) + const breakAngle = startAngle + sweep * BREAK_POSITION + const geometries: FloorplanGeometry[] = [ + annotationText( + notePoint, + `${stepCount} R @ ${formatConstructionLength(riserHeight, unit, profile, { metricNotation })} · T(CL) ${formatConstructionLength(treadDepth, unit, profile, { metricNotation })} · CLR W ${formatConstructionLength(stair.width, unit, profile, { metricNotation })}`, + ANNOTATION_FONT_SIZE, + stroke, + ), + annotationText(directionPoint, direction === 'up' ? 'UP' : 'DN', DIRECTION_FONT_SIZE, stroke), + buildCurvedBreakLine(center, innerRadius, outerRadius, breakAngle, stroke), + ] + if (stair.railingMode !== 'none') { + geometries.push( + annotationText( + arcPoint(center, outerRadius + ANNOTATION_OFFSET * 2, noteAngle), + `RAIL ${stair.railingMode.toLocaleUpperCase()} @ ${formatConstructionLength(stair.railingHeight, unit, profile, { metricNotation })}`, + ANNOTATION_FONT_SIZE, + stroke, + ), + ) + } + return geometries +} + +function buildStraightBreakLine( + segmentEntry: FloorplanStairSegmentEntry, + stroke: string, +): FloorplanGeometry { + const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.innerPolygon + if (!(backLeft && backRight && frontRight && frontLeft)) { + return { kind: 'group', children: [] } + } + const left = interpolate(backLeft, frontLeft, BREAK_POSITION) + const right = interpolate(backRight, frontRight, BREAK_POSITION) + const travel = normalizedDirection(backLeft, frontLeft) ?? { x: 0, y: 1 } + return { + kind: 'polyline', + points: [ + toTuple(left), + offset(interpolate(left, right, 0.42), travel, BREAK_ZIGZAG), + offset(interpolate(left, right, 0.5), travel, -BREAK_ZIGZAG), + offset(interpolate(left, right, 0.58), travel, BREAK_ZIGZAG), + toTuple(right), + ], + fill: 'none', + stroke, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } +} + +function buildCurvedBreakLine( + center: Point2D, + innerRadius: number, + outerRadius: number, + angle: number, + stroke: string, +): FloorplanGeometry { + const radial = { x: Math.cos(angle), y: Math.sin(angle) } + const tangent = { x: -radial.y, y: radial.x } + const pointAt = (t: number, tangentOffset = 0): FloorplanPoint => { + const radius = innerRadius + (outerRadius - innerRadius) * t + return [ + center.x + radial.x * radius + tangent.x * tangentOffset, + center.y + radial.y * radius + tangent.y * tangentOffset, + ] + } + return { + kind: 'polyline', + points: [ + pointAt(0), + pointAt(0.42, BREAK_ZIGZAG), + pointAt(0.5, -BREAK_ZIGZAG), + pointAt(0.58, BREAK_ZIGZAG), + pointAt(1), + ], + fill: 'none', + stroke, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } +} + +function annotationText( + point: Point2D, + text: string, + fontSize: number, + fill: string, +): FloorplanGeometry { + return { + kind: 'text', + x: point.x, + y: point.y, + text, + fontSize, + fill, + stroke: '#ffffff', + strokeWidth: fontSize * 0.22, + paintOrder: 'stroke', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontWeight: 650, + textAnchor: 'middle', + dominantBaseline: 'central', + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), + } +} + +function segmentFrame(segmentEntry: FloorplanStairSegmentEntry) { + const [backLeft, backRight, frontRight, frontLeft] = segmentEntry.polygon + if (!(backLeft && backRight && frontRight && frontLeft)) return null + const widthDirection = normalizedDirection(backLeft, backRight) + if (!widthDirection) return null + return { + widthDirection, + leftMid: interpolate(backLeft, frontLeft, 0.5), + rightMid: interpolate(backRight, frontRight, 0.5), + } +} + +function normalizedSweep(stair: StairNode): number { + const defaultSweep = stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2 + const sweep = stair.sweepAngle ?? defaultSweep + if (Math.abs(sweep) < Math.PI * 2) return sweep + return Math.sign(sweep || 1) * (Math.PI * 2 - 0.001) +} + +function arcPoint(center: Point2D, radius: number, angle: number): Point2D { + return { x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius } +} + +function normalizedDirection(start: Point2D, end: Point2D): Point2D | null { + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.hypot(dx, dy) + return length <= Number.EPSILON ? null : { x: dx / length, y: dy / length } +} + +function interpolate(start: Point2D, end: Point2D, t: number): Point2D { + return { x: start.x + (end.x - start.x) * t, y: start.y + (end.y - start.y) * t } +} + +function offset(point: Point2D, direction: Point2D, amount: number): FloorplanPoint { + return [point.x + direction.x * amount, point.y + direction.y * amount] +} + +function toTuple(point: Point2D): FloorplanPoint { + return [point.x, point.y] +} + +function distance(first: Point2D, second: Point2D): number { + return Math.hypot(second.x - first.x, second.y - first.y) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/packages/nodes/src/stair/floorplan.test.ts b/packages/nodes/src/stair/floorplan.test.ts new file mode 100644 index 00000000..d1873e40 --- /dev/null +++ b/packages/nodes/src/stair/floorplan.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { + type FloorplanGeometry, + type GeometryContext, + LevelNode, + StairNode, + StairSegmentNode, +} from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildStairFloorplan } from './floorplan' + +function textValues(geometry: FloorplanGeometry | null) { + if (geometry?.kind !== 'group') return [] + return geometry.children.flatMap((child) => + child.kind === 'text' && + readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation' + ? [child.text] + : [], + ) +} + +describe('buildStairFloorplan documentation', () => { + test('integrates stair notes, break line, and visible treads below the break', () => { + const segment = StairSegmentNode.parse({ + id: 'sseg_main', + width: 1.2, + length: 3, + height: 2.5, + stepCount: 10, + }) + const stair = StairNode.parse({ + id: 'stair_main', + parentId: 'level_ground', + fromLevelId: 'level_ground', + toLevelId: 'level_upper', + children: [segment.id], + railingMode: 'both', + }) + const geometry = buildStairFloorplan(stair, { + resolve: () => undefined, + children: [segment], + siblings: [], + parent: LevelNode.parse({ id: 'level_ground' }), + } satisfies GeometryContext) + + expect(textValues(geometry)[0]).toBe('UP') + expect(textValues(geometry)).toContain('10 R @ 0.25m · T 0.3m · CLR W 1.2m') + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + expect( + geometry.children.some( + (child) => + child.kind === 'polyline' && + readFloorplanGeometryMetadata(child).annotationRole === 'stair-annotation', + ), + ).toBe(true) + expect( + geometry.children.filter((child) => child.kind === 'polygon' && child.fill === '#262626'), + ).toHaveLength(6) + expect(geometry.children.some((child) => 'strokeDasharray' in child)).toBe(false) + }) +}) diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts index eeabc2d3..39107d49 100644 --- a/packages/nodes/src/stair/floorplan.ts +++ b/packages/nodes/src/stair/floorplan.ts @@ -17,8 +17,15 @@ import { buildSvgAnnularSectorPath, buildSvgArcPath, buildSvgArrowHeadPoints, + floorplanGeometryMetadata, getArcPlanPoint, } from '@pascal-app/editor' +import { + buildStairDocumentation, + resolveStairPlanDirection, + resolveStraightStairDirectionArrow, + stairPlanBreakStep, +} from './documentation' /** * Stage C floor-plan emitter for stair. The stair is the parent; its @@ -105,7 +112,10 @@ export function buildStairFloorplan( // Tread bars — one per visible step inside the segment. // `buildFloorplanStairEntry` already returns the thickened // polygons; we emit them as filled polygons. - for (const treadBar of segmentEntry.treadBars) { + const breakStep = stairPlanBreakStep(segmentEntry.segment.stepCount) + for (let treadIndex = 0; treadIndex < segmentEntry.treadBars.length; treadIndex += 1) { + if (treadIndex + 1 >= breakStep) continue + const treadBar = segmentEntry.treadBars[treadIndex]! children.push({ kind: 'polygon', points: toFloorplanPoints(treadBar), @@ -256,10 +266,9 @@ export function buildStairFloorplan( const stepBase = stairType === 'spiral' ? 6 : 4 const stepCount = Math.max(stepBase, Math.round(stair.stepCount ?? 10)) const stepSweep = normalizedSweepAngle / stepCount - // For spirals only: the last ~32% of the sweep is dashed (matches - // the legacy `dashedFromIndex = Math.floor(stepCount * 0.68)`). - const dashedFromIndex = stairType === 'spiral' ? Math.floor(stepCount * 0.68) : Infinity + const breakStep = stairPlanBreakStep(stepCount) for (let index = 0; index <= stepCount; index += 1) { + if (index >= breakStep && index !== stepCount) continue const angle = sectorStartAngle + stepSweep * index const inner = getArcPlanPoint(stairCenter, innerRadius, angle) const outer = getArcPlanPoint(stairCenter, outerRadius, angle) @@ -268,8 +277,7 @@ export function buildStairFloorplan( // Curved: regular stroke everywhere, but both the starting and the // ending step lines are bolded (matches the legacy // `` curved branch). - // Spiral: only the last step is accented + bolded; intermediate - // steps past `dashedFromIndex` are dashed. + // Spiral: only the last step is accented + bolded. const isEmphasised = stairType === 'spiral' ? isLast : isFirst || isLast const stepWidth = stairType === 'spiral' ? (isEmphasised ? 1.8 : 1.15) : isEmphasised ? 1.5 : 1.1 @@ -281,7 +289,6 @@ export function buildStairFloorplan( y2: outer.y, stroke: stairType === 'spiral' && isLast ? stairAccent : stairStroke, strokeWidth: stepWidth, - strokeDasharray: index >= dashedFromIndex && !isLast ? '0.1 0.08' : undefined, vectorEffect: 'non-scaling-stroke', }) } @@ -322,9 +329,18 @@ export function buildStairFloorplan( } // 6. Direction arrow — head only, at the upper end of the sweep. - const arrowAngle = visualSectorEndAngle - stepSweep * 0.8 + const direction = resolveStairPlanDirection( + stair, + ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId, + ) + const arrowAngle = + direction === 'up' + ? visualSectorEndAngle - stepSweep * 0.8 + : sectorStartAngle + stepSweep * 0.8 const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle) - const tangentAngle = arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2) + const sweepDirection = normalizedSweepAngle >= 0 ? 1 : -1 + const tangentAngle = + arrowAngle + sweepDirection * (direction === 'up' ? Math.PI / 2 : -Math.PI / 2) const arrowSize = clamp(stair.width * (stairType === 'spiral' ? 0.18 : 0.16), 0.1, 0.18) const headPts = buildSvgArrowHeadPoints(arrowPoint, tangentAngle, arrowSize) children.push({ @@ -332,6 +348,7 @@ export function buildStairFloorplan( points: headPts.map((p) => [p.x, p.y] as FloorplanPoint), fill: stairAccent, stroke: 'none', + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), }) // 7. Resize arrows — mirror of the 3D `CurvedStairWidthArrow`, @@ -397,29 +414,38 @@ export function buildStairFloorplan( // the stair-segment chain in straight space and produces a malformed // polyline once the chain is laid around an arc. if (stairType === 'straight' && entry.arrow) { - if (entry.arrow.polyline.length >= 2) { + const direction = resolveStairPlanDirection( + stair, + ctx.parent?.type === 'level' ? ctx.parent.id : stair.parentId, + ) + const directionArrow = resolveStraightStairDirectionArrow(entry, direction) + if (directionArrow && directionArrow.polyline.length >= 2) { children.push({ kind: 'polyline', - points: toFloorplanPoints(entry.arrow.polyline), + points: toFloorplanPoints(directionArrow.polyline), fill: 'none', stroke: stairAccent, strokeWidth: 0.02, strokeLinecap: 'round', strokeLinejoin: 'round', opacity: showSelectedChrome ? 0.92 : 0.72, + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), }) } - if (entry.arrow.head.length >= 3) { + if (directionArrow && directionArrow.head.length >= 3) { children.push({ kind: 'polygon', - points: toFloorplanPoints(entry.arrow.head), + points: toFloorplanPoints(directionArrow.head), fill: stairAccent, stroke: 'none', opacity: showSelectedChrome ? 0.92 : 0.72, + metadata: floorplanGeometryMetadata({ annotationRole: 'stair-annotation' }), }) } } + children.push(...buildStairDocumentation(stair, entry, ctx)) + // Whole-stair rotation handle — sister to the 3D `stairRotateHandle` // (arc-resize, curved-arrow). 2D doesn't have a dedicated curved-arrow // primitive, so we emit a `move-arrow` with the `'stair-rotate'` diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 00297893..fae2b5ac 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -4,6 +4,8 @@ import { type AnyNode, type AnyNodeId, type LevelNode, + resolveStairTotalRise, + type SlabNode, type StairNode, type StairRailingMode, type StairSegmentNode, @@ -35,6 +37,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' +import { getStairDestinationUpdates } from './destination' const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ { label: 'None', value: 'none' }, @@ -59,6 +62,10 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode } { label: 'Destination', value: 'destination' }, ] +// Slabs at least this high off the storey floor read as decks (mezzanines) — +// lower slabs are floor coverings, never useful stair destinations. +const DECK_DESTINATION_MIN_ELEVATION = 0.5 + export default function StairPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedCount = useViewer((s) => s.selection.selectedIds.length) @@ -75,6 +82,20 @@ export default function StairPanel() { () => (node?.type === 'stair' ? getStairLevelOptions(nodes, node) : []), [node, nodes], ) + const candidateDecks = useMemo(() => { + if (node?.type !== 'stair') return [] + const level = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (level?.type !== 'level') return [] + const decks: SlabNode[] = [] + for (const childId of level.children) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'slab') continue + if (child.id === node.deckSlabId || child.elevation >= DECK_DESTINATION_MIN_ELEVATION) { + decks.push(child) + } + } + return decks + }, [node, nodes]) const segments = useScene( useShallow((s) => { if (!selectedId) return [] @@ -133,6 +154,15 @@ export default function StairPanel() { [handleUpdate], ) + const handleDestinationChange = useCallback( + (value: string) => { + if (!node) return + const target = useScene.getState().nodes[value as AnyNodeId] + handleUpdate(getStairDestinationUpdates(node, target, value)) + }, + [node, handleUpdate], + ) + const getLastSegmentFillDefaults = useCallback(() => { if (!node) return { fillToFloor: true } const children = node.children ?? [] @@ -223,6 +253,9 @@ export default function StairPanel() { const resolvedFromLevelId = resolveStairFromLevelId(nodes, node, levels) const resolvedToLevelId = resolveStairToLevelId(nodes, node, resolvedFromLevelId, levels) + const deckNode = node.deckSlabId ? nodes[node.deckSlabId as AnyNodeId] : undefined + const attachedDeck = deckNode?.type === 'slab' ? deckNode : undefined + const resolvedRise = Math.round(resolveStairTotalRise(node, nodes) * 100) / 100 return (
- + {attachedDeck ? null : ( + + )}
@@ -276,40 +311,85 @@ export default function StairPanel() {
- To Level + To
- handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} - value={node.slabOpeningMode ?? 'none'} - /> - - {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - handleUpdate({ openingOffset: value })} - precision={2} - step={0.01} - unit="m" - value={Math.round((node.openingOffset ?? 0) * 100) / 100} - /> + {attachedDeck ? ( +
+
+ Rise +
+ + handleUpdate( + value === 'custom' ? { totalRise: resolvedRise } : { totalRise: undefined }, + ) + } + options={[ + { label: 'Follows deck', value: 'follows' }, + { label: 'Custom rise', value: 'custom' }, + ]} + value={node.totalRise == null ? 'follows' : 'custom'} + /> + {node.totalRise == null ? ( +
+ Currently {resolvedRise} m +
+ ) : ( + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={resolvedRise} + /> + )} +
) : null} + {attachedDeck ? null : ( + <> + handleAutoCutoutChange(value === 'destination')} + options={STAIR_SLAB_OPENING_OPTIONS} + value={node.slabOpeningMode ?? 'none'} + /> + + {(node.slabOpeningMode ?? 'none') === 'destination' ? ( + handleUpdate({ openingOffset: value })} + precision={2} + step={0.01} + unit="m" + value={Math.round((node.openingOffset ?? 0) * 100) / 100} + /> + ) : null} + + )} + {node.stairType === 'spiral' && ( <>
@@ -389,7 +469,7 @@ export default function StairPanel() { precision={2} step={0.05} unit="m" - value={Math.round((node.totalRise ?? 2.5) * 100) / 100} + value={Math.round(resolveStairTotalRise(node, nodes) * 100) / 100} /> state.nodes) const sideMaterial = bodyMaterials[1] const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) - const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) + const totalRise = Math.max(resolveStairTotalRise(stair, nodes), 0.1) const stepHeight = totalRise / stepCount const isSpiral = stair.stairType === 'spiral' const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9) diff --git a/packages/nodes/src/structural-grid/coordination.test.ts b/packages/nodes/src/structural-grid/coordination.test.ts new file mode 100644 index 00000000..29e3f866 --- /dev/null +++ b/packages/nodes/src/structural-grid/coordination.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { StructuralGridNode } from '@pascal-app/core' +import { + collectStructuralGridAxes, + resolveStructuralGridReference, + resolveStructuralGridSnap, +} from './coordination' + +const vertical = StructuralGridNode.parse({ + id: 'structural-grid_1', + parentId: 'level_main', + start: [2, 0], + end: [2, 8], + label: '1', +}) +const horizontal = StructuralGridNode.parse({ + id: 'structural-grid_a', + parentId: 'level_main', + start: [0, 3], + end: [8, 3], + label: 'A', +}) + +describe('structural-grid coordination', () => { + test('snaps columns to a nearby grid intersection before an individual axis', () => { + expect(resolveStructuralGridSnap([2.18, 3.12], [vertical, horizontal])).toMatchObject({ + point: [2, 3], + kind: 'intersection', + reference: 'A-1', + }) + }) + + test('projects onto one axis when no intersection is within range', () => { + expect(resolveStructuralGridSnap([2.12, 6], [vertical, horizontal])).toMatchObject({ + point: [2, 6], + kind: 'line', + reference: '1', + }) + }) + + test('does not snap beyond the configured distance or past an axis endpoint', () => { + expect(resolveStructuralGridSnap([2.4, 6], [vertical, horizontal])).toBeNull() + expect(resolveStructuralGridSnap([2.05, 8.4], [vertical], 0.25)).toBeNull() + }) + + test('derives an associative alphabetic-numeric reference at the column center', () => { + expect(resolveStructuralGridReference([2, 3], [vertical, horizontal])).toBe('A-1') + expect(resolveStructuralGridReference([2, 3], [vertical, { ...horizontal, label: 'B' }])).toBe( + 'B-1', + ) + }) + + test('collects only visible axes from the active level', () => { + const hidden = StructuralGridNode.parse({ + ...horizontal, + id: 'structural-grid_hidden', + visible: false, + }) + const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal, [hidden.id]: hidden } + expect(collectStructuralGridAxes(nodes, 'level_main').map((axis) => axis.id)).toEqual([ + vertical.id, + horizontal.id, + ]) + expect(collectStructuralGridAxes(nodes, 'level_other')).toEqual([]) + }) +}) diff --git a/packages/nodes/src/structural-grid/coordination.ts b/packages/nodes/src/structural-grid/coordination.ts new file mode 100644 index 00000000..a1f30df1 --- /dev/null +++ b/packages/nodes/src/structural-grid/coordination.ts @@ -0,0 +1,149 @@ +import type { AnyNode, StructuralGridNode } from '@pascal-app/core' + +export type StructuralGridPoint = readonly [x: number, z: number] + +export type StructuralGridSnap = { + point: [number, number] + distance: number + kind: 'intersection' | 'line' + axes: StructuralGridNode[] + reference: string +} + +export const STRUCTURAL_GRID_SNAP_DISTANCE_M = 0.25 +export const STRUCTURAL_GRID_REFERENCE_TOLERANCE_M = 0.02 + +const EPSILON = 1e-9 + +export function collectStructuralGridAxes( + nodes: Readonly>, + levelId: string | null | undefined, +): StructuralGridNode[] { + if (!levelId) return [] + return Object.values(nodes).filter( + (node): node is StructuralGridNode => + node.type === 'structural-grid' && node.parentId === levelId && node.visible !== false, + ) +} + +export function formatStructuralGridReference(axes: readonly StructuralGridNode[]): string { + const labels = [...new Set(axes.map((axis) => axis.label.trim()).filter(Boolean))] + labels.sort((left, right) => { + const leftFamily = structuralGridLabelSortFamily(left) + const rightFamily = structuralGridLabelSortFamily(right) + if (leftFamily !== rightFamily) return leftFamily - rightFamily + return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' }) + }) + return labels.join('-') +} + +export function resolveStructuralGridSnap( + point: StructuralGridPoint, + axes: readonly StructuralGridNode[], + maxDistance = STRUCTURAL_GRID_SNAP_DISTANCE_M, +): StructuralGridSnap | null { + let nearestIntersection: StructuralGridSnap | null = null + + for (let firstIndex = 0; firstIndex < axes.length; firstIndex += 1) { + const first = axes[firstIndex] + if (!first) continue + for (let secondIndex = firstIndex + 1; secondIndex < axes.length; secondIndex += 1) { + const second = axes[secondIndex] + if (!second) continue + const intersection = segmentIntersection(first.start, first.end, second.start, second.end) + if (!intersection) continue + const distance = pointDistance(point, intersection) + if ( + distance > maxDistance || + (nearestIntersection && distance >= nearestIntersection.distance) + ) { + continue + } + nearestIntersection = { + point: intersection, + distance, + kind: 'intersection', + axes: [first, second], + reference: formatStructuralGridReference([first, second]), + } + } + } + + if (nearestIntersection) return nearestIntersection + + let nearestLine: StructuralGridSnap | null = null + for (const axis of axes) { + const projected = closestPointOnSegment(point, axis.start, axis.end) + const distance = pointDistance(point, projected) + if (distance > maxDistance || (nearestLine && distance >= nearestLine.distance)) continue + nearestLine = { + point: projected, + distance, + kind: 'line', + axes: [axis], + reference: formatStructuralGridReference([axis]), + } + } + return nearestLine +} + +export function resolveStructuralGridReference( + point: StructuralGridPoint, + axes: readonly StructuralGridNode[], + tolerance = STRUCTURAL_GRID_REFERENCE_TOLERANCE_M, +): string | null { + const matching = axes.filter( + (axis) => pointDistance(point, closestPointOnSegment(point, axis.start, axis.end)) <= tolerance, + ) + const reference = formatStructuralGridReference(matching) + return reference || null +} + +function structuralGridLabelSortFamily(label: string): number { + if (/^[A-Za-z]+$/.test(label)) return 0 + if (/^\d+$/.test(label)) return 1 + return 2 +} + +function pointDistance(first: StructuralGridPoint, second: StructuralGridPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +function closestPointOnSegment( + point: StructuralGridPoint, + start: StructuralGridPoint, + end: StructuralGridPoint, +): [number, number] { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= EPSILON) return [start[0], start[1]] + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + return [start[0] + dx * t, start[1] + dz * t] +} + +function segmentIntersection( + firstStart: StructuralGridPoint, + firstEnd: StructuralGridPoint, + secondStart: StructuralGridPoint, + secondEnd: StructuralGridPoint, +): [number, number] | null { + const firstDx = firstEnd[0] - firstStart[0] + const firstDz = firstEnd[1] - firstStart[1] + const secondDx = secondEnd[0] - secondStart[0] + const secondDz = secondEnd[1] - secondStart[1] + const denominator = firstDx * secondDz - firstDz * secondDx + if (Math.abs(denominator) <= EPSILON) return null + + const offsetX = secondStart[0] - firstStart[0] + const offsetZ = secondStart[1] - firstStart[1] + const firstT = (offsetX * secondDz - offsetZ * secondDx) / denominator + const secondT = (offsetX * firstDz - offsetZ * firstDx) / denominator + if (firstT < -EPSILON || firstT > 1 + EPSILON || secondT < -EPSILON || secondT > 1 + EPSILON) { + return null + } + return [firstStart[0] + firstDx * firstT, firstStart[1] + firstDz * firstT] +} diff --git a/packages/nodes/src/structural-grid/definition.test.ts b/packages/nodes/src/structural-grid/definition.test.ts new file mode 100644 index 00000000..39db6299 --- /dev/null +++ b/packages/nodes/src/structural-grid/definition.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { getFloorplanNodeExtension } from '@pascal-app/editor' +import { structuralGridDefinition } from './definition' + +describe('structuralGridDefinition', () => { + test('registers as a floor-plan-only structural annotation', () => { + expect(structuralGridDefinition.kind).toBe('structural-grid') + expect(structuralGridDefinition.bake).toBe('strip') + expect(structuralGridDefinition.dirtyTracking).toBe(false) + expect(structuralGridDefinition.floorplan).toBeFunction() + expect(structuralGridDefinition.capabilities.selectable).toBeDefined() + expect(getFloorplanNodeExtension(structuralGridDefinition)?.preferredView).toBe('2d') + }) +}) diff --git a/packages/nodes/src/structural-grid/definition.ts b/packages/nodes/src/structural-grid/definition.ts new file mode 100644 index 00000000..340531fb --- /dev/null +++ b/packages/nodes/src/structural-grid/definition.ts @@ -0,0 +1,59 @@ +import type { NodeDefinition } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { buildStructuralGridFloorplan } from './floorplan' +import { StructuralGridNode } from './schema' + +export const structuralGridDefinition: NodeDefinition = { + kind: 'structural-grid', + bake: 'strip', + schemaVersion: 1, + schema: StructuralGridNode, + category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + tool: () => import('./floorplan-tool'), + preferredView: '2d', + } satisfies FloorplanNodeExtension, + }, + snapProfile: 'structural', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + start: [0, 0], + end: [0, 5], + label: '1', + showStartBubble: true, + showEndBubble: true, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + deletable: true, + presettable: false, + }, + + dirtyTracking: false, + floorplan: buildStructuralGridFloorplan, + toolHints: [ + { key: 'Left click', label: 'Start grid axis' }, + { key: 'Left click', label: 'Finish grid axis' }, + { key: 'Alt', label: 'Bypass snapping' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Structural Grid', + description: 'Persistent construction grid axis with identification bubbles.', + icon: { kind: 'url', src: '/icons/structural-grid.webp' }, + paletteSection: 'structure', + paletteOrder: 72, + }, + + mcp: { + description: + 'A floor-plan structural datum axis defined by two level-local points and a grid identifier.', + }, +} diff --git a/packages/nodes/src/structural-grid/floorplan-tool.test.ts b/packages/nodes/src/structural-grid/floorplan-tool.test.ts new file mode 100644 index 00000000..90166930 --- /dev/null +++ b/packages/nodes/src/structural-grid/floorplan-tool.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, StructuralGridNode } from '@pascal-app/core' +import { + alphabeticGridLabel, + nextStructuralGridLabel, + shouldConsumeStructuralGridPointerEvent, + snapStructuralGridAngle, + structuralGridLabelFamily, +} from './floorplan-tool' + +describe('structural-grid drafting helpers', () => { + test('assigns numbers to vertical axes and letters to horizontal axes', () => { + expect(structuralGridLabelFamily([2, 0], [2, 8])).toBe('numeric') + expect(structuralGridLabelFamily([0, 3], [8, 3])).toBe('alphabetic') + }) + + test('continues labels within the active level and direction family', () => { + const vertical = StructuralGridNode.parse({ + id: 'structural-grid_1', + parentId: 'level_main', + start: [1, 0], + end: [1, 6], + label: '1', + }) + const horizontal = StructuralGridNode.parse({ + id: 'structural-grid_a', + parentId: 'level_main', + start: [0, 1], + end: [6, 1], + label: 'A', + }) + const nodes = { [vertical.id]: vertical, [horizontal.id]: horizontal } as Record< + string, + AnyNode + > + + expect(nextStructuralGridLabel(nodes, 'level_main', [2, 0], [2, 6])).toBe('2') + expect(nextStructuralGridLabel(nodes, 'level_main', [0, 2], [6, 2])).toBe('B') + expect(nextStructuralGridLabel(nodes, 'level_other', [2, 0], [2, 6])).toBe('1') + }) + + test('supports labels beyond Z and snaps angles to 45-degree increments', () => { + expect(alphabeticGridLabel(25)).toBe('Z') + expect(alphabeticGridLabel(26)).toBe('AA') + const snapped = snapStructuralGridAngle([0, 0], [4, 0.4]) + expect(snapped[1]).toBeCloseTo(0) + expect(Math.hypot(snapped[0], snapped[1])).toBeCloseTo(Math.hypot(4, 0.4)) + }) + + test('leaves right-button drag moves available for floor-plan rotation', () => { + expect( + shouldConsumeStructuralGridPointerEvent({ + type: 'pointermove', + button: -1, + buttons: 2, + }), + ).toBe(false) + }) +}) diff --git a/packages/nodes/src/structural-grid/floorplan-tool.tsx b/packages/nodes/src/structural-grid/floorplan-tool.tsx new file mode 100644 index 00000000..f9ffee58 --- /dev/null +++ b/packages/nodes/src/structural-grid/floorplan-tool.tsx @@ -0,0 +1,313 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + StructuralGridNode, + type StructuralGridNode as StructuralGridNodeType, +} from '@pascal-app/core' +import { + clearSurfacePlanSnapFeedback, + type FloorplanToolContext, + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, + markToolCancelConsumed, + resolveSurfacePlanPointSnap, + triggerSFX, + useFloorplanRender, + useInteractionScope, +} from '@pascal-app/editor' +import { useCallback, useEffect, useRef, useState } from 'react' + +const MIN_GRID_LENGTH = 0.01 +const GRID_BUBBLE_RADIUS = 0.22 +const GRID_LABEL_SIZE = 0.18 +const ANGLE_INCREMENT = Math.PI / 4 + +type PlanPoint = [number, number] +export type StructuralGridLabelFamily = 'numeric' | 'alphabetic' + +export function shouldConsumeStructuralGridPointerEvent(event: { + type: string + button: number + buttons: number +}): boolean { + if (event.type === 'pointerdown') return event.button === 0 + return (event.buttons & 0b110) === 0 +} + +function snap(value: number, step: number): number { + return step > 0 ? Math.round(value / step) * step : value +} + +function clientToPlanPoint(group: SVGGElement, clientX: number, clientY: number): PlanPoint | null { + const matrix = group.getScreenCTM() + if (!matrix) return null + const local = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()) + return [local.x, local.y] +} + +export function structuralGridLabelFamily( + start: PlanPoint, + end: PlanPoint, +): StructuralGridLabelFamily { + return Math.abs(end[1] - start[1]) >= Math.abs(end[0] - start[0]) ? 'numeric' : 'alphabetic' +} + +export function alphabeticGridLabel(index: number): string { + let value = Math.max(0, Math.floor(index)) + let label = '' + do { + label = String.fromCharCode(65 + (value % 26)) + label + value = Math.floor(value / 26) - 1 + } while (value >= 0) + return label +} + +export function nextStructuralGridLabel( + nodes: Readonly>, + levelId: string, + start: PlanPoint, + end: PlanPoint, +): string { + const family = structuralGridLabelFamily(start, end) + const used = new Set( + Object.values(nodes) + .filter( + (node): node is StructuralGridNodeType => + node.type === 'structural-grid' && + node.parentId === levelId && + structuralGridLabelFamily(node.start, node.end) === family, + ) + .map((node) => node.label.toUpperCase()), + ) + + for (let index = 0; ; index += 1) { + const candidate = family === 'numeric' ? String(index + 1) : alphabeticGridLabel(index) + if (!used.has(candidate)) return candidate + } +} + +export function snapStructuralGridAngle(start: PlanPoint, point: PlanPoint): PlanPoint { + const dx = point[0] - start[0] + const dz = point[1] - start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_GRID_LENGTH) return point + const angle = Math.round(Math.atan2(dz, dx) / ANGLE_INCREMENT) * ANGLE_INCREMENT + return [start[0] + Math.cos(angle) * length, start[1] + Math.sin(angle) * length] +} + +export function FloorplanStructuralGridToolLayer({ + activeLevelId, + finishTool, + gridSnapStep, + sceneApi, + selectNode, +}: FloorplanToolContext) { + const groupRef = useRef(null) + const startRef = useRef(null) + const [start, setStart] = useState(null) + const [hover, setHover] = useState(null) + const renderContext = useFloorplanRender() + + useEffect(() => { + useInteractionScope.getState().begin({ kind: 'drafting', tool: 'structural-grid' }) + return () => + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'drafting' && scope.tool === 'structural-grid') + }, []) + + const updateStart = useCallback((point: PlanPoint | null) => { + startRef.current = point + setStart(point) + }, []) + + useEffect(() => { + updateStart(null) + setHover(null) + const group = groupRef.current + const svg = group?.ownerSVGElement + if (!(activeLevelId && group && svg)) return + + const consume = (event: Event) => { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + const resolveEvent = (event: MouseEvent | PointerEvent): PlanPoint | null => { + const raw = clientToPlanPoint(group, event.clientX, event.clientY) + if (!raw) return null + const anglePoint = + startRef.current && !event.altKey && isAngleSnapActive() + ? snapStructuralGridAngle(startRef.current, raw) + : raw + const step = !event.altKey && isGridSnapActive() ? gridSnapStep : 0 + const fallback: PlanPoint = [snap(anglePoint[0], step), snap(anglePoint[1], step)] + const snapped = resolveSurfacePlanPointSnap({ + rawPoint: anglePoint, + fallbackPoint: fallback, + levelId: activeLevelId, + magnetic: !event.altKey && isMagneticSnapActive(), + align: isMagneticSnapActive(), + }) + return snapped.point + } + const onPointerDown = (event: PointerEvent) => { + if (shouldConsumeStructuralGridPointerEvent(event)) consume(event) + } + const onPointerMove = (event: PointerEvent) => { + if (shouldConsumeStructuralGridPointerEvent(event)) consume(event) + setHover(resolveEvent(event)) + } + const onPointerLeave = () => { + clearSurfacePlanSnapFeedback() + setHover(null) + } + const onClick = (event: MouseEvent) => { + if (event.button !== 0) return + consume(event) + const point = resolveEvent(event) + if (!point) return + const currentStart = startRef.current + if (!currentStart) { + updateStart(point) + triggerSFX('sfx:grid-snap') + return + } + if (Math.hypot(point[0] - currentStart[0], point[1] - currentStart[1]) < MIN_GRID_LENGTH) { + return + } + + const label = nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, currentStart, point) + const node = StructuralGridNode.parse({ + name: `Grid ${label}`, + start: currentStart, + end: point, + label, + }) + sceneApi.upsert(node, activeLevelId as AnyNodeId) + selectNode(node.id) + triggerSFX('sfx:structure-build') + updateStart(null) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopImmediatePropagation() + markToolCancelConsumed() + if (startRef.current) { + updateStart(null) + return + } + finishTool() + } + const onBlur = () => clearSurfacePlanSnapFeedback() + + svg.addEventListener('pointerdown', onPointerDown, true) + svg.addEventListener('pointermove', onPointerMove, true) + svg.addEventListener('pointerleave', onPointerLeave, true) + svg.addEventListener('click', onClick, true) + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('blur', onBlur) + return () => { + clearSurfacePlanSnapFeedback() + svg.removeEventListener('pointerdown', onPointerDown, true) + svg.removeEventListener('pointermove', onPointerMove, true) + svg.removeEventListener('pointerleave', onPointerLeave, true) + svg.removeEventListener('click', onClick, true) + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('blur', onBlur) + } + }, [activeLevelId, finishTool, gridSnapStep, sceneApi, selectNode, updateStart]) + + if (!activeLevelId) return null + const unitsPerPixel = renderContext?.unitsPerPixel ?? 0.01 + const reticleRadius = 9 * unitsPerPixel + const label = + start && hover ? nextStructuralGridLabel(sceneApi.nodes(), activeLevelId, start, hover) : null + + const renderBubble = (point: PlanPoint, key: string) => ( + + + + + {label} + + + + ) + + return ( + + {start && hover && label ? ( + + + {renderBubble(start, 'start')} + {renderBubble(hover, 'end')} + + ) : null} + {hover ? ( + + + + + + ) : null} + + ) +} + +export default FloorplanStructuralGridToolLayer diff --git a/packages/nodes/src/structural-grid/floorplan.test.ts b/packages/nodes/src/structural-grid/floorplan.test.ts new file mode 100644 index 00000000..d2e3335c --- /dev/null +++ b/packages/nodes/src/structural-grid/floorplan.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import { type GeometryContext, StructuralGridNode } from '@pascal-app/core' +import { buildStructuralGridFloorplan } from './floorplan' + +const context = { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, +} satisfies GeometryContext + +describe('buildStructuralGridFloorplan', () => { + test('draws a datum axis with labels at both ends', () => { + const grid = StructuralGridNode.parse({ + id: 'structural-grid_axis-1', + start: [2, 1], + end: [2, 8], + label: '3', + }) + + const geometry = buildStructuralGridFloorplan(grid, context) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + + expect(geometry.children[0]).toMatchObject({ + kind: 'line', + x1: 2, + y1: 1, + x2: 2, + y2: 8, + strokeDasharray: '10 4 2 4', + }) + expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(2) + expect(JSON.stringify(geometry)).toContain('"text":"3"') + }) + + test('respects independent endpoint-bubble visibility', () => { + const grid = StructuralGridNode.parse({ + start: [0, 0], + end: [5, 0], + label: 'A', + showStartBubble: false, + }) + + const geometry = buildStructuralGridFloorplan(grid, context) + expect(geometry?.kind).toBe('group') + if (geometry?.kind !== 'group') return + expect(geometry.children.filter((child) => child.kind === 'group')).toHaveLength(1) + }) + + test('omits a degenerate axis', () => { + const grid = StructuralGridNode.parse({ start: [1, 1], end: [1, 1] }) + expect(buildStructuralGridFloorplan(grid, context)).toBeNull() + }) +}) diff --git a/packages/nodes/src/structural-grid/floorplan.ts b/packages/nodes/src/structural-grid/floorplan.ts new file mode 100644 index 00000000..f92ed4e1 --- /dev/null +++ b/packages/nodes/src/structural-grid/floorplan.ts @@ -0,0 +1,76 @@ +import type { + FloorplanGeometry, + FloorplanPoint, + GeometryContext, + StructuralGridNode, +} from '@pascal-app/core' +import { withFloorplanGeometryMetadata } from '@pascal-app/editor' + +const GRID_BUBBLE_RADIUS = 0.22 +const GRID_LABEL_SIZE = 0.18 + +function bubble(point: FloorplanPoint, label: string, stroke: string): FloorplanGeometry { + return { + kind: 'group', + children: [ + { + kind: 'circle', + cx: point[0], + cy: point[1], + r: GRID_BUBBLE_RADIUS, + fill: '#ffffff', + stroke, + strokeWidth: 1.2, + vectorEffect: 'non-scaling-stroke', + }, + { + kind: 'text', + x: point[0], + y: point[1], + text: label, + fontSize: GRID_LABEL_SIZE, + fill: stroke, + fontWeight: 700, + textAnchor: 'middle', + dominantBaseline: 'middle', + upright: true, + }, + ], + } +} + +export function buildStructuralGridFloorplan( + node: StructuralGridNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const length = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1]) + if (length < 0.001) return null + + const selected = ctx.viewState?.selected ?? false + const highlighted = ctx.viewState?.highlighted ?? false + const palette = ctx.viewState?.palette + const active = selected || highlighted + const stroke = active && palette ? palette.selectedStroke : '#475569' + const children: FloorplanGeometry[] = [ + { + kind: 'line', + x1: node.start[0], + y1: node.start[1], + x2: node.end[0], + y2: node.end[1], + stroke, + strokeWidth: active ? 1.6 : 1, + strokeDasharray: '10 4 2 4', + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'stroke', + }, + ] + + if (node.showStartBubble) children.push(bubble(node.start, node.label, stroke)) + if (node.showEndBubble) children.push(bubble(node.end, node.label, stroke)) + + return withFloorplanGeometryMetadata( + { kind: 'group', children }, + { annotationRole: 'structural-grid' }, + ) +} diff --git a/packages/nodes/src/structural-grid/index.ts b/packages/nodes/src/structural-grid/index.ts new file mode 100644 index 00000000..950baee1 --- /dev/null +++ b/packages/nodes/src/structural-grid/index.ts @@ -0,0 +1 @@ +export { structuralGridDefinition } from './definition' diff --git a/packages/nodes/src/structural-grid/schema.ts b/packages/nodes/src/structural-grid/schema.ts new file mode 100644 index 00000000..ff6875fe --- /dev/null +++ b/packages/nodes/src/structural-grid/schema.ts @@ -0,0 +1 @@ +export { StructuralGridNode } from '@pascal-app/core' diff --git a/packages/nodes/src/wall/construction-dimension-reference-policy.test.ts b/packages/nodes/src/wall/construction-dimension-reference-policy.test.ts new file mode 100644 index 00000000..9df3a96c --- /dev/null +++ b/packages/nodes/src/wall/construction-dimension-reference-policy.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, WallNode, type WallNode as WallNodeType } from '@pascal-app/core' +import { constructionDimensionStandard } from '../shared/construction-dimension-standards' +import { + buildLevelWallConstructionDimensionPlan, + type PlannedConstructionDimension, + renderPlannedConstructionDimensions, +} from './construction-dimensions' +import { computeWallFloorplanLevelData } from './floorplan' + +function wall(overrides: Partial): WallNodeType { + return WallNode.parse({ + id: 'wall', + parentId: 'level_main', + start: [0, 0], + end: [1, 0], + frontSide: 'interior', + backSide: 'interior', + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.2, + materialRef: 'library:stud', + datumEligible: ['structural-face'], + }, + { + id: 'interior-finish', + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-finish', + role: 'exterior-finish', + side: 'exterior', + thickness: 0.04, + materialRef: 'library:cladding', + datumEligible: ['finish-face'], + }, + ], + ...overrides, + }) +} + +function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false) { + const top = wall({ + id: 'wall_top', + start: [0, 0], + end: splitAtPartition ? [4, 0] : [10, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const topContinuation = splitAtPartition + ? wall({ + id: 'wall_top_continuation', + start: [4, 0], + end: [10, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + : undefined + const right = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const partition = wall({ + id: 'wall_partition', + start: [4, partitionSpansPlan ? -6 : -4], + end: [4, 0], + frontSide: 'interior', + backSide: 'interior', + assemblyLayers: [ + { + id: 'partition-stud-core', + role: 'structure', + side: 'core', + thickness: 0.12, + materialRef: 'library:stud', + datumEligible: ['structural-face'], + }, + { + id: 'partition-finish-left', + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'partition-finish-right', + role: 'interior-finish', + side: 'exterior', + thickness: 0.02, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'partition-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.1, + materialRef: 'library:brick', + datumEligible: ['veneer-face'], + }, + ], + }) + const walls = [top, ...(topContinuation ? [topContinuation] : []), right, bottom, left, partition] + const nodes = Object.fromEntries(walls.map((candidate) => [candidate.id, candidate])) as Record< + string, + AnyNode + > + return { nodes, top, walls } +} + +function topFacadePlan( + datumPolicy: 'wall-face' | 'finish-face' | 'centerline' | 'structural-face', + splitAtPartition = false, +) { + const { nodes, top, walls } = topFacadeFixture(splitAtPartition) + return ( + buildLevelWallConstructionDimensionPlan( + walls, + nodes, + constructionDimensionStandard({ datumPolicy }), + ).get(top.id) ?? [] + ) +} + +function tierEntries( + plan: readonly PlannedConstructionDimension[], + tier: PlannedConstructionDimension['tier'], +) { + return plan.filter((entry) => entry.tier === tier) +} + +describe('automatic wall dimension reference policy', () => { + test('keeps exterior corner witnesses on outside stud faces in every mode', () => { + for (const datumPolicy of ['finish-face', 'centerline', 'structural-face'] as const) { + const overall = tierEntries(topFacadePlan(datumPolicy), 'overall')[0] + + expect(overall?.start[0]).toBeCloseTo(-0.1) + expect(overall?.end[0]).toBeCloseTo(10.1) + expect(overall?.start[1]).toBeCloseTo(0.1) + expect(overall?.end[1]).toBeCloseTo(0.1) + } + }) + + test('applies the selected reference only to the intersecting partition', () => { + const intersection = (datumPolicy: 'finish-face' | 'centerline' | 'structural-face') => { + const entries = tierEntries(topFacadePlan(datumPolicy), 'partitions') + return entries[0]?.end[0] + } + + expect(intersection('finish-face')).toBeCloseTo(3.92) + expect(intersection('centerline')).toBeCloseTo(4) + expect(intersection('structural-face')).toBeCloseTo(3.94) + }) + + test('keeps finished faces, centerline, and face of stud as distinct display modes', () => { + const { nodes, top, walls } = topFacadeFixture(true) + const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes }) + const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => { + const partitionChain = tierEntries( + levelData.constructionDimensionsByReference[reference].get(top.id) ?? [], + 'partitions', + ) + const rendered = renderPlannedConstructionDimensions(partitionChain, 'metric') + const dimensionString = rendered[0] + return dimensionString?.kind === 'dimension-string' ? dimensionString.segments : [] + } + + expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([ + '3.92m', + '0.26m', + '6.02m', + ]) + expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m']) + expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([ + '4.04m', + '6.16m', + ]) + }) + + test('renders a face-of-stud partition chain with one shared witness', () => { + const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' }) + const partitionChain = tierEntries(topFacadePlan('structural-face'), 'partitions') + const rendered = renderPlannedConstructionDimensions( + partitionChain, + 'metric', + undefined, + 'editor', + standard, + ) + + expect(rendered).toHaveLength(1) + const dimensionString = rendered[0] + expect(dimensionString?.kind).toBe('dimension-string') + if (dimensionString?.kind !== 'dimension-string') return + expect(dimensionString.segments).toHaveLength(2) + expect(dimensionString.segments[0]?.end[0]).toBeCloseTo(3.94) + expect(dimensionString.segments[1]?.start[0]).toBeCloseTo(3.94) + }) + + test('uses only one stud face when the facade is split at the intersecting wall', () => { + const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' }) + const partitionChain = tierEntries(topFacadePlan('structural-face', true), 'partitions') + const rendered = renderPlannedConstructionDimensions( + partitionChain, + 'metric', + undefined, + 'editor', + standard, + ) + const dimensionString = rendered[0] + + expect(rendered).toHaveLength(1) + expect(dimensionString?.kind).toBe('dimension-string') + if (dimensionString?.kind !== 'dimension-string') return + expect(dimensionString.segments).toHaveLength(2) + expect(dimensionString.segments.map((segment) => segment.text)).not.toContain('0.12m') + }) + + test('uses the same left or top stud face from opposing sides of the plan', () => { + const standard = constructionDimensionStandard({ datumPolicy: 'structural-face' }) + const verticalFixture = topFacadeFixture(false, true) + const verticalPlan = buildLevelWallConstructionDimensionPlan( + verticalFixture.walls, + verticalFixture.nodes, + standard, + ) + const verticalReference = (wallId: string) => + tierEntries(verticalPlan.get(wallId) ?? [], 'partitions')[0]?.end + + expect(verticalReference('wall_top')?.[0]).toBeCloseTo(3.94) + expect(verticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94) + + const reversedVerticalWalls = verticalFixture.walls.map((candidate) => + candidate.id === 'wall_partition' + ? { ...candidate, start: candidate.end, end: candidate.start } + : candidate, + ) + const reversedVerticalNodes = Object.fromEntries( + reversedVerticalWalls.map((candidate) => [candidate.id, candidate]), + ) as Record + const reversedVerticalPlan = buildLevelWallConstructionDimensionPlan( + reversedVerticalWalls, + reversedVerticalNodes, + standard, + ) + const reversedVerticalReference = (wallId: string) => + tierEntries(reversedVerticalPlan.get(wallId) ?? [], 'partitions')[0]?.end + + expect(reversedVerticalReference('wall_top')?.[0]).toBeCloseTo(3.94) + expect(reversedVerticalReference('wall_bottom')?.[0]).toBeCloseTo(3.94) + + const top = wall({ + id: 'wall_horizontal_top', + start: [0, 0], + end: [10, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const right = wall({ + id: 'wall_horizontal_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_horizontal_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_horizontal_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const partition = wall({ + id: 'wall_horizontal_partition', + start: [0, -3], + end: [10, -3], + assemblyLayers: [ + { + id: 'horizontal-stud-core', + role: 'structure', + side: 'core', + thickness: 0.12, + materialRef: 'library:stud', + datumEligible: ['structural-face'], + }, + ], + }) + const horizontalWalls = [top, right, bottom, left, partition] + const horizontalNodes = Object.fromEntries( + horizontalWalls.map((candidate) => [candidate.id, candidate]), + ) as Record + const horizontalPlan = buildLevelWallConstructionDimensionPlan( + horizontalWalls, + horizontalNodes, + standard, + ) + const horizontalReference = (wallId: string) => + tierEntries(horizontalPlan.get(wallId) ?? [], 'partitions')[0]?.end + + expect(horizontalReference('wall_horizontal_left')?.[1]).toBeCloseTo(-2.94) + expect(horizontalReference('wall_horizontal_right')?.[1]).toBeCloseTo(-2.94) + }) +}) diff --git a/packages/nodes/src/wall/construction-dimensions.test.ts b/packages/nodes/src/wall/construction-dimensions.test.ts new file mode 100644 index 00000000..eca833e0 --- /dev/null +++ b/packages/nodes/src/wall/construction-dimensions.test.ts @@ -0,0 +1,1326 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + ColumnNode, + DoorNode, + type FloorplanGeometry, + type GeometryContext, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { constructionDimensionStandard } from '../shared/construction-dimension-standards' +import { + buildCurvedWallConstructionDimensions, + buildLevelWallConstructionDimensionPlan, + buildWallConstructionDimensions, + formatConstructionLength, + renderPlannedConstructionDimensions, +} from './construction-dimensions' + +function wall(overrides: Partial = {}) { + return WallNode.parse({ + id: 'wall_main', + parentId: 'level_main', + start: [0, 0], + end: [10, 0], + thickness: 0.2, + frontSide: 'exterior', + backSide: 'interior', + ...overrides, + }) +} + +function context( + children: GeometryContext['children'] = [], + siblings: GeometryContext['siblings'] = [], +) { + return { + resolve: () => undefined, + children, + siblings, + parent: null, + } satisfies GeometryContext +} + +function dimensionTexts(entries: readonly FloorplanGeometry[]): string[] { + return entries.flatMap((entry) => { + if (entry.kind === 'dimension') return [entry.text] + if (entry.kind === 'dimension-string') return entry.segments.map((segment) => segment.text) + return [] + }) +} + +function firstDimensionSegment(entry: FloorplanGeometry) { + if (entry.kind === 'dimension') return entry + if (entry.kind === 'dimension-string') return entry.segments[0] + return null +} + +describe('formatConstructionLength', () => { + test('formats U.S. architectural feet, inches, and reduced sixteenths', () => { + expect(formatConstructionLength(12 * 0.3048, 'imperial')).toBe(`12'-0"`) + expect(formatConstructionLength((7 * 12 + 5.5) * 0.0254, 'imperial')).toBe(`7'-5 1/2"`) + expect(formatConstructionLength((2 * 12 + 3.1875) * 0.0254, 'imperial')).toBe(`2'-3 3/16"`) + }) + + test('rounds to the nearest sixteenth and carries into the next foot', () => { + expect(formatConstructionLength((11 + 0.99) * 0.0254, 'imperial')).toBe(`1'-0"`) + expect(formatConstructionLength(-0.5 * 0.0254, 'imperial')).toBe(`-0 1/2"`) + }) + + test('keeps metric output concise', () => { + expect(formatConstructionLength(3.456, 'metric')).toBe('3.46m') + expect(formatConstructionLength(Number.NaN, 'metric')).toBe('--') + }) +}) + +describe('buildWallConstructionDimensions', () => { + test('dimensions curved-wall depth orthogonally without a radius leader', () => { + const curved = wall({ curveOffset: 1 }) + const extension = wall({ id: 'wall_extension', start: [10, 0], end: [14, 0] }) + const geometry = buildCurvedWallConstructionDimensions(curved, { + unit: 'metric', + siblings: [extension], + })[0] + + expect(geometry).toMatchObject({ + kind: 'dimension-string', + offsetNormal: [1, 0], + segments: [ + { + end: [10, -0.1], + text: '1m', + }, + ], + }) + if (geometry?.kind !== 'dimension-string') return + const segment = geometry.segments[0] + expect(segment?.start[0]).toBeCloseTo(5) + expect(segment?.start[1]).toBeCloseTo(-1.1) + expect(segment?.dimensionStart[0]).toBeCloseTo(14.55) + expect(segment?.dimensionEnd[0]).toBeCloseTo(14.55) + }) + + test('builds a jamb-by-jamb chain plus a farther wall span', () => { + const door = DoorNode.parse({ + id: 'door_entry', + parentId: 'wall_main', + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_front', + parentId: 'wall_main', + position: [6, 1.5, 0], + width: 2, + }) + + const dimensions = buildWallConstructionDimensions(wall(), context([door, window]), { + unit: 'metric', + }) + + expect(dimensions).toHaveLength(6) + expect(dimensions.map((entry) => entry.kind)).toEqual(Array(6).fill('dimension-string')) + expect(dimensionTexts(dimensions)).toEqual(['1.5m', '1m', '2.5m', '2m', '3m', '10m']) + expect(dimensions.at(-1)).toMatchObject({ + kind: 'dimension-string', + offsetNormal: [0, 1], + offsetDistance: 1.05, + }) + }) + + test('applies drawing dimension standards to automatic wall strings', () => { + const door = DoorNode.parse({ + id: 'door_entry', + parentId: 'wall_main', + position: [2, 1.05, 0], + width: 1, + }) + const standard = constructionDimensionStandard({ + openingChainOffset: 0.7, + wallSpanOffset: 1.4, + extensionStartGap: 0.03, + extensionOvershoot: 0.18, + terminator: 'dot', + textPosition: 'centered', + metricNotation: 'millimeters', + }) + + const dimensions = buildWallConstructionDimensions(wall(), context([door]), { + unit: 'metric', + standard, + }) + + expect(dimensionTexts(dimensions)).toEqual(['1500', '1000', '7500', '10000']) + expect(dimensions[0]).toMatchObject({ + kind: 'dimension-string', + offsetDistance: 0.7, + extensionStartGap: 0.03, + extensionOvershoot: 0.18, + terminator: 'dot', + textPosition: 'centered', + }) + expect(dimensions.at(-1)).toMatchObject({ + kind: 'dimension-string', + offsetDistance: 1.4, + }) + }) + + test('uses the exterior wall face as the dimension side', () => { + const dimensions = buildWallConstructionDimensions( + wall({ frontSide: 'interior', backSide: 'exterior' }), + context(), + { unit: 'metric' }, + ) + + expect(dimensions).toHaveLength(1) + expect(dimensions[0]).toMatchObject({ + kind: 'dimension-string', + offsetNormal: [0, -1], + }) + expect(firstDimensionSegment(dimensions[0]!)).toMatchObject({ + start: [0, -0.1], + end: [10, -0.1], + }) + }) + + test('places witness origins on centerline, structural, finish, or assembly faces', () => { + const assemblyWall = wall({ + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.1, + datumEligible: ['structural-face'], + }, + { + id: 'interior-finish', + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + { + id: 'exterior-finish', + role: 'exterior-finish', + side: 'exterior', + thickness: 0.03, + datumEligible: ['finish-face'], + }, + ], + }) + const witnessY = ( + datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face', + ) => { + const entry = buildWallConstructionDimensions(assemblyWall, context(), { + unit: 'metric', + standard: constructionDimensionStandard({ datumPolicy }), + })[0] + return entry ? firstDimensionSegment(entry)?.start[1] : Number.NaN + } + + expect(witnessY('centerline')).toBe(0) + expect(witnessY('structural-face')).toBeCloseTo(0.05) + expect(witnessY('finish-face')).toBeCloseTo(0.08) + expect(witnessY('wall-face')).toBeCloseTo(0.08) + }) + + test('never dimensions a classified interior wall', () => { + const interior = wall({ frontSide: 'interior', backSide: 'interior' }) + expect(buildWallConstructionDimensions(interior, context(), { unit: 'metric' })).toEqual([]) + }) + + test('keeps curved walls out of the straight-string builder', () => { + expect( + buildWallConstructionDimensions(wall({ curveOffset: 1 }), context(), { unit: 'metric' }), + ).toEqual([]) + }) +}) + +describe('buildLevelWallConstructionDimensionPlan', () => { + test('chains straight facade runs across a curved-wall opening', () => { + const upper = wall({ + id: 'wall_left_upper', + start: [0, 0], + end: [0, -6], + frontSide: 'interior', + backSide: 'exterior', + }) + const curved = wall({ + id: 'wall_left_curve', + start: [0, -6], + end: [0, -18], + curveOffset: 6, + frontSide: 'interior', + backSide: 'exterior', + }) + const lower = wall({ + id: 'wall_left_lower', + start: [0, -18], + end: [0, -24], + frontSide: 'interior', + backSide: 'exterior', + }) + const top = wall({ id: 'wall_top', start: [0, 0], end: [12, 0] }) + const right = wall({ + id: 'wall_right', + start: [12, 0], + end: [12, -24], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [12, -24], + end: [0, -24], + frontSide: 'exterior', + backSide: 'interior', + }) + + const plan = buildLevelWallConstructionDimensionPlan( + [upper, curved, lower, top, right, bottom], + {}, + ) + const leftFacade = plan.get(lower.id) ?? [] + + expect(leftFacade.map((entry) => entry.tier)).toEqual(['jogs', 'jogs', 'jogs', 'overall']) + expect(dimensionTexts(renderPlannedConstructionDimensions(leftFacade, 'metric'))).toEqual([ + '6m', + '12m', + '6m', + '24.2m', + ]) + const jogs = leftFacade.filter((entry) => entry.tier === 'jogs') + const overall = leftFacade.find((entry) => entry.tier === 'overall') + expect(jogs[0]?.dimensionStart?.[0]).toBeCloseTo(-6.65) + expect(overall?.dimensionStart?.[0]).toBeCloseTo(-7.27) + expect( + leftFacade.every( + (entry) => + (entry.dimensionStart?.[0] ?? Number.POSITIVE_INFINITY) < -6.1 && + (entry.dimensionEnd?.[0] ?? Number.POSITIVE_INFINITY) < -6.1, + ), + ).toBe(true) + }) + + test('coordinates opening widths, centers, partition references, and overall extent', () => { + const exterior = wall() + const partition = wall({ + id: 'wall_partition', + start: [4, 0], + end: [4, -4], + frontSide: 'interior', + backSide: 'interior', + }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exterior.id, + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_front', + parentId: exterior.id, + position: [6, 1.5, 0], + width: 2, + }) + const nodes = { [door.id]: door, [window.id]: window } satisfies Record + + const plan = buildLevelWallConstructionDimensionPlan([exterior, partition], nodes) + const planned = plan.get(exterior.id) + const exteriorPlanned = planned ?? [] + expect(exteriorPlanned.map((entry) => entry.tier)).toEqual([ + 'opening-widths', + 'opening-widths', + 'openings', + 'openings', + 'openings', + 'partitions', + 'partitions', + 'overall', + ]) + expect(exteriorPlanned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([ + 0.62, 0.62, 1.24, 1.24, 1.24, 1.86, 1.86, 2.48, + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(exteriorPlanned, 'metric'))).toEqual([ + '1m', + '2m', + '2m', + '4m', + '4m', + '3.9m', + '6.1m', + '10m', + ]) + expect(exteriorPlanned.at(-1)).toMatchObject({ + start: [0, 0.1], + end: [10, 0.1], + offsetNormal: [0, 1], + }) + }) + + test('spaces the opening-width tier from the wall like the following dimension tiers', () => { + const exterior = wall() + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exterior.id, + position: [2, 1.05, 0], + width: 1, + }) + const standard = constructionDimensionStandard() + const planned = + buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }, standard).get( + exterior.id, + ) ?? [] + const tierOffsets = planned.reduce((offsets, entry) => { + if (!offsets.includes(entry.offsetDistance)) offsets.push(entry.offsetDistance) + return offsets + }, []) + + expect(tierOffsets[0]).toBeCloseTo(standard.tierSpacing) + for (const [index, offset] of tierOffsets.slice(1).entries()) { + expect(offset - tierOffsets[index]!).toBeCloseTo(standard.tierSpacing) + } + }) + + test('labels verified rough openings while retaining framed centerline locations', () => { + const exterior = wall() + const door = DoorNode.parse({ + id: 'door_ro', + parentId: exterior.id, + position: [2, 1.05, 0], + width: 1, + dimensionReference: 'rough-opening', + roughOpeningWidth: 1.2, + roughOpeningHeight: 2.2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }).get(exterior.id) ?? + [] + + expect(planned.map((entry) => entry.tier)).toEqual([ + 'opening-widths', + 'openings', + 'openings', + 'overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + 'RO 1.2m', + '2m', + '8m', + '10m', + ]) + }) + + test('uses masonry openings as edge-to-edge dimensions without framed centerline strings', () => { + const exterior = wall() + const window = WindowNode.parse({ + id: 'window_mo', + parentId: exterior.id, + position: [6, 1.5, 0], + width: 1, + constructionType: 'masonry', + masonryOpeningWidth: 1.4, + masonryOpeningHeight: 1.6, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([exterior], { [window.id]: window }).get( + exterior.id, + ) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual(['opening-widths', 'overall']) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + 'MO 1.4m', + '10m', + ]) + }) + + test('skips unverified rough-opening widths instead of deriving them from nominal size', () => { + const exterior = wall() + const door = DoorNode.parse({ + id: 'door_missing_ro', + parentId: exterior.id, + position: [2, 1.05, 0], + width: 1, + dimensionReference: 'rough-opening', + }) + + const planned = + buildLevelWallConstructionDimensionPlan([exterior], { [door.id]: door }).get(exterior.id) ?? + [] + + expect(planned.map((entry) => entry.tier)).toEqual(['openings', 'openings', 'overall']) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '2m', + '8m', + '10m', + ]) + }) + + test('labels optional finish-opening reference dimensions when explicitly verified', () => { + const exterior = wall() + const window = WindowNode.parse({ + id: 'window_fo', + parentId: exterior.id, + position: [5, 1.5, 0], + width: 1.2, + dimensionReference: 'finish-opening', + finishOpeningWidth: 0.95, + finishOpeningHeight: 1.2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([exterior], { [window.id]: window }).get( + exterior.id, + ) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual([ + 'opening-widths', + 'openings', + 'openings', + 'overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + 'FO 0.95m', + '5m', + '5m', + '10m', + ]) + }) + + test('uses drawing standard tier spacing for coordinated facade dimensions', () => { + const exterior = wall() + const partition = wall({ + id: 'wall_partition', + start: [4, 0], + end: [4, -4], + frontSide: 'interior', + backSide: 'interior', + }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: exterior.id, + position: [2, 1.05, 0], + width: 1, + }) + const standard = constructionDimensionStandard({ + firstOpeningWidthOffset: 0.4, + firstGeneralTierOffset: 0.6, + tierSpacing: 0.5, + extensionOvershoot: 0.2, + terminator: 'open-arrow', + }) + + const planned = + buildLevelWallConstructionDimensionPlan( + [exterior, partition], + { [door.id]: door }, + standard, + ).get(exterior.id) ?? [] + const rendered = renderPlannedConstructionDimensions( + planned, + 'metric', + undefined, + 'editor', + standard, + ) + + expect(planned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([ + 0.4, 0.9, 0.9, 1.4, 1.4, 1.9, + ]) + expect(rendered[0]).toMatchObject({ + kind: 'dimension-string', + extensionOvershoot: 0.2, + terminator: 'open-arrow', + }) + }) + + test('combines collinear exterior wall segments into one facade run', () => { + const first = wall({ id: 'wall_a', end: [5, 0] }) + const second = wall({ id: 'wall_b', start: [5, 0] }) + const opening = WindowNode.parse({ + id: 'window_b', + parentId: second.id, + position: [2, 1.5, 0], + width: 1, + }) + + const plan = buildLevelWallConstructionDimensionPlan([second, first], { [opening.id]: opening }) + const exteriorPlanned = plan.get(first.id) ?? [] + + expect([...plan.keys()]).toEqual([first.id]) + expect(dimensionTexts(renderPlannedConstructionDimensions(exteriorPlanned, 'metric'))).toEqual([ + '1m', + '7m', + '3m', + '10m', + ]) + }) + + test('adds a wall-local overall dimension for a classified interior partition', () => { + const exterior = wall() + const partition = wall({ + id: 'wall_partition', + start: [4, 0], + end: [4, -4], + frontSide: 'interior', + backSide: 'interior', + }) + + const plan = buildLevelWallConstructionDimensionPlan([exterior, partition], {}) + const exteriorPlanned = plan.get(exterior.id) ?? [] + const interiorPlanned = plan.get(partition.id) ?? [] + + expect(exteriorPlanned.map((entry) => entry.tier)).toEqual([ + 'partitions', + 'partitions', + 'overall', + ]) + expect(interiorPlanned).toEqual([ + expect.objectContaining({ + tier: 'interior-overall', + offsetDistance: 0.55, + }), + ]) + expect(exteriorPlanned.map((entry) => Number(entry.offsetDistance.toFixed(2)))).toEqual([ + 0.55, 0.55, 1.17, + ]) + }) + + test('dimensions interior wall segments and hosted door and window widths in the larger room', () => { + const partition = wall({ + id: 'wall_partition', + start: [0, 4], + end: [10, 4], + frontSide: 'interior', + backSide: 'interior', + }) + const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] }) + const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] }) + const door = DoorNode.parse({ + id: 'door_entry', + parentId: partition.id, + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_internal', + parentId: partition.id, + position: [6, 1.5, 0], + width: 2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([partition, lowerBoundary, upperBoundary], { + [door.id]: door, + [window.id]: window, + }).get(partition.id) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual([ + 'interior', + 'interior', + 'interior', + 'interior', + 'interior', + 'interior-overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '1.5m', + '1m', + '2.5m', + '2m', + '3m', + '10m', + ]) + expect(planned[0]).toMatchObject({ + start: [0, 4.1], + offsetNormal: [0, 1], + offsetDistance: 0.55, + }) + expect(planned.at(-1)).toMatchObject({ + offsetNormal: [0, 1], + offsetDistance: 1.05, + }) + for (const geometry of renderPlannedConstructionDimensions(planned, 'metric')) { + expect(geometry.kind).toBe('dimension-string') + if (geometry.kind !== 'dimension-string') continue + for (const segment of geometry.segments) { + const dimensionStart = segment.dimensionStart ?? [ + segment.start[0] + geometry.offsetNormal[0] * geometry.offsetDistance, + segment.start[1] + geometry.offsetNormal[1] * geometry.offsetDistance, + ] + const clearance = + (dimensionStart[0] - segment.start[0]) * geometry.offsetNormal[0] + + (dimensionStart[1] - segment.start[1]) * geometry.offsetNormal[1] + expect(clearance).toBeCloseTo(geometry.offsetDistance) + } + } + }) + + test('dimensions perimeter door widths on the room side in every wall orientation', () => { + const top = wall({ id: 'wall_top', end: [6, 0] }) + const right = wall({ + id: 'wall_right', + start: [6, 0], + end: [6, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [6, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const cases = [ + { wall: top, normal: [0, -1] as const, width: 1.2 }, + { wall: right, normal: [-1, 0] as const, width: 1.3 }, + { wall: bottom, normal: [0, 1] as const, width: 1.4 }, + { wall: left, normal: [1, 0] as const, width: 1.5 }, + ] + const doors = cases.map(({ wall: host, width }, index) => + DoorNode.parse({ + id: `door_${index}`, + parentId: host.id, + position: [3, 1.05, 0], + width, + }), + ) + const plan = buildLevelWallConstructionDimensionPlan( + [top, right, bottom, left], + Object.fromEntries(doors.map((door) => [door.id, door])), + ) + + for (const { wall: host, normal, width } of cases) { + const roomSideDimensions = (plan.get(host.id) ?? []).filter( + (entry) => + (entry.tier === 'interior' || entry.tier === 'interior-overall') && + entry.offsetNormal[0] * normal[0] + entry.offsetNormal[1] * normal[1] > 0.99, + ) + expect(roomSideDimensions.length).toBeGreaterThan(0) + expect( + dimensionTexts(renderPlannedConstructionDimensions(roomSideDimensions, 'metric')), + ).toContain(`${width}m`) + } + }) + + test('keeps room-side door and window dimensions when the opposite boundary is curved', () => { + const top = wall({ id: 'wall_top', end: [6, 0] }) + const right = wall({ + id: 'wall_right', + start: [6, 0], + end: [6, -8], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [6, -8], + end: [0, -8], + frontSide: 'exterior', + backSide: 'interior', + }) + const curved = wall({ + id: 'wall_curved', + start: [0, -8], + end: [0, 0], + curveOffset: 2, + frontSide: 'exterior', + backSide: 'interior', + }) + const door = DoorNode.parse({ + id: 'door_right', + parentId: right.id, + position: [2, 1.05, 0], + width: 1.2, + }) + const window = WindowNode.parse({ + id: 'window_right', + parentId: right.id, + position: [6, 1.2, 0], + width: 1.5, + }) + const straight = WallNode.parse({ ...curved, id: 'wall_straight', curveOffset: 0 }) + const reverseCurve = WallNode.parse({ ...curved, id: 'wall_reverse_curve', curveOffset: -2 }) + const roomSideTexts = (oppositeBoundary: WallNode) => { + const planned = + buildLevelWallConstructionDimensionPlan([top, right, bottom, oppositeBoundary], { + [door.id]: door, + [window.id]: window, + }).get(right.id) ?? [] + const roomSideDimensions = planned.filter( + (entry) => + (entry.tier === 'interior' || entry.tier === 'interior-overall') && + entry.offsetNormal[0] < -0.99, + ) + return dimensionTexts(renderPlannedConstructionDimensions(roomSideDimensions, 'metric')) + } + + expect(roomSideTexts(straight)).toEqual(expect.arrayContaining(['1.2m', '1.5m'])) + expect(roomSideTexts(curved)).toEqual(expect.arrayContaining(['1.2m', '1.5m'])) + expect(roomSideTexts(reverseCurve)).toEqual(expect.arrayContaining(['1.2m', '1.5m'])) + }) + + test('starts and ends an interior opening chain at the adjacent wall faces', () => { + const partition = wall({ + id: 'wall_partition_clear_span', + start: [0, 4], + end: [10, 4], + frontSide: 'interior', + backSide: 'interior', + }) + const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] }) + const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] }) + const leftBoundary = wall({ + id: 'wall_left', + start: [0, 10], + end: [0, 0], + thickness: 0.4, + }) + const rightBoundary = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, 10], + thickness: 0.2, + }) + const door = DoorNode.parse({ + id: 'door_clear_span', + parentId: partition.id, + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_clear_span', + parentId: partition.id, + position: [6, 1.5, 0], + width: 2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan( + [partition, lowerBoundary, upperBoundary, leftBoundary, rightBoundary], + { [door.id]: door, [window.id]: window }, + ).get(partition.id) ?? [] + + expect(planned[0]).toMatchObject({ start: [0.2, 4.1] }) + expect(planned.at(-1)).toMatchObject({ + start: [0.2, 4.1], + end: [9.9, 4.1], + }) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '1.3m', + '1m', + '2.5m', + '2m', + '2.9m', + '9.7m', + ]) + }) + + test('dimensions hosted openings on a bounded partition with incomplete side metadata', () => { + const partition = wall({ + id: 'wall_unclassified_partition', + start: [0, 4], + end: [10, 4], + frontSide: 'unknown', + backSide: 'unknown', + }) + const lowerBoundary = wall({ id: 'wall_lower', start: [0, 0], end: [10, 0] }) + const upperBoundary = wall({ id: 'wall_upper', start: [10, 10], end: [0, 10] }) + const door = DoorNode.parse({ + id: 'door_unclassified_partition', + wallId: partition.id, + parentId: partition.id, + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_unclassified_partition', + parentId: partition.id, + position: [6, 1.5, 0], + width: 2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([partition, lowerBoundary, upperBoundary], { + [door.id]: door, + [window.id]: window, + }).get(partition.id) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual([ + 'interior', + 'interior', + 'interior', + 'interior', + 'interior', + 'interior-overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '1.5m', + '1m', + '2.5m', + '2m', + '3m', + '10m', + ]) + }) + + test('dimensions hosted openings on a bounded partition with stale exterior metadata', () => { + const top = wall({ id: 'wall_top' }) + const right = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const partition = wall({ + id: 'wall_stale_partition', + start: [0, -3], + end: [10, -3], + frontSide: 'interior', + backSide: 'exterior', + }) + const door = DoorNode.parse({ + id: 'door_stale_partition', + parentId: partition.id, + position: [2, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_stale_partition', + parentId: partition.id, + position: [6, 1.5, 0], + width: 2, + }) + + const planned = + buildLevelWallConstructionDimensionPlan([top, right, bottom, left, partition], { + [door.id]: door, + [window.id]: window, + }).get(partition.id) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual([ + 'interior', + 'interior', + 'interior', + 'interior', + 'interior', + 'interior-overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '1.4m', + '1m', + '2.5m', + '2m', + '2.9m', + '9.8m', + ]) + }) + + test('does not treat an unbounded unknown wall with an opening as an interior partition', () => { + const unknownWall = wall({ + id: 'wall_unbounded_unknown', + frontSide: 'unknown', + backSide: 'unknown', + }) + const door = DoorNode.parse({ + id: 'door_unbounded_unknown', + parentId: unknownWall.id, + position: [2, 1.05, 0], + width: 1, + }) + + const plan = buildLevelWallConstructionDimensionPlan([unknownWall], { [door.id]: door }) + + expect(plan.get(unknownWall.id)).toBeUndefined() + }) + + test('locates partitions from a consistent face of stud', () => { + const exterior = wall() + const thinPartition = wall({ + id: 'wall_partition_thin', + start: [3, 0], + end: [3, -3], + thickness: 0.1, + frontSide: 'interior', + backSide: 'interior', + }) + const thickPartition = wall({ + id: 'wall_partition_thick', + start: [7, 0], + end: [7, -3], + thickness: 0.4, + frontSide: 'interior', + backSide: 'interior', + }) + + const planned = + buildLevelWallConstructionDimensionPlan([exterior, thinPartition, thickPartition], {}).get( + exterior.id, + ) ?? [] + + expect( + dimensionTexts( + renderPlannedConstructionDimensions( + planned.filter((entry) => entry.tier === 'partitions'), + 'metric', + ), + ), + ).toEqual(['2.95m', '3.85m', '3.2m']) + }) + + test('chains stepped facade projections on one exterior baseline', () => { + const lower = wall({ id: 'wall_lower', end: [4, 0] }) + const step = wall({ + id: 'wall_step', + start: [4, 0], + end: [4, 1], + frontSide: 'interior', + backSide: 'exterior', + }) + const upper = wall({ id: 'wall_upper', start: [4, 1], end: [10, 1] }) + + const planned = + buildLevelWallConstructionDimensionPlan([lower, step, upper], {}).get(lower.id) ?? [] + const rendered = renderPlannedConstructionDimensions(planned, 'metric') + + expect(planned.map((entry) => entry.tier)).toEqual(['jogs', 'jogs', 'overall']) + expect(dimensionTexts(rendered)).toEqual(['4m', '6m', '10m']) + const jogs = planned.filter((entry) => entry.tier === 'jogs') + expect(jogs).toEqual([ + expect.objectContaining({ + start: [0, 0.1], + end: [4, 1.1], + }), + expect.objectContaining({ + start: [4, 1.1], + end: [10, 1.1], + }), + ]) + expect(jogs[0]?.dimensionStart?.[1]).toBeCloseTo(1.65) + expect(jogs[0]?.dimensionEnd?.[1]).toBeCloseTo(1.65) + expect(jogs[1]?.dimensionStart?.[1]).toBeCloseTo(1.65) + expect(jogs[1]?.dimensionEnd?.[1]).toBeCloseTo(1.65) + }) + + test('dimensions an exterior column row by structural centerline', () => { + const top = wall({ id: 'wall_top' }) + const right = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const columns = [-1, 5, 11].map((x, index) => + ColumnNode.parse({ + id: `column_${index}`, + parentId: 'level_main', + position: [x, 0, 2], + crossSection: 'square', + }), + ) + const nodes = Object.fromEntries(columns.map((column) => [column.id, column])) + + const plan = buildLevelWallConstructionDimensionPlan([top, right, bottom, left], nodes) + const planned = plan.get(top.id) ?? [] + + expect(plan.size).toBe(4) + expect(planned.map((entry) => entry.tier)).toEqual([ + 'structure', + 'structure', + 'overall', + 'structural-overall', + ]) + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'metric'))).toEqual([ + '6m', + '6m', + '10.2m', + '12m', + ]) + expect(planned[0]).toMatchObject({ + start: [-1, 2], + end: [5, 2], + }) + expect(planned[0]?.dimensionStart?.[0]).toBe(-1) + expect(planned[0]?.dimensionEnd?.[0]).toBe(5) + expect(planned[0]?.dimensionStart?.[1]).toBeCloseTo(2.8712) + expect(planned[0]?.dimensionEnd?.[1]).toBeCloseTo(2.8712) + }) + + test('does not stretch interior column references to an exterior dimension string', () => { + const top = wall({ id: 'wall_top' }) + const right = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const columns = [2, 8].map((x, index) => + ColumnNode.parse({ + id: `column_interior_${index}`, + parentId: 'level_main', + position: [x, 0, -2], + crossSection: 'square', + }), + ) + const nodes = Object.fromEntries(columns.map((column) => [column.id, column])) + + const planned = + buildLevelWallConstructionDimensionPlan([top, right, bottom, left], nodes).get(top.id) ?? [] + + expect(planned.map((entry) => entry.tier)).toEqual(['overall']) + }) + + test('keeps internal openings off exterior strings and dimensions them locally', () => { + const top = wall({ id: 'wall_top' }) + const right = wall({ + id: 'wall_right', + start: [10, 0], + end: [10, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const bottom = wall({ + id: 'wall_bottom', + start: [10, -6], + end: [0, -6], + frontSide: 'exterior', + backSide: 'interior', + }) + const left = wall({ + id: 'wall_left', + start: [0, -6], + end: [0, 0], + frontSide: 'exterior', + backSide: 'interior', + }) + const interiorDoorWall = wall({ + id: 'wall_interior_door', + start: [4, -2], + end: [10, -2], + frontSide: 'interior', + backSide: 'exterior', + }) + const door = DoorNode.parse({ + id: 'door_interior', + parentId: interiorDoorWall.id, + position: [3, 1.05, 0], + width: 1, + }) + const window = WindowNode.parse({ + id: 'window_interior', + parentId: interiorDoorWall.id, + position: [1.5, 1.2, 0], + width: 1, + }) + + const plan = buildLevelWallConstructionDimensionPlan( + [top, right, bottom, left, interiorDoorWall], + { [door.id]: door, [window.id]: window }, + ) + const bottomDimensions = plan.get(bottom.id) ?? [] + const interiorDimensions = plan.get(interiorDoorWall.id) ?? [] + + expect(bottomDimensions.map((entry) => entry.tier)).toEqual(['overall']) + expect(interiorDimensions.map((entry) => entry.tier)).toEqual([ + 'interior', + 'interior', + 'interior', + 'interior', + 'interior', + 'interior-overall', + ]) + expect( + dimensionTexts(renderPlannedConstructionDimensions(interiorDimensions, 'metric')), + ).toEqual(['1m', '1m', '0.5m', '1m', '2.4m', '5.9m']) + }) + + test('keeps disconnected collinear facade runs independent', () => { + const first = wall({ id: 'wall_a', end: [4, 0] }) + const second = wall({ id: 'wall_b', start: [8, 0], end: [12, 0] }) + const firstPartition = wall({ + id: 'wall_partition_a', + start: [2, 0], + end: [2, -2], + frontSide: 'interior', + backSide: 'interior', + }) + const secondPartition = wall({ + id: 'wall_partition_b', + start: [10, 0], + end: [10, -2], + frontSide: 'interior', + backSide: 'interior', + }) + + const plan = buildLevelWallConstructionDimensionPlan( + [first, second, firstPartition, secondPartition], + {}, + ) + + expect([...plan.keys()]).toEqual([first.id, second.id, firstPartition.id, secondPartition.id]) + expect(plan.get(first.id)?.find((entry) => entry.tier === 'overall')).toMatchObject({ + start: [0, 0.1], + end: [4, 0.1], + }) + expect(plan.get(second.id)?.find((entry) => entry.tier === 'overall')).toMatchObject({ + start: [8, 0.1], + end: [12, 0.1], + }) + }) + + test('places a back-side exterior facade beyond the back face', () => { + const exterior = wall({ frontSide: 'interior', backSide: 'exterior' }) + const planned = buildLevelWallConstructionDimensionPlan([exterior], {}).get(exterior.id) + + const overall = planned?.find((entry) => entry.tier === 'overall') + expect(overall).toMatchObject({ + start: [10, -0.1], + end: [0, -0.1], + offsetNormal: [0, -1], + dimensionStart: [10, -0.65], + dimensionEnd: [0, -0.65], + }) + expect(overall?.offsetDistance).toBeCloseTo(0.55) + }) + + test('keeps angled exterior dimensions aligned and reports their true length', () => { + const angled = wall({ end: [3, 4] }) + const planned = buildLevelWallConstructionDimensionPlan([angled], {}).get(angled.id) ?? [] + + expect(dimensionTexts(renderPlannedConstructionDimensions(planned, 'imperial'))).toEqual([ + `16'-4 7/8"`, + ]) + expect(planned[0]).toMatchObject({ + tier: 'overall', + offsetNormal: [-0.8, 0.6], + }) + expect( + Math.hypot( + planned[0]!.dimensionEnd![0] - planned[0]!.dimensionStart![0], + planned[0]!.dimensionEnd![1] - planned[0]!.dimensionStart![1], + ), + ).toBeCloseTo(5) + }) + + test('dimensions every exterior side and interior run in a subdivided rectangular plan', () => { + const topLeft = wall({ id: 'wall_top_left', end: [2.5, 0] }) + const topRight = wall({ id: 'wall_top_right', start: [2.5, 0], end: [6, 0] }) + const rightTop = wall({ id: 'wall_right_top', start: [6, 0], end: [6, -1.5] }) + const rightBottom = wall({ id: 'wall_right_bottom', start: [6, -1.5], end: [6, -3] }) + const bottomRight = wall({ id: 'wall_bottom_right', start: [6, -3], end: [4, -3] }) + const bottomMiddle = wall({ id: 'wall_bottom_middle', start: [4, -3], end: [2.5, -3] }) + const bottomLeft = wall({ id: 'wall_bottom_left', start: [2.5, -3], end: [0, -3] }) + const leftBottom = wall({ id: 'wall_left_bottom', start: [0, -3], end: [0, -1.5] }) + const leftTop = wall({ id: 'wall_left_top', start: [0, -1.5], end: [0, 0] }) + const interior = (id: string, start: [number, number], end: [number, number]) => + wall({ id, start, end }) + const middleLeft = interior('wall_middle_left', [0, -1.5], [2.5, -1.5]) + const middleCenter = interior('wall_middle_center', [2.5, -1.5], [4, -1.5]) + const middleRight = interior('wall_middle_right', [4, -1.5], [6, -1.5]) + const centerTop = interior('wall_center_top', [2.5, 0], [2.5, -1.5]) + const centerBottom = interior('wall_center_bottom', [2.5, -1.5], [2.5, -3]) + const lowerRight = interior('wall_lower_right', [4, -1.5], [4, -3]) + const walls = [ + topLeft, + topRight, + rightTop, + rightBottom, + bottomRight, + bottomMiddle, + bottomLeft, + leftBottom, + leftTop, + middleLeft, + middleCenter, + middleRight, + centerTop, + centerBottom, + lowerRight, + ] + + const plan = buildLevelWallConstructionDimensionPlan(walls, {}) + const exteriorFacades = [ + [topLeft.id, topRight.id], + [rightTop.id, rightBottom.id], + [bottomRight.id, bottomMiddle.id, bottomLeft.id], + [leftBottom.id, leftTop.id], + ] + expect( + exteriorFacades.map((ids) => + ids.some((id) => plan.get(id)?.some((entry) => entry.tier === 'partitions')), + ), + ).toEqual([true, true, true, true]) + + expect( + [middleLeft, middleCenter, middleRight, centerTop, centerBottom, lowerRight].map((entry) => + plan.get(entry.id)?.some((dimension) => dimension.tier === 'interior-overall'), + ), + ).toEqual([true, true, true, true, true, true]) + }) + + test('does not automatically dimension walls without a side classification', () => { + const plan = buildLevelWallConstructionDimensionPlan( + [wall({ frontSide: 'unknown', backSide: 'unknown' })], + {}, + ) + + expect(plan.size).toBe(0) + }) +}) diff --git a/packages/nodes/src/wall/construction-dimensions.ts b/packages/nodes/src/wall/construction-dimensions.ts new file mode 100644 index 00000000..f8b0b616 --- /dev/null +++ b/packages/nodes/src/wall/construction-dimensions.ts @@ -0,0 +1,1622 @@ +import { + type AnyNode, + type ColumnNode, + type DoorNode, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallArcData, + getWallAssemblyFaceOffsets, + getWallChordFrame, + getWallMidpointHandlePoint, + isCurvedWall, + resolveWallAssemblyDatumReferences, + type WallNode, + type WindowNode, +} from '@pascal-app/core' +import { getColumnFloorplanFootprint } from '../column/floorplan' +import { + type ConstructionDimensionDrawingStandard, + DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, +} from '../shared/construction-dimension-standards' +import { + type ConstructionLengthProfile, + type ConstructionLinearUnit, + formatConstructionLength, +} from '../shared/construction-length' +import { buildDimensionStringGeometry } from '../shared/dimension-string' +import { resolveOpeningDimensionDocumentation } from '../shared/opening-documentation' + +export { formatConstructionLength } from '../shared/construction-length' + +const MIN_SEGMENT_LENGTH = 0.02 +const FACADE_LINE_TOLERANCE = 0.03 +const FACADE_DIRECTION_TOLERANCE = 0.001 +const COLUMN_ROW_TOLERANCE = 0.05 +const EXTERIOR_CORNER_DATUM_POLICY = 'structural-face' as const + +type OpeningNode = DoorNode | WindowNode + +export type ConstructionDimensionTier = + | 'opening-widths' + | 'openings' + | 'partitions' + | 'structure' + | 'jogs' + | 'overall' + | 'structural-overall' + | 'interior' + | 'interior-overall' + +const TIER_ORDER: readonly ConstructionDimensionTier[] = [ + 'opening-widths', + 'openings', + 'partitions', + 'structure', + 'jogs', + 'overall', + 'structural-overall', +] + +export type PlannedConstructionDimension = { + tier: ConstructionDimensionTier + start: FloorplanPoint + end: FloorplanPoint + dimensionStart?: FloorplanPoint + dimensionEnd?: FloorplanPoint + offsetNormal: FloorplanPoint + offsetDistance: number + textPrefix?: string +} + +export type WallConstructionDimensionPlan = ReadonlyMap< + string, + readonly PlannedConstructionDimension[] +> + +type FacadeMember = { + wall: WallNode + normal: FloorplanPoint + tangent: FloorplanPoint +} + +type PendingConstructionDimension = { + tier: ConstructionDimensionTier + start: FloorplanPoint + end: FloorplanPoint + startProjection: number + endProjection: number + textPrefix?: string +} + +export function buildLevelWallConstructionDimensionPlan( + walls: ReadonlyArray, + nodes: Record, + standard: ConstructionDimensionDrawingStandard = DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, +): WallConstructionDimensionPlan { + const dimensionsByWallId = new Map() + const wallNetworkById = buildWallNetworkIndex(walls) + const interiorWallIds = new Set( + walls.flatMap((wall) => { + if (isCurvedWall(wall)) return [] + const network = wallNetworkById.get(wall.id) ?? [wall] + return shouldDimensionInteriorWall(wall, walls, network) ? [wall.id] : [] + }), + ) + const exteriorMembers = walls.flatMap((wall): FacadeMember[] => { + if (isCurvedWall(wall) || interiorWallIds.has(wall.id)) return [] + const normal = exteriorNormal(wall) + if (!normal) return [] + const network = wallNetworkById.get(wall.id) ?? [wall] + if (isFacadeOccluded(wall, normal, network)) return [] + return [{ wall, normal, tangent: [cleanZero(normal[1]), cleanZero(-normal[0])] }] + }) + const columns = Object.values(nodes).filter( + (node): node is ColumnNode => node.type === 'column' && node.visible !== false, + ) + + const components = splitConnectedFacadeComponents(exteriorMembers) + for (const component of components) { + const directionGroups = groupFacadeMembersByDirection(component) + const componentColumns = columns.filter( + (column) => + column.parentId === component[0]?.wall.parentId && + nearestFacadeComponent(column, components) === component, + ) + + for (const directionMembers of directionGroups.values()) { + const representative = [...directionMembers].sort((left, right) => + String(left.wall.id).localeCompare(String(right.wall.id)), + )[0] + if (!representative) continue + const { normal, tangent } = representative + const wallProjections = directionMembers.flatMap(({ wall }) => [ + dot(wall.start, tangent), + dot(wall.end, tangent), + ]) + const [extentStart, extentEnd] = facadeStructuralExtents(directionMembers, walls, tangent) + if (extentEnd - extentStart < MIN_SEGMENT_LENGTH) continue + + const outerFaceCoordinate = Math.max( + ...directionMembers.map(({ wall }) => + exteriorFaceCoordinate(wall, normal, EXTERIOR_CORNER_DATUM_POLICY), + ), + ...curvedFacadeOuterFaceCoordinates( + walls, + directionMembers, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + ) + const pending: PendingConstructionDimension[] = [] + const lineGroups = groupFacadeMembersByLine(directionMembers, normal) + let facadeRunCount = 0 + + for (const groupedMembers of lineGroups.values()) { + const runs = splitFacadeRuns(groupedMembers) + facadeRunCount += runs.length + for (const run of runs) { + appendFacadeRunDimensions( + pending, + run, + walls, + nodes, + interiorWallIds, + normal, + tangent, + standard, + ) + } + } + + if (lineGroups.size > 1 || facadeRunCount > lineGroups.size) { + const jogProjections = uniqueSorted(wallProjections) + appendProjectedChain(pending, jogProjections, 'jogs', (projection) => + exteriorOriginAtProjection( + directionMembers, + projection, + tangent, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + ) + } + + const exteriorColumns = componentColumns.filter( + (column) => + dot(columnPlanPoint(column), normal) + columnNormalHalfExtent(column, normal) >= + outerFaceCoordinate - FACADE_LINE_TOLERANCE, + ) + const structureRow = outermostColumnRow(exteriorColumns, component, normal, tangent) + if (structureRow.length >= 2) { + const projections = uniqueSorted( + structureRow.map((column) => dot(columnPlanPoint(column), tangent)), + ) + appendProjectedChain(pending, projections, 'structure', (projection) => + columnOriginAtProjection(structureRow, projection, tangent), + ) + } + + pending.push({ + tier: 'overall', + start: exteriorOriginAtProjection( + directionMembers, + extentStart, + tangent, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + end: exteriorOriginAtProjection( + directionMembers, + extentEnd, + tangent, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + startProjection: extentStart, + endProjection: extentEnd, + }) + + const structuralProjections = structureRow.map((column) => + dot(columnPlanPoint(column), tangent), + ) + const structuralStart = Math.min(extentStart, ...structuralProjections) + const structuralEnd = Math.max(extentEnd, ...structuralProjections) + if ( + structureRow.length >= 2 && + (structuralStart < extentStart - MIN_SEGMENT_LENGTH || + structuralEnd > extentEnd + MIN_SEGMENT_LENGTH) + ) { + pending.push({ + tier: 'structural-overall', + start: + structuralStart < extentStart - MIN_SEGMENT_LENGTH + ? columnOriginAtProjection(structureRow, structuralStart, tangent) + : exteriorOriginAtProjection( + directionMembers, + extentStart, + tangent, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + end: + structuralEnd > extentEnd + MIN_SEGMENT_LENGTH + ? columnOriginAtProjection(structureRow, structuralEnd, tangent) + : exteriorOriginAtProjection( + directionMembers, + extentEnd, + tangent, + normal, + EXTERIOR_CORNER_DATUM_POLICY, + ), + startProjection: structuralStart, + endProjection: structuralEnd, + }) + } + + const structuralFaceCoordinate = Math.max( + outerFaceCoordinate, + ...structureRow.map( + (column) => dot(columnPlanPoint(column), normal) + columnNormalHalfExtent(column, normal), + ), + ) + dimensionsByWallId.set( + representative.wall.id, + finalizeDimensionTiers(pending, tangent, normal, structuralFaceCoordinate, standard), + ) + } + } + + for (const wall of walls) { + if (isCurvedWall(wall)) continue + const openings = hostedOpeningsForWall(wall, nodes) + const roomSideNormal = interiorWallIds.has(wall.id) + ? undefined + : enclosedRoomSideNormal(wall, walls) + if (!interiorWallIds.has(wall.id) && (openings.length === 0 || roomSideNormal === null)) { + continue + } + const planned = buildInteriorWallDimensions(wall, walls, openings, standard, roomSideNormal) + if (planned.length === 0) continue + dimensionsByWallId.set(wall.id, [...(dimensionsByWallId.get(wall.id) ?? []), ...planned]) + } + + return dimensionsByWallId +} + +function buildInteriorWallDimensions( + wall: WallNode, + walls: ReadonlyArray, + openings: readonly OpeningNode[], + standard: ConstructionDimensionDrawingStandard, + normalOverride?: FloorplanPoint | null, +): PlannedConstructionDimension[] { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength < MIN_SEGMENT_LENGTH) return [] + + const tangent: FloorplanPoint = [dx / wallLength, dz / wallLength] + const [spanStart, spanEnd] = interiorWallClearSpan( + wall, + walls, + tangent, + wallLength, + standard.datumPolicy, + ) + if (spanEnd - spanStart < MIN_SEGMENT_LENGTH) return [] + const normal = normalOverride ?? resolveInteriorDimensionNormal(wall, walls, tangent) + const datumDistance = wallDatumDistanceToward(wall, standard.datumPolicy, normal) + const pointAt = (along: number): FloorplanPoint => [ + wall.start[0] + tangent[0] * along + normal[0] * datumDistance, + wall.start[1] + tangent[1] * along + normal[1] * datumDistance, + ] + const openingSpans = openings.flatMap((opening): Array => { + const halfWidth = Math.max(0, opening.width) / 2 + const start = clamp(opening.position[0] - halfWidth, spanStart, spanEnd) + const end = clamp(opening.position[0] + halfWidth, spanStart, spanEnd) + return end - start >= MIN_SEGMENT_LENGTH ? [[start, end]] : [] + }) + + const planned: PlannedConstructionDimension[] = [] + if (openingSpans.length > 0) { + const breakpoints = uniqueSorted([spanStart, spanEnd, ...openingSpans.flat()]) + for (let index = 0; index < breakpoints.length - 1; index++) { + const start = breakpoints[index] + const end = breakpoints[index + 1] + if (start === undefined || end === undefined || end - start < MIN_SEGMENT_LENGTH) continue + planned.push({ + tier: 'interior', + start: pointAt(start), + end: pointAt(end), + offsetNormal: normal, + offsetDistance: standard.openingChainOffset, + }) + } + } + + planned.push({ + tier: 'interior-overall', + start: pointAt(spanStart), + end: pointAt(spanEnd), + offsetNormal: normal, + offsetDistance: openingSpans.length > 0 ? standard.wallSpanOffset : standard.openingChainOffset, + }) + return planned +} + +function interiorWallClearSpan( + wall: WallNode, + walls: ReadonlyArray, + tangent: FloorplanPoint, + wallLength: number, + datumPolicy: ConstructionDimensionDrawingStandard['datumPolicy'], +): readonly [number, number] { + const insetAt = (endpoint: FloorplanPoint, inward: FloorplanPoint): number => { + let inset = 0 + for (const candidate of walls) { + if ( + candidate.id === wall.id || + isCurvedWall(candidate) || + pointSegmentDistance(endpoint, candidate.start, candidate.end) > FACADE_LINE_TOLERANCE + ) { + continue + } + const candidateDirection = subtract(candidate.end, candidate.start) + const candidateLength = Math.hypot(candidateDirection[0], candidateDirection[1]) + if (candidateLength < MIN_SEGMENT_LENGTH) continue + const candidateNormal: FloorplanPoint = [ + -candidateDirection[1] / candidateLength, + candidateDirection[0] / candidateLength, + ] + const crossing = Math.abs(dot(inward, candidateNormal)) + if (crossing < FACADE_DIRECTION_TOLERANCE) continue + inset = Math.max(inset, maximumWallDatumDistance(candidate, datumPolicy) / crossing) + } + return inset + } + + const spanStart = clamp(insetAt(wall.start, tangent), 0, wallLength) + const spanEnd = clamp(wallLength - insetAt(wall.end, negate(tangent)), spanStart, wallLength) + return [spanStart, spanEnd] +} + +export function renderPlannedConstructionDimensions( + planned: readonly PlannedConstructionDimension[], + unit: ConstructionLinearUnit, + stroke?: string, + profile: ConstructionLengthProfile = 'editor', + standard: ConstructionDimensionDrawingStandard = DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, +): FloorplanGeometry[] { + return groupContiguousPlannedDimensions(planned).map((entries) => { + const first = entries[0]! + return buildDimensionStringGeometry({ + segments: entries.map((entry) => ({ + witnessStart: entry.start, + witnessEnd: entry.end, + dimensionStart: entry.dimensionStart, + dimensionEnd: entry.dimensionEnd, + text: constructionDimensionText( + entry.dimensionStart ?? entry.start, + entry.dimensionEnd ?? entry.end, + unit, + profile, + standard, + entry.textPrefix, + ), + })), + offsetNormal: first.offsetNormal, + offsetDistance: first.offsetDistance, + extensionStartGap: standard.extensionStartGap, + extensionOvershoot: standard.extensionOvershoot, + terminator: standard.terminator, + textPosition: standard.textPosition, + stroke, + }) + }) +} + +function groupContiguousPlannedDimensions( + planned: readonly PlannedConstructionDimension[], +): PlannedConstructionDimension[][] { + const groups: PlannedConstructionDimension[][] = [] + for (const entry of planned) { + const group = groups.at(-1) + const previous = group?.at(-1) + if (group && previous && plannedDimensionsAreContiguous(previous, entry)) group.push(entry) + else groups.push([entry]) + } + return groups +} + +function plannedDimensionsAreContiguous( + previous: PlannedConstructionDimension, + next: PlannedConstructionDimension, +): boolean { + return ( + previous.tier === next.tier && + distance(previous.offsetNormal, next.offsetNormal) <= 1e-6 && + distance(previous.end, next.start) <= 1e-6 && + distance(plannedDimensionEnd(previous), plannedDimensionStart(next)) <= 1e-6 + ) +} + +function plannedDimensionStart(entry: PlannedConstructionDimension): FloorplanPoint { + return entry.dimensionStart ?? addScaled(entry.start, entry.offsetNormal, entry.offsetDistance) +} + +function plannedDimensionEnd(entry: PlannedConstructionDimension): FloorplanPoint { + return entry.dimensionEnd ?? addScaled(entry.end, entry.offsetNormal, entry.offsetDistance) +} + +export function buildCurvedWallConstructionDimensions( + wall: WallNode, + { + unit, + stroke, + profile = 'editor', + standard = DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, + siblings = [], + }: { + unit: ConstructionLinearUnit + stroke?: string + profile?: ConstructionLengthProfile + standard?: ConstructionDimensionDrawingStandard + siblings?: ReadonlyArray + }, +): FloorplanGeometry[] { + const chord = getWallChordFrame(wall) + const midpoint = getWallMidpointHandlePoint(wall) + const curveVector: FloorplanPoint = [midpoint.x - chord.midpoint.x, midpoint.y - chord.midpoint.y] + const curveDepth = Math.hypot(curveVector[0], curveVector[1]) + if (chord.length < MIN_SEGMENT_LENGTH || curveDepth < MIN_SEGMENT_LENGTH) return [] + + const curveDirection: FloorplanPoint = [curveVector[0] / curveDepth, curveVector[1] / curveDepth] + const tangent: FloorplanPoint = [chord.tangent.x, chord.tangent.y] + const datumDistance = wallDatumDistanceToward(wall, standard.datumPolicy, curveDirection) + const curveWitness = addScaled([midpoint.x, midpoint.y], curveDirection, datumDistance) + const chordWitness = addScaled(wall.end, curveDirection, datumDistance) + const connectedWalls = connectedWallComponent(wall, [wall, ...siblings]) + const forwardExtent = Math.max( + ...connectedWalls.flatMap((candidate) => [ + dot(candidate.start, tangent), + dot(candidate.end, tangent), + ]), + ) + const baselineProjection = forwardExtent + standard.firstGeneralTierOffset + const dimensionStart = pointFromCoordinates( + baselineProjection, + dot(curveWitness, curveDirection), + tangent, + curveDirection, + ) + const dimensionEnd = pointFromCoordinates( + baselineProjection, + dot(chordWitness, curveDirection), + tangent, + curveDirection, + ) + + return [ + dimension( + curveWitness, + chordWitness, + tangent, + Math.max(0, dot(subtract(dimensionStart, curveWitness), tangent)), + unit, + stroke, + dimensionStart, + dimensionEnd, + profile, + standard, + ), + ] +} + +export function buildWallConstructionDimensions( + wall: WallNode, + ctx: GeometryContext, + { + unit, + stroke, + profile = 'editor', + standard = DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, + }: { + unit: ConstructionLinearUnit + stroke?: string + profile?: ConstructionLengthProfile + standard?: ConstructionDimensionDrawingStandard + }, +): FloorplanGeometry[] { + if (isCurvedWall(wall)) return [] + + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength < MIN_SEGMENT_LENGTH) return [] + + const sideIsClassified = wall.frontSide !== 'unknown' || wall.backSide !== 'unknown' + const isExterior = wall.frontSide === 'exterior' || wall.backSide === 'exterior' + if (sideIsClassified && !isExterior) return [] + + const dirX = dx / wallLength + const dirZ = dz / wallLength + const outwardNormal = resolveOutwardNormal(wall, ctx, dirX, dirZ) + const datumDistance = wallDatumDistanceToward(wall, standard.datumPolicy, outwardNormal) + const pointAt = (along: number): FloorplanPoint => [ + wall.start[0] + dirX * along + outwardNormal[0] * datumDistance, + wall.start[1] + dirZ * along + outwardNormal[1] * datumDistance, + ] + + const openings = ctx.children + .filter((child): child is OpeningNode => child.type === 'door' || child.type === 'window') + .flatMap((opening) => { + const halfWidth = Math.max(0, opening.width) / 2 + const start = clamp(opening.position[0] - halfWidth, 0, wallLength) + const end = clamp(opening.position[0] + halfWidth, 0, wallLength) + return end - start >= MIN_SEGMENT_LENGTH ? ([start, end] as const) : [] + }) + + const dimensions: FloorplanGeometry[] = [] + if (openings.length > 0) { + const breakpoints = uniqueSorted([0, wallLength, ...openings.flat()]) + for (let index = 0; index < breakpoints.length - 1; index++) { + const start = breakpoints[index]! + const end = breakpoints[index + 1]! + if (end - start < MIN_SEGMENT_LENGTH) continue + dimensions.push( + dimension( + pointAt(start), + pointAt(end), + outwardNormal, + standard.openingChainOffset, + unit, + stroke, + undefined, + undefined, + profile, + standard, + ), + ) + } + } + + dimensions.push( + dimension( + pointAt(0), + pointAt(wallLength), + outwardNormal, + openings.length > 0 ? standard.wallSpanOffset : standard.openingChainOffset, + unit, + stroke, + undefined, + undefined, + profile, + standard, + ), + ) + + return dimensions +} + +function dimension( + start: FloorplanPoint, + end: FloorplanPoint, + offsetNormal: FloorplanPoint, + offsetDistance: number, + unit: ConstructionLinearUnit, + stroke?: string, + dimensionStart?: FloorplanPoint, + dimensionEnd?: FloorplanPoint, + profile: ConstructionLengthProfile = 'editor', + standard: ConstructionDimensionDrawingStandard = DEFAULT_CONSTRUCTION_DIMENSION_STANDARD, + textPrefix?: string, +): FloorplanGeometry { + const measurementStart = dimensionStart ?? start + const measurementEnd = dimensionEnd ?? end + return buildDimensionStringGeometry({ + segments: [ + { + witnessStart: start, + witnessEnd: end, + dimensionStart, + dimensionEnd, + text: constructionDimensionText( + measurementStart, + measurementEnd, + unit, + profile, + standard, + textPrefix, + ), + }, + ], + offsetNormal, + offsetDistance, + extensionStartGap: standard.extensionStartGap, + extensionOvershoot: standard.extensionOvershoot, + terminator: standard.terminator, + textPosition: standard.textPosition, + stroke, + }) +} + +function constructionDimensionText( + start: FloorplanPoint, + end: FloorplanPoint, + unit: ConstructionLinearUnit, + profile: ConstructionLengthProfile, + standard: ConstructionDimensionDrawingStandard, + prefix?: string, +): string { + const lengthText = formatConstructionLength( + Math.hypot(end[0] - start[0], end[1] - start[1]), + unit, + profile, + { + imperialPrecision: standard.imperialPrecision, + metricNotation: standard.metricNotation, + }, + ) + return prefix ? `${prefix} ${lengthText}` : lengthText +} + +function resolveOutwardNormal( + wall: WallNode, + ctx: GeometryContext, + dirX: number, + dirZ: number, +): FloorplanPoint { + const front: FloorplanPoint = [cleanZero(-dirZ), cleanZero(dirX)] + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return front + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return negate(front) + + const walls = [ + wall, + ...ctx.siblings.filter((sibling): sibling is WallNode => sibling.type === 'wall'), + ] + let sumX = 0 + let sumZ = 0 + for (const candidate of walls) { + sumX += candidate.start[0] + candidate.end[0] + sumZ += candidate.start[1] + candidate.end[1] + } + const centroidX = sumX / (walls.length * 2) + const centroidZ = sumZ / (walls.length * 2) + const midX = (wall.start[0] + wall.end[0]) / 2 + const midZ = (wall.start[1] + wall.end[1]) / 2 + return (midX - centroidX) * front[0] + (midZ - centroidZ) * front[1] >= 0 ? front : negate(front) +} + +function exteriorNormal(wall: WallNode): FloorplanPoint | null { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_SEGMENT_LENGTH) return null + const front: FloorplanPoint = [cleanZero(-dz / length), cleanZero(dx / length)] + if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return front + if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return negate(front) + return null +} + +function isClassifiedInteriorWall(wall: WallNode): boolean { + return wall.frontSide === 'interior' && wall.backSide === 'interior' +} + +function hostedOpeningsForWall(wall: WallNode, nodes: Record): OpeningNode[] { + return Object.values(nodes).filter( + (node): node is OpeningNode => + (node.type === 'door' || node.type === 'window') && + node.visible !== false && + (node.wallId ?? node.parentId) === wall.id, + ) +} + +function shouldDimensionInteriorWall( + wall: WallNode, + walls: ReadonlyArray, + network: ReadonlyArray, +): boolean { + if (isClassifiedInteriorWall(wall)) return true + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_SEGMENT_LENGTH) return false + const tangent: FloorplanPoint = [dx / length, dz / length] + const { frontClearance, backClearance } = interiorDimensionClearances(wall, walls, tangent) + if (frontClearance === null || backClearance === null) return false + + const claimedExteriorNormal = exteriorNormal(wall) + return claimedExteriorNormal === null || isFacadeOccluded(wall, claimedExteriorNormal, network) +} + +function enclosedRoomSideNormal( + wall: WallNode, + walls: ReadonlyArray, +): FloorplanPoint | null { + const outward = exteriorNormal(wall) + if (!outward) return null + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_SEGMENT_LENGTH) return null + const tangent: FloorplanPoint = [dx / length, dz / length] + const front: FloorplanPoint = [cleanZero(-tangent[1]), cleanZero(tangent[0])] + const { frontClearance, backClearance } = interiorDimensionClearances(wall, walls, tangent) + const inward = negate(outward) + const inwardClearance = dot(inward, front) >= 0 ? frontClearance : backClearance + return inwardClearance === null ? null : inward +} + +function resolveInteriorDimensionNormal( + wall: WallNode, + walls: ReadonlyArray, + tangent: FloorplanPoint, +): FloorplanPoint { + const front: FloorplanPoint = [cleanZero(-tangent[1]), cleanZero(tangent[0])] + const back = negate(front) + const { frontClearance, backClearance } = interiorDimensionClearances(wall, walls, tangent) + + if (frontClearance !== null && backClearance === null) return front + if (backClearance !== null && frontClearance === null) return back + if (frontClearance !== null && backClearance !== null) { + if (Math.abs(frontClearance - backClearance) > FACADE_LINE_TOLERANCE) { + return frontClearance > backClearance ? front : back + } + } + + const midpoint: FloorplanPoint = [ + (wall.start[0] + wall.end[0]) / 2, + (wall.start[1] + wall.end[1]) / 2, + ] + const centroid = wallNetworkCentroid(walls) + return dot(subtract(centroid, midpoint), front) >= 0 ? front : back +} + +function interiorDimensionClearances( + wall: WallNode, + walls: ReadonlyArray, + tangent: FloorplanPoint, +): { frontClearance: number | null; backClearance: number | null } { + const front: FloorplanPoint = [cleanZero(-tangent[1]), cleanZero(tangent[0])] + const back = negate(front) + const midpoint: FloorplanPoint = [ + (wall.start[0] + wall.end[0]) / 2, + (wall.start[1] + wall.end[1]) / 2, + ] + const clearance = (normal: FloorplanPoint): number | null => { + let nearest = Number.POSITIVE_INFINITY + for (const candidate of walls) { + if (candidate.id === wall.id) continue + const hit = isCurvedWall(candidate) + ? rayArcDistance(midpoint, normal, candidate) + : raySegmentDistance(midpoint, normal, candidate.start, candidate.end) + if (hit !== null) nearest = Math.min(nearest, hit) + } + return Number.isFinite(nearest) ? nearest : null + } + const frontClearance = clearance(front) + const backClearance = clearance(back) + return { frontClearance, backClearance } +} + +function wallNetworkCentroid(walls: ReadonlyArray): FloorplanPoint { + if (walls.length === 0) return [0, 0] + let sumX = 0 + let sumY = 0 + for (const wall of walls) { + sumX += wall.start[0] + wall.end[0] + sumY += wall.start[1] + wall.end[1] + } + return [sumX / (walls.length * 2), sumY / (walls.length * 2)] +} + +function buildWallNetworkIndex( + walls: ReadonlyArray, +): Map> { + const straightWalls = walls.filter((wall) => !isCurvedWall(wall)) + const unvisited = new Set(straightWalls) + const networkById = new Map>() + + while (unvisited.size > 0) { + const seed = unvisited.values().next().value + if (!seed) break + unvisited.delete(seed) + const network = [seed] + const queue = [seed] + while (queue.length > 0) { + const current = queue.shift() + if (!current) continue + for (const candidate of unvisited) { + if (!wallSegmentsTouch(current, candidate)) continue + unvisited.delete(candidate) + network.push(candidate) + queue.push(candidate) + } + } + for (const wall of network) networkById.set(wall.id, network) + } + + return networkById +} + +function wallSegmentsTouch(left: WallNode, right: WallNode): boolean { + return ( + pointSegmentDistance(left.start, right.start, right.end) <= FACADE_LINE_TOLERANCE || + pointSegmentDistance(left.end, right.start, right.end) <= FACADE_LINE_TOLERANCE || + pointSegmentDistance(right.start, left.start, left.end) <= FACADE_LINE_TOLERANCE || + pointSegmentDistance(right.end, left.start, left.end) <= FACADE_LINE_TOLERANCE || + segmentIntersection(left.start, left.end, right.start, right.end) !== null + ) +} + +function isFacadeOccluded( + wall: WallNode, + outwardNormal: FloorplanPoint, + network: ReadonlyArray, +): boolean { + const halfThickness = (wall.thickness ?? 0.1) / 2 + const origin = addScaled( + [(wall.start[0] + wall.end[0]) / 2, (wall.start[1] + wall.end[1]) / 2], + outwardNormal, + halfThickness + FACADE_LINE_TOLERANCE, + ) + return network.some( + (candidate) => + candidate.id !== wall.id && + raySegmentDistance(origin, outwardNormal, candidate.start, candidate.end) !== null, + ) +} + +function raySegmentDistance( + rayOrigin: FloorplanPoint, + rayDirection: FloorplanPoint, + segmentStart: FloorplanPoint, + segmentEnd: FloorplanPoint, +): number | null { + const segmentDirection = subtract(segmentEnd, segmentStart) + const denominator = cross(rayDirection, segmentDirection) + if (Math.abs(denominator) < 1e-8) return null + const fromRay = subtract(segmentStart, rayOrigin) + const alongRay = cross(fromRay, segmentDirection) / denominator + const alongSegment = cross(fromRay, rayDirection) / denominator + if (alongRay <= FACADE_LINE_TOLERANCE || alongSegment < -1e-6 || alongSegment > 1 + 1e-6) { + return null + } + return alongRay +} + +function rayArcDistance( + rayOrigin: FloorplanPoint, + rayDirection: FloorplanPoint, + wall: WallNode, +): number | null { + const arc = getWallArcData(wall) + if (!arc) return null + const fromCenter: FloorplanPoint = [rayOrigin[0] - arc.center.x, rayOrigin[1] - arc.center.y] + const directionLengthSquared = dot(rayDirection, rayDirection) + const linear = 2 * dot(fromCenter, rayDirection) + const constant = dot(fromCenter, fromCenter) - arc.radius * arc.radius + const discriminant = linear * linear - 4 * directionLengthSquared * constant + if (discriminant < 0 || directionLengthSquared < 1e-12) return null + + const root = Math.sqrt(Math.max(0, discriminant)) + const denominator = 2 * directionLengthSquared + const hits = [(-linear - root) / denominator, (-linear + root) / denominator] + .filter((distance) => distance > FACADE_LINE_TOLERANCE) + .sort((left, right) => left - right) + for (const distance of hits) { + const point: FloorplanPoint = [ + rayOrigin[0] + rayDirection[0] * distance, + rayOrigin[1] + rayDirection[1] * distance, + ] + const angle = Math.atan2(point[1] - arc.center.y, point[0] - arc.center.x) + if (angleFallsOnArc(angle, arc.startAngle, arc.delta)) return distance + } + return null +} + +function angleFallsOnArc(angle: number, startAngle: number, delta: number): boolean { + const fullTurn = Math.PI * 2 + const positiveTurn = (value: number) => ((value % fullTurn) + fullTurn) % fullTurn + const swept = delta >= 0 ? positiveTurn(angle - startAngle) : positiveTurn(startAngle - angle) + return swept <= Math.abs(delta) + 1e-8 +} + +function splitConnectedFacadeComponents(members: FacadeMember[]): FacadeMember[][] { + const unvisited = new Set(members) + const components: FacadeMember[][] = [] + + while (unvisited.size > 0) { + const seed = unvisited.values().next().value + if (!seed) break + unvisited.delete(seed) + const component = [seed] + const queue = [seed] + while (queue.length > 0) { + const current = queue.shift() + if (!current) continue + for (const candidate of unvisited) { + if (!wallsTouch(current.wall, candidate.wall)) continue + unvisited.delete(candidate) + component.push(candidate) + queue.push(candidate) + } + } + components.push(component) + } + + return components +} + +function wallsTouch(left: WallNode, right: WallNode): boolean { + return [left.start, left.end].some((leftPoint) => + [right.start, right.end].some( + (rightPoint) => distance(leftPoint, rightPoint) <= FACADE_LINE_TOLERANCE, + ), + ) +} + +function connectedWallComponent(wall: WallNode, candidates: ReadonlyArray): WallNode[] { + const unvisited = new Set( + candidates.filter( + (candidate) => candidate.id !== wall.id && candidate.parentId === wall.parentId, + ), + ) + const component = [wall] + const queue = [wall] + + while (queue.length > 0) { + const current = queue.shift() + if (!current) continue + for (const candidate of unvisited) { + if (!wallsTouch(current, candidate)) continue + unvisited.delete(candidate) + component.push(candidate) + queue.push(candidate) + } + } + + return component +} + +function groupFacadeMembersByDirection( + members: readonly FacadeMember[], +): Map { + const groups = new Map() + for (const member of members) { + const key = `${Math.round(member.normal[0] / FACADE_DIRECTION_TOLERANCE)},${Math.round(member.normal[1] / FACADE_DIRECTION_TOLERANCE)}` + const group = groups.get(key) + if (group) group.push(member) + else groups.set(key, [member]) + } + return groups +} + +function groupFacadeMembersByLine( + members: readonly FacadeMember[], + normal: FloorplanPoint, +): Map { + const groups = new Map() + for (const member of members) { + const midpoint: FloorplanPoint = [ + (member.wall.start[0] + member.wall.end[0]) / 2, + (member.wall.start[1] + member.wall.end[1]) / 2, + ] + const key = Math.round(dot(midpoint, normal) / FACADE_LINE_TOLERANCE) + const group = groups.get(key) + if (group) group.push(member) + else groups.set(key, [member]) + } + return groups +} + +function appendFacadeRunDimensions( + pending: PendingConstructionDimension[], + members: readonly FacadeMember[], + walls: ReadonlyArray, + nodes: Record, + interiorWallIds: ReadonlySet, + normal: FloorplanPoint, + tangent: FloorplanPoint, + standard: ConstructionDimensionDrawingStandard, +): void { + const [extentStart, extentEnd] = facadeStructuralExtents(members, walls, tangent) + if (extentEnd - extentStart < MIN_SEGMENT_LENGTH) return + + const faceCoordinate = Math.max( + ...members.map(({ wall }) => + exteriorFaceCoordinate(wall, normal, EXTERIOR_CORNER_DATUM_POLICY), + ), + ) + const pointAt = (projection: number): FloorplanPoint => + pointFromCoordinates(projection, faceCoordinate, tangent, normal) + const openingCenters: number[] = [] + const openingSpans: Array = [] + + for (const { wall } of members) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_SEGMENT_LENGTH) continue + for (const opening of Object.values(nodes)) { + if (opening.type !== 'door' && opening.type !== 'window') continue + if (opening.visible === false) continue + if ((opening.wallId ?? opening.parentId) !== wall.id) continue + const along = clamp(opening.position[0], 0, length) + const center: FloorplanPoint = [ + wall.start[0] + (dx / length) * along, + wall.start[1] + (dz / length) * along, + ] + const documentation = resolveOpeningDimensionDocumentation(opening) + if (documentation.locationPolicy === 'centerline') openingCenters.push(dot(center, tangent)) + if (documentation.width === null) continue + const halfWidth = Math.max(0, documentation.width) / 2 + const startProjection = dot( + [ + wall.start[0] + (dx / length) * clamp(along - halfWidth, 0, length), + wall.start[1] + (dz / length) * clamp(along - halfWidth, 0, length), + ], + tangent, + ) + const endProjection = dot( + [ + wall.start[0] + (dx / length) * clamp(along + halfWidth, 0, length), + wall.start[1] + (dz / length) * clamp(along + halfWidth, 0, length), + ], + tangent, + ) + if (Math.abs(endProjection - startProjection) >= MIN_SEGMENT_LENGTH) { + openingSpans.push([ + Math.min(startProjection, endProjection), + Math.max(startProjection, endProjection), + documentation.prefix, + ]) + } + } + } + + for (const [startProjection, endProjection, textPrefix] of openingSpans.sort( + (left, right) => left[0] - right[0], + )) { + pending.push({ + tier: 'opening-widths', + start: pointAt(startProjection), + end: pointAt(endProjection), + startProjection, + endProjection, + textPrefix, + }) + } + appendReferenceTier(pending, openingCenters, extentStart, extentEnd, pointAt, 'openings') + + const memberIds = new Set(members.map(({ wall }) => wall.id)) + const partitionReferences: number[] = [] + for (const candidate of walls) { + if ( + memberIds.has(candidate.id) || + isCurvedWall(candidate) || + !interiorWallIds.has(candidate.id) + ) { + continue + } + const intersections = members.flatMap(({ wall }) => + facadePartitionFaceIntersections(wall, candidate, normal, standard.datumPolicy), + ) + const references = intersections.map((point) => dot(point, tangent)) + const canonicalStructuralFace = selectCanonicalWallFaceIntersection(intersections, candidate) + const selectedReferences = + standard.intersectionReferencePolicy === 'both-faces' + ? uniqueSorted(references) + : standard.datumPolicy === 'structural-face' && canonicalStructuralFace + ? [dot(canonicalStructuralFace, tangent)] + : [Math.min(...references)] + for (const selectedReference of selectedReferences) { + if ( + Number.isFinite(selectedReference) && + selectedReference > extentStart + MIN_SEGMENT_LENGTH && + selectedReference < extentEnd - MIN_SEGMENT_LENGTH + ) { + partitionReferences.push(selectedReference) + } + } + } + appendReferenceTier(pending, partitionReferences, extentStart, extentEnd, pointAt, 'partitions') +} + +function selectCanonicalWallFaceIntersection( + intersections: readonly FloorplanPoint[], + wall: WallNode, +): FloorplanPoint | undefined { + const direction = subtract(wall.end, wall.start) + const length = Math.hypot(direction[0], direction[1]) + if (length < MIN_SEGMENT_LENGTH) return undefined + + let tangent: FloorplanPoint = [direction[0] / length, direction[1] / length] + if ( + tangent[0] < -FACADE_DIRECTION_TOLERANCE || + (Math.abs(tangent[0]) <= FACADE_DIRECTION_TOLERANCE && tangent[1] < 0) + ) { + tangent = negate(tangent) + } + const canonicalFaceNormal: FloorplanPoint = [-tangent[1], tangent[0]] + return intersections.reduce((selected, intersection) => { + if (!selected) return intersection + return dot(intersection, canonicalFaceNormal) > dot(selected, canonicalFaceNormal) + ? intersection + : selected + }, undefined) +} + +function appendReferenceTier( + pending: PendingConstructionDimension[], + references: number[], + extentStart: number, + extentEnd: number, + pointAt: (projection: number) => FloorplanPoint, + tier: 'openings' | 'partitions', +): void { + const interiorReferences = uniqueSorted(references).filter( + (value) => value > extentStart + MIN_SEGMENT_LENGTH && value < extentEnd - MIN_SEGMENT_LENGTH, + ) + if (interiorReferences.length === 0) return + appendProjectedChain(pending, [extentStart, ...interiorReferences, extentEnd], tier, pointAt) +} + +function appendProjectedChain( + pending: PendingConstructionDimension[], + projections: number[], + tier: ConstructionDimensionTier, + originAt: (projection: number) => FloorplanPoint, +): void { + const breakpoints = uniqueSorted(projections) + for (let index = 0; index < breakpoints.length - 1; index++) { + const startProjection = breakpoints[index] + const endProjection = breakpoints[index + 1] + if ( + startProjection === undefined || + endProjection === undefined || + endProjection - startProjection < MIN_SEGMENT_LENGTH + ) { + continue + } + pending.push({ + tier, + start: originAt(startProjection), + end: originAt(endProjection), + startProjection, + endProjection, + }) + } +} + +function finalizeDimensionTiers( + pending: PendingConstructionDimension[], + tangent: FloorplanPoint, + normal: FloorplanPoint, + outerCoordinate: number, + standard: ConstructionDimensionDrawingStandard, +): PlannedConstructionDimension[] { + const activeTiers = TIER_ORDER.filter((tier) => pending.some((entry) => entry.tier === tier)) + const offsets = new Map() + activeTiers.forEach((tier, index) => { + const firstOffset = + activeTiers[0] === 'opening-widths' + ? standard.firstOpeningWidthOffset + : standard.firstGeneralTierOffset + offsets.set(tier, firstOffset + index * standard.tierSpacing) + }) + + return [...pending] + .sort((left, right) => { + const tierDelta = TIER_ORDER.indexOf(left.tier) - TIER_ORDER.indexOf(right.tier) + return tierDelta || left.startProjection - right.startProjection + }) + .map((entry) => { + const offset = offsets.get(entry.tier) ?? standard.firstGeneralTierOffset + const baselineCoordinate = outerCoordinate + offset + const dimensionStart = pointFromCoordinates( + entry.startProjection, + baselineCoordinate, + tangent, + normal, + ) + const dimensionEnd = pointFromCoordinates( + entry.endProjection, + baselineCoordinate, + tangent, + normal, + ) + return { + tier: entry.tier, + start: entry.start, + end: entry.end, + dimensionStart, + dimensionEnd, + offsetNormal: normal, + offsetDistance: Math.max(0, dot(subtract(dimensionStart, entry.start), normal)), + textPrefix: entry.textPrefix, + } + }) +} + +function facadeStructuralExtents( + members: readonly FacadeMember[], + walls: ReadonlyArray, + tangent: FloorplanPoint, +): readonly [number, number] { + const endpoints = members.flatMap(({ wall }) => [wall.start, wall.end]) + const centerlineProjections = endpoints.map((point) => dot(point, tangent)) + const centerlineStart = Math.min(...centerlineProjections) + const centerlineEnd = Math.max(...centerlineProjections) + + const structuralProjectionsAt = (targetProjection: number): number[] => + endpoints + .filter( + (endpoint) => Math.abs(dot(endpoint, tangent) - targetProjection) <= FACADE_LINE_TOLERANCE, + ) + .flatMap((endpoint) => + walls.flatMap((candidate): number[] => { + if ( + isCurvedWall(candidate) || + (distance(endpoint, candidate.start) > FACADE_LINE_TOLERANCE && + distance(endpoint, candidate.end) > FACADE_LINE_TOLERANCE) + ) { + return [] + } + const direction = subtract(candidate.end, candidate.start) + const length = Math.hypot(direction[0], direction[1]) + if (length < MIN_SEGMENT_LENGTH) return [] + const normal: FloorplanPoint = [-direction[1] / length, direction[0] / length] + return wallDatumOffsets(candidate, EXTERIOR_CORNER_DATUM_POLICY).map((offset) => + dot(addScaled(endpoint, normal, offset), tangent), + ) + }), + ) + + return [ + Math.min(centerlineStart, ...structuralProjectionsAt(centerlineStart)), + Math.max(centerlineEnd, ...structuralProjectionsAt(centerlineEnd)), + ] +} + +function exteriorFaceCoordinate( + wall: WallNode, + normal: FloorplanPoint, + datumPolicy: ConstructionDimensionDrawingStandard['datumPolicy'], +): number { + const midpoint: FloorplanPoint = [ + (wall.start[0] + wall.end[0]) / 2, + (wall.start[1] + wall.end[1]) / 2, + ] + return dot(midpoint, normal) + wallDatumDistanceToward(wall, datumPolicy, normal) +} + +function curvedFacadeOuterFaceCoordinates( + walls: ReadonlyArray, + members: readonly FacadeMember[], + normal: FloorplanPoint, + datumPolicy: ConstructionDimensionDrawingStandard['datumPolicy'], +): number[] { + const parentId = members[0]?.wall.parentId + const memberEndpoints = members.flatMap(({ wall }) => [wall.start, wall.end]) + const touchesFacade = (point: FloorplanPoint) => + memberEndpoints.some((endpoint) => distance(point, endpoint) <= FACADE_LINE_TOLERANCE) + + return walls.flatMap((wall): number[] => { + if ( + wall.parentId !== parentId || + !isCurvedWall(wall) || + !touchesFacade(wall.start) || + !touchesFacade(wall.end) + ) { + return [] + } + + const midpoint = getWallMidpointHandlePoint(wall) + const outerCenterlineCoordinate = Math.max( + dot(wall.start, normal), + dot(wall.end, normal), + dot([midpoint.x, midpoint.y], normal), + ) + return [outerCenterlineCoordinate + wallDatumDistanceToward(wall, datumPolicy, normal)] + }) +} + +function exteriorOriginAtProjection( + members: readonly FacadeMember[], + projection: number, + tangent: FloorplanPoint, + normal: FloorplanPoint, + datumPolicy: ConstructionDimensionDrawingStandard['datumPolicy'], +): FloorplanPoint { + const endpoint = members + .flatMap(({ wall }) => [ + { point: wall.start, wall }, + { point: wall.end, wall }, + ]) + .sort((left, right) => { + const projectionDelta = + Math.abs(dot(left.point, tangent) - projection) - + Math.abs(dot(right.point, tangent) - projection) + return ( + projectionDelta || + exteriorFaceCoordinate(right.wall, normal, datumPolicy) - + exteriorFaceCoordinate(left.wall, normal, datumPolicy) + ) + })[0] + if (!endpoint) return pointFromCoordinates(projection, 0, tangent, normal) + return pointFromCoordinates( + projection, + exteriorFaceCoordinate(endpoint.wall, normal, datumPolicy), + tangent, + normal, + ) +} + +function outermostColumnRow( + columns: readonly ColumnNode[], + component: readonly FacadeMember[], + normal: FloorplanPoint, + tangent: FloorplanPoint, +): ColumnNode[] { + if (columns.length < 2) return [] + const centroid = facadeCentroid(component) + const outwardColumns = columns.filter( + (column) => dot(subtract(columnPlanPoint(column), centroid), normal) >= -COLUMN_ROW_TOLERANCE, + ) + const sorted = [...outwardColumns].sort( + (left, right) => dot(columnPlanPoint(right), normal) - dot(columnPlanPoint(left), normal), + ) + const outerCoordinate = sorted[0] ? dot(columnPlanPoint(sorted[0]), normal) : 0 + return sorted + .filter( + (column) => + Math.abs(dot(columnPlanPoint(column), normal) - outerCoordinate) <= COLUMN_ROW_TOLERANCE, + ) + .sort( + (left, right) => dot(columnPlanPoint(left), tangent) - dot(columnPlanPoint(right), tangent), + ) +} + +function columnOriginAtProjection( + columns: readonly ColumnNode[], + projection: number, + tangent: FloorplanPoint, +): FloorplanPoint { + return columnPlanPoint( + [...columns].sort( + (left, right) => + Math.abs(dot(columnPlanPoint(left), tangent) - projection) - + Math.abs(dot(columnPlanPoint(right), tangent) - projection), + )[0]!, + ) +} + +function columnPlanPoint(column: ColumnNode): FloorplanPoint { + return [column.position[0], column.position[2]] +} + +function columnNormalHalfExtent(column: ColumnNode, normal: FloorplanPoint): number { + const center = columnPlanPoint(column) + return Math.max( + ...getColumnFloorplanFootprint(column).map((point) => dot(subtract(point, center), normal)), + ) +} + +function nearestFacadeComponent( + column: ColumnNode, + components: readonly FacadeMember[][], +): FacadeMember[] | undefined { + const point = columnPlanPoint(column) + return [...components] + .filter((component) => component[0]?.wall.parentId === column.parentId) + .sort( + (left, right) => + distanceToFacadeComponent(point, left) - distanceToFacadeComponent(point, right), + )[0] +} + +function distanceToFacadeComponent( + point: FloorplanPoint, + component: readonly FacadeMember[], +): number { + return Math.min(...component.map(({ wall }) => pointSegmentDistance(point, wall.start, wall.end))) +} + +function facadeCentroid(component: readonly FacadeMember[]): FloorplanPoint { + const points = component.flatMap(({ wall }) => [wall.start, wall.end]) + return [ + points.reduce((sum, point) => sum + point[0], 0) / points.length, + points.reduce((sum, point) => sum + point[1], 0) / points.length, + ] +} + +function pointFromCoordinates( + tangentCoordinate: number, + normalCoordinate: number, + tangent: FloorplanPoint, + normal: FloorplanPoint, +): FloorplanPoint { + return [ + tangent[0] * tangentCoordinate + normal[0] * normalCoordinate, + tangent[1] * tangentCoordinate + normal[1] * normalCoordinate, + ] +} + +function splitFacadeRuns(members: FacadeMember[]): FacadeMember[][] { + const tangent = members[0]?.tangent + if (!tangent) return [] + const sorted = [...members].sort((left, right) => { + const leftStart = Math.min(dot(left.wall.start, tangent), dot(left.wall.end, tangent)) + const rightStart = Math.min(dot(right.wall.start, tangent), dot(right.wall.end, tangent)) + return leftStart - rightStart + }) + const runs: FacadeMember[][] = [] + let runEnd = Number.NEGATIVE_INFINITY + for (const member of sorted) { + const start = Math.min(dot(member.wall.start, tangent), dot(member.wall.end, tangent)) + const end = Math.max(dot(member.wall.start, tangent), dot(member.wall.end, tangent)) + const current = runs.at(-1) + if (!current || start > runEnd + FACADE_LINE_TOLERANCE) { + runs.push([member]) + runEnd = end + } else { + current.push(member) + runEnd = Math.max(runEnd, end) + } + } + return runs +} + +function facadePartitionFaceIntersections( + facade: WallNode, + candidate: WallNode, + outwardNormal: FloorplanPoint, + datumPolicy: ConstructionDimensionDrawingStandard['datumPolicy'], +): FloorplanPoint[] { + const halfThickness = wallDatumDistanceToward(facade, 'wall-face', outwardNormal) + const insideStart: FloorplanPoint = [ + facade.start[0] - outwardNormal[0] * halfThickness, + facade.start[1] - outwardNormal[1] * halfThickness, + ] + const insideEnd: FloorplanPoint = [ + facade.end[0] - outwardNormal[0] * halfThickness, + facade.end[1] - outwardNormal[1] * halfThickness, + ] + const dx = candidate.end[0] - candidate.start[0] + const dz = candidate.end[1] - candidate.start[1] + const length = Math.hypot(dx, dz) + if (length < MIN_SEGMENT_LENGTH) return [] + const candidateNormal: FloorplanPoint = [-dz / length, dx / length] + const candidateOffsets = wallDatumOffsets(candidate, datumPolicy) + + return candidateOffsets.flatMap((offset): FloorplanPoint[] => { + const faceStart = addScaled(candidate.start, candidateNormal, offset) + const faceEnd = addScaled(candidate.end, candidateNormal, offset) + const intersection = segmentIntersection(insideStart, insideEnd, faceStart, faceEnd) + return intersection ? [intersection] : [] + }) +} + +function wallDatumOffsets( + wall: WallNode, + policy: ConstructionDimensionDrawingStandard['datumPolicy'], +): number[] { + if (policy === 'centerline') return [0] + return [wallDatumOffsetOnSide(wall, policy, -1), wallDatumOffsetOnSide(wall, policy, 1)] +} + +function wallDatumOffsetOnSide( + wall: WallNode, + policy: ConstructionDimensionDrawingStandard['datumPolicy'], + side: 1 | -1, +): number { + const faces = getWallAssemblyFaceOffsets(wall) + if (policy === 'wall-face') return side > 0 ? faces.exterior : faces.interior + if (policy === 'centerline') return 0 + + const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face' + const candidates = resolveWallAssemblyDatumReferences(wall) + .filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side) + .map((reference) => reference.offset) + if (candidates.length === 0) return side > 0 ? faces.exterior : faces.interior + return side > 0 ? Math.max(...candidates) : Math.min(...candidates) +} + +function wallDatumDistanceToward( + wall: WallNode, + policy: ConstructionDimensionDrawingStandard['datumPolicy'], + direction: FloorplanPoint, +): number { + if (policy === 'centerline') return 0 + const wallDirection = subtract(wall.end, wall.start) + const length = Math.hypot(wallDirection[0], wallDirection[1]) + if (length < MIN_SEGMENT_LENGTH) return 0 + const positiveNormal: FloorplanPoint = [-wallDirection[1] / length, wallDirection[0] / length] + const side: 1 | -1 = dot(positiveNormal, direction) >= 0 ? 1 : -1 + return Math.abs(wallDatumOffsetOnSide(wall, policy, side)) +} + +function maximumWallDatumDistance( + wall: WallNode, + policy: ConstructionDimensionDrawingStandard['datumPolicy'], +): number { + return Math.max(...wallDatumOffsets(wall, policy).map(Math.abs)) +} + +function segmentIntersection( + aStart: FloorplanPoint, + aEnd: FloorplanPoint, + bStart: FloorplanPoint, + bEnd: FloorplanPoint, +): FloorplanPoint | null { + const ax = aEnd[0] - aStart[0] + const ay = aEnd[1] - aStart[1] + const bx = bEnd[0] - bStart[0] + const by = bEnd[1] - bStart[1] + const denominator = ax * by - ay * bx + if (Math.abs(denominator) < 1e-8) return null + + const qx = bStart[0] - aStart[0] + const qy = bStart[1] - aStart[1] + const alongA = (qx * by - qy * bx) / denominator + const alongB = (qx * ay - qy * ax) / denominator + if (alongA < -1e-6 || alongA > 1 + 1e-6 || alongB < -1e-6 || alongB > 1 + 1e-6) { + return null + } + return [aStart[0] + ax * alongA, aStart[1] + ay * alongA] +} + +function pointSegmentDistance( + point: FloorplanPoint, + start: FloorplanPoint, + end: FloorplanPoint, +): number { + const segment = subtract(end, start) + const lengthSquared = dot(segment, segment) + if (lengthSquared < 1e-12) return distance(point, start) + const along = clamp(dot(subtract(point, start), segment) / lengthSquared, 0, 1) + return distance(point, addScaled(start, segment, along)) +} + +function distance(left: FloorplanPoint, right: FloorplanPoint): number { + return Math.hypot(left[0] - right[0], left[1] - right[1]) +} + +function subtract(left: FloorplanPoint, right: FloorplanPoint): FloorplanPoint { + 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] +} + +function dot(left: FloorplanPoint, right: FloorplanPoint): number { + return left[0] * right[0] + left[1] * right[1] +} + +function cross(left: FloorplanPoint, right: FloorplanPoint): number { + return left[0] * right[1] - left[1] * right[0] +} + +function negate(point: FloorplanPoint): FloorplanPoint { + return [cleanZero(-point[0]), cleanZero(-point[1])] +} + +function cleanZero(value: number): number { + return Object.is(value, -0) ? 0 : value +} + +function uniqueSorted(values: number[]): number[] { + const sorted = [...values].sort((a, b) => a - b) + return sorted.filter((value, index) => index === 0 || Math.abs(value - sorted[index - 1]!) > 1e-6) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts new file mode 100644 index 00000000..63f31194 --- /dev/null +++ b/packages/nodes/src/wall/definition.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { getFloorplanNodeExtension } from '@pascal-app/editor' +import { wallDefinition } from './definition' + +describe('wallDefinition floor-plan extension', () => { + test('owns curve eligibility for hosted openings', () => { + const wall = wallDefinition.schema.parse({ + id: 'wall_test', + children: ['door_test'], + start: [0, 0], + end: [4, 0], + }) + const canCurve = getFloorplanNodeExtension(wallDefinition)?.actionMenu?.canCurve + const nodes = { + [wall.id]: wall, + door_test: { + object: 'node', + id: 'door_test', + type: 'door', + parentId: wall.id, + visible: true, + metadata: {}, + } as AnyNode, + } as Record + + expect(canCurve?.({ node: wall, nodes })).toBe(false) + expect(canCurve?.({ node: { ...wall, children: [] }, nodes })).toBe(true) + }) +}) diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 73c0acea..278b893c 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -1,4 +1,5 @@ -import type { NodeDefinition } from '@pascal-app/core' +import type { AnyNodeId, NodeDefinition } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan' import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances' import { wallFloorplanMoveTarget } from './floorplan-move' @@ -32,10 +33,24 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 5, + schemaVersion: 6, schema: WallNode, category: 'structure', surfaceRole: 'wall', + extensions: { + 'pascal:editor/floorplan': { + actionMenu: { + canCurve: ({ node, nodes }) => + !node.children.some((childId) => { + const child = nodes[childId as AnyNodeId] + if (!child) return false + if (child.type === 'door' || child.type === 'window') return true + if (child.type !== 'item') return false + return child.asset?.attachTo === 'wall' || child.asset?.attachTo === 'wall-side' + }), + }, + } satisfies FloorplanNodeExtension, + }, defaults: () => ({ object: 'node', @@ -43,6 +58,7 @@ export const wallDefinition: NodeDefinition = { visible: true, metadata: {}, children: [], + assemblyLayers: [], start: [0, 0], end: [3, 0], frontSide: 'unknown', diff --git a/packages/nodes/src/wall/floorplan-affordances.ts b/packages/nodes/src/wall/floorplan-affordances.ts index 4bb0c18d..86dd79df 100644 --- a/packages/nodes/src/wall/floorplan-affordances.ts +++ b/packages/nodes/src/wall/floorplan-affordances.ts @@ -169,6 +169,18 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const originalEnd: WallPlanPoint = [...node.end] as WallPlanPoint const linkedWalls = collectLinkedWalls(nodes, node.id, originalStart, originalEnd) const affectedIds: AnyNodeId[] = [node.id, ...linkedWalls.map((w) => w.id)] + const movingOriginal: WallPlanPoint = endpoint === 'start' ? originalStart : originalEnd + // Walls attached to the MOVING corner cascade with the drag, but the snap + // pipeline reads the scene store, which keeps their pre-drag coordinates + // until commit. Their stale corners would recreate the old junction as a + // snap/alignment target: inside the connect radius the endpoint could + // never land closer than ~5cm to where it started, making sub-5cm + // corrections (e.g. squaring a scan-imported 91° corner) impossible. + // Excluded while attached; under Alt-detach they stay put and remain + // legitimate targets. Mirrors the 3D move-endpoint tool. + const movingLinkedWallIds = linkedWalls + .filter((w) => pointsEqual(w.start, movingOriginal) || pointsEqual(w.end, movingOriginal)) + .map((w) => w.id) // Remember the latest preview so `commit()` can write it tracked. let lastPrimaryStart: WallPlanPoint = originalStart @@ -181,11 +193,12 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // Re-collect walls every tick so the snap pipeline sees fresh // positions (matters when the user releases + re-grabs without // unmounting the layer). Snap reads from scene — which holds - // the pre-drag positions throughout — so the linked-wall snap - // targets stay anchored to where corners *were*, exactly like - // the legacy flow. + // the pre-drag positions throughout — so walls that cascade with + // the moving corner are excluded (stale coordinates); under + // Alt-detach they stay put, so they rejoin the candidate pool. const sceneNodes = useScene.getState().nodes const walls = collectLevelWalls(sceneNodes, node.id) + const staleWallIds = modifiers.altKey ? [node.id] : [node.id, ...movingLinkedWallIds] // The grid step follows the active snapping mode (`getSegmentGridStep()` // is 0 outside grid mode), so `'lines' / 'angles' / 'off'` no longer // force a grid snap the mode chip says is inactive. In `'angles'` mode @@ -195,7 +208,7 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { const snapped = snapWallDraftPoint({ point: planPoint as WallPlanPoint, walls, - ignoreWallIds: [node.id], + ignoreWallIds: staleWallIds, start: angleLocked ? fixedPoint : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), @@ -205,13 +218,15 @@ export const wallMoveEndpointAffordance: FloorplanAffordance = { // object's edge / wall face and publishes a guide. The guide is // DISPLAYED in every mode except Off (isAlignmentGuideActive); the // magnetic pull onto it is applied only in 'lines' mode - // (isMagneticSnapActive), like the draft tool does. The dragged wall and - // its linked siblings (which cascade with the corner) are excluded from - // the candidate pool. Alt is detach, NOT bypass. + // (isMagneticSnapActive), like the draft tool does. Only the dragged + // wall and the siblings cascading with the moving corner are excluded + // from the candidate pool — walls linked at the FIXED corner don't + // move, and their anchors are what let the dragged corner align back + // onto a true axis. Alt is detach, NOT bypass. const aligned = alignFloorplanDraftPoint(snapped, { applySnap: isMagneticSnapActive(), bypass: !isAlignmentGuideActive(), - excludeIds: [node.id, ...linkedWalls.map((w) => w.id)], + excludeIds: staleWallIds, }) as WallPlanPoint const primaryStart: WallPlanPoint = endpoint === 'start' ? aligned : fixedPoint diff --git a/packages/nodes/src/wall/floorplan-overrides.test.ts b/packages/nodes/src/wall/floorplan-overrides.test.ts new file mode 100644 index 00000000..fd41f74a --- /dev/null +++ b/packages/nodes/src/wall/floorplan-overrides.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, ItemNode, WallNode } from '@pascal-app/core' +import { wallFloorplanSiblingOverrides } from './floorplan-overrides' + +describe('wallFloorplanSiblingOverrides', () => { + test('projects live wall and opening positions without changing unrelated nodes', () => { + const wall = WallNode.parse({ + id: 'wall_main', + parentId: 'level_main', + start: [0, 0], + end: [10, 0], + }) + const door = DoorNode.parse({ + id: 'door_main', + parentId: wall.id, + position: [2, 1, 0], + }) + const item = ItemNode.parse({ + id: 'item_main', + parentId: 'level_main', + position: [0, 0, 0], + asset: { + id: 'asset_item', + category: 'test', + name: 'Test item', + thumbnail: '/test.png', + src: '/test.glb', + }, + }) + const nodes = { [wall.id]: wall, [door.id]: door, [item.id]: item } as Record + + const result = wallFloorplanSiblingOverrides({ + nodeId: wall.id, + nodes, + liveTransforms: new Map([[door.id, { position: [6, 1, 0], rotation: 0 }]]), + liveOverrides: new Map([ + [wall.id, { end: [12, 0] }], + [door.id, { position: [5, 1, 0] }], + [item.id, { position: [3, 0, 0] }], + ]), + }) + + expect(result).not.toBe(nodes) + expect(result[wall.id]).toMatchObject({ end: [12, 0] }) + expect(result[door.id]).toMatchObject({ position: [6, 1, 0] }) + expect(result[item.id]).toBe(item) + }) +}) diff --git a/packages/nodes/src/wall/floorplan-overrides.ts b/packages/nodes/src/wall/floorplan-overrides.ts index d069d152..c1a450c8 100644 --- a/packages/nodes/src/wall/floorplan-overrides.ts +++ b/packages/nodes/src/wall/floorplan-overrides.ts @@ -1,34 +1,46 @@ -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, LiveTransform } from '@pascal-app/core' /** - * Project per-frame wall drag overrides (`{ start, end, curveOffset }`) - * from `useLiveNodeOverrides` into a fresh `nodes` snapshot. The 2D drag + * Project per-frame wall and opening drag overrides into a fresh `nodes` + * snapshot. Wall overrides keep shared miters current; door and window + * overrides keep associative construction dimensions current while an + * opening moves or changes host. The 2D drag * handlers publish overrides for the moved wall plus its linked * neighbours; the floor-plan layer hands the merged snapshot to * `buildContext` so each wall's `ctx.siblings` (which feeds the * miter calculation) reflects the live cursor positions instead of * the last committed scene state. * - * Only wall entries are touched; every other node is shared by - * reference. The allocation cost is one shallow object per overridden - * wall — the override map is small, so this is cheap. When the + * Other node types are shared by reference. The allocation cost is one + * shallow object per relevant override — the override map is small, so + * this is cheap. When the * override map is empty (no live drag) the input is returned * unchanged. */ export function wallFloorplanSiblingOverrides(args: { nodeId: AnyNodeId nodes: Record + liveTransforms?: Map liveOverrides: Map> }): Record { - const { nodes, liveOverrides } = args - if (liveOverrides.size === 0) return nodes + const { nodes, liveOverrides, liveTransforms } = args + if (liveOverrides.size === 0 && !liveTransforms?.size) return nodes let out: Record | null = null - for (const [id, override] of liveOverrides) { + const ids = new Set([...liveOverrides.keys(), ...(liveTransforms?.keys() ?? [])]) + for (const id of ids) { const existing = nodes[id as AnyNodeId] - if (existing?.type !== 'wall') continue - if (Object.keys(override).length === 0) continue + if (existing?.type !== 'wall' && existing?.type !== 'door' && existing?.type !== 'window') { + continue + } + const override = liveOverrides.get(id) + const liveTransform = liveTransforms?.get(id) + const livePosition = + liveTransform && (existing.type === 'door' || existing.type === 'window') + ? { position: liveTransform.position } + : undefined + if ((!override || Object.keys(override).length === 0) && !livePosition) continue if (!out) out = { ...nodes } - out[id as AnyNodeId] = { ...existing, ...override } as AnyNode + out[id as AnyNodeId] = { ...existing, ...override, ...livePosition } as AnyNode } return out ?? nodes } diff --git a/packages/nodes/src/wall/floorplan.test.ts b/packages/nodes/src/wall/floorplan.test.ts new file mode 100644 index 00000000..b828fae3 --- /dev/null +++ b/packages/nodes/src/wall/floorplan.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from 'bun:test' +import { + type FloorplanGeometry, + type FloorplanPalette, + type GeometryContext, + WallNode, +} from '@pascal-app/core' +import { createFloorplanContextExtensions, readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildWallFloorplan } from './floorplan' + +const palette: FloorplanPalette = { + selectedStroke: '#334155', + selectedFill: '#ffffff', + selectedHatch: '#334155', + wallHoverStroke: '#334155', + endpointHandleFill: '#ffffff', + endpointHandleStroke: '#334155', + endpointHandleHoverStroke: '#334155', + endpointHandleActiveFill: '#334155', + endpointHandleActiveStroke: '#334155', + curveHandleFill: '#ffffff', + curveHandleStroke: '#334155', + curveHandleHoverStroke: '#334155', + measurementStroke: '#334155', + measurementLabelBackground: '#ffffff', + measurementLabelText: '#111827', +} + +function context( + purpose: 'edit' | 'document', + selected = false, + metricNotation: 'meters' | 'millimeters' = 'meters', + wallDimensionReference: 'finished-faces' | 'centerline' | 'stud-faces' = 'finished-faces', +): GeometryContext { + return { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, + viewState: { + selected, + unit: 'metric', + highlighted: false, + hovered: false, + moving: false, + palette, + }, + extensions: createFloorplanContextExtensions({ + metricNotation, + purpose, + wallDimensionReference, + }), + } +} + +function flatten(geometry: FloorplanGeometry): FloorplanGeometry[] { + return geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flatten)] : [geometry] +} + +describe('buildWallFloorplan render purpose', () => { + const wall = WallNode.parse({ + id: 'wall_main', + parentId: 'level_main', + start: [0, 0], + end: [4, 0], + thickness: 0.1, + frontSide: 'exterior', + backSide: 'interior', + }) + + test('keeps thin walls legible in edit mode but uses modeled thickness in documents', () => { + const edit = buildWallFloorplan(wall, context('edit')) + const document = buildWallFloorplan(wall, context('document')) + const editPolygon = edit && flatten(edit).find((entry) => entry.kind === 'polygon') + const documentPolygon = document && flatten(document).find((entry) => entry.kind === 'polygon') + + expect(editPolygon?.kind).toBe('polygon') + expect(documentPolygon?.kind).toBe('polygon') + if (editPolygon?.kind !== 'polygon' || documentPolygon?.kind !== 'polygon') return + + const editThickness = + Math.max(...editPolygon.points.map((point) => point[1])) - + Math.min(...editPolygon.points.map((point) => point[1])) + const documentThickness = + Math.max(...documentPolygon.points.map((point) => point[1])) - + Math.min(...documentPolygon.points.map((point) => point[1])) + expect(editThickness).toBeCloseTo(0.13) + expect(documentThickness).toBeCloseTo(0.1) + expect(readFloorplanGeometryMetadata(editPolygon).annotationObstacle).toBe('outline') + expect(readFloorplanGeometryMetadata(documentPolygon).annotationObstacle).toBe('outline') + }) + + test('uses document metric notation only for document output', () => { + const edit = buildWallFloorplan(wall, context('edit')) + const document = buildWallFloorplan(wall, context('document')) + const texts = (geometry: FloorplanGeometry | null) => + geometry + ? flatten(geometry).flatMap((entry) => + entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [], + ) + : [] + + expect(texts(edit)).toContain('4m') + expect(texts(document)).toContain('4000') + }) + + test('uses the live millimeter notation in edit mode', () => { + const edit = buildWallFloorplan(wall, context('edit', false, 'millimeters')) + const texts = edit + ? flatten(edit).flatMap((entry) => + entry.kind === 'dimension-string' ? entry.segments.map((segment) => segment.text) : [], + ) + : [] + + expect(texts).toContain('4000') + }) + + test('keeps standalone wall witnesses on the stud face in every intersection mode', () => { + const assemblyWall = WallNode.parse({ + ...wall, + thickness: undefined, + assemblyLayers: [ + { + id: 'stud-core', + role: 'structure', + side: 'core', + thickness: 0.1, + materialRef: 'library:stud', + datumEligible: ['structural-face'], + }, + { + id: 'interior-finish', + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-finish', + role: 'exterior-finish', + side: 'exterior', + thickness: 0.03, + materialRef: 'library:cladding', + datumEligible: ['finish-face'], + }, + ], + }) + const witnessY = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => { + const geometry = buildWallFloorplan(assemblyWall, context('edit', false, 'meters', reference)) + const dimension = geometry + ? flatten(geometry).find((entry) => entry.kind === 'dimension-string') + : undefined + return dimension?.kind === 'dimension-string' ? dimension.segments[0]?.start[1] : undefined + } + + expect(witnessY('finished-faces')).toBeCloseTo(0.05) + expect(witnessY('centerline')).toBeCloseTo(0.05) + expect(witnessY('stud-faces')).toBeCloseTo(0.05) + }) + + test('uses total assembly thickness and emits construction graphics for modeled layers', () => { + const assemblyWall = WallNode.parse({ + ...wall, + thickness: undefined, + assemblyLayers: [ + { + id: 'block-core', + role: 'concrete-block', + side: 'core', + thickness: 0.19, + materialRef: 'library:cmu', + datumEligible: ['structural-face'], + }, + { + id: 'interior-furring', + role: 'furring', + side: 'interior', + thickness: 0.025, + materialRef: 'library:furring', + datumEligible: [], + }, + { + id: 'interior-gwb', + role: 'interior-finish', + side: 'interior', + thickness: 0.016, + materialRef: 'library:gypsum-board', + datumEligible: ['finish-face'], + }, + { + id: 'exterior-air-space', + role: 'air-space', + side: 'exterior', + thickness: 0.025, + materialRef: '', + datumEligible: [], + }, + { + id: 'brick-veneer', + role: 'masonry-veneer', + side: 'exterior', + thickness: 0.09, + materialRef: 'library:brick', + datumEligible: ['veneer-face'], + }, + ], + }) + + const document = buildWallFloorplan(assemblyWall, context('document')) + const entries = document ? flatten(document) : [] + const polygons = entries.filter((entry) => entry.kind === 'polygon') + const mainPolygon = polygons[0] + + expect(mainPolygon?.kind).toBe('polygon') + if (mainPolygon?.kind !== 'polygon') return + + const documentThickness = + Math.max(...mainPolygon.points.map((point) => point[1])) - + Math.min(...mainPolygon.points.map((point) => point[1])) + expect(documentThickness).toBeCloseTo(0.346) + + const layerPolygons = polygons.slice(1) + expect(layerPolygons).toHaveLength(5) + expect( + layerPolygons.every((entry) => entry.kind === 'polygon' && entry.pointerEvents === 'none'), + ).toBe(true) + expect( + layerPolygons.map((entry) => (entry.kind === 'polygon' ? entry.fill : undefined)), + ).toEqual(['#cbd5e1', '#fde68a', '#f8fafc', '#ffffff', '#fca5a5']) + + const lines = entries.filter((entry) => entry.kind === 'line') + expect(lines.some((entry) => entry.kind === 'line' && entry.stroke === '#991b1b')).toBe(true) + expect( + lines.some( + (entry) => + entry.kind === 'line' && + entry.stroke === '#64748b' && + entry.strokeDasharray === '0.035 0.025', + ), + ).toBe(true) + expect( + lines.some( + (entry) => + entry.kind === 'line' && + entry.stroke === '#92400e' && + entry.strokeDasharray === '0.04 0.02', + ), + ).toBe(true) + expect( + lines.filter( + (entry) => + entry.kind === 'line' && entry.stroke === '#111827' && entry.strokeWidth === 0.85, + ), + ).toHaveLength(2) + }) + + test('shows an orthogonal depth dimension for a curved wall without a radius leader', () => { + const curved = WallNode.parse({ ...wall, curveOffset: 1 }) + const geometry = buildWallFloorplan(curved, context('edit')) + const entries = geometry ? flatten(geometry) : [] + + expect(entries.find((entry) => entry.kind === 'dimension-label')).toBeUndefined() + expect(entries.find((entry) => entry.kind === 'dimension-string')).toMatchObject({ + kind: 'dimension-string', + segments: [{ text: '1m' }], + }) + }) + + test('places selected move arrows on the curved wall midpoint', () => { + const curved = WallNode.parse({ ...wall, curveOffset: 1 }) + const geometry = buildWallFloorplan(curved, context('edit', true)) + const arrows = geometry ? flatten(geometry).filter((entry) => entry.kind === 'move-arrow') : [] + + expect(arrows).toHaveLength(2) + expect(arrows[0]).toMatchObject({ kind: 'move-arrow', angle: Math.PI / 2 }) + expect(arrows[1]).toMatchObject({ kind: 'move-arrow', angle: -Math.PI / 2 }) + if (arrows[0]?.kind !== 'move-arrow' || arrows[1]?.kind !== 'move-arrow') return + expect(arrows[0].point[0]).toBeCloseTo(2) + expect(arrows[0].point[1]).toBeCloseTo(-0.885) + expect(arrows[1].point[0]).toBeCloseTo(2) + expect(arrows[1].point[1]).toBeCloseTo(-1.115) + }) +}) diff --git a/packages/nodes/src/wall/floorplan.ts b/packages/nodes/src/wall/floorplan.ts index 7260ef80..a2c54a09 100644 --- a/packages/nodes/src/wall/floorplan.ts +++ b/packages/nodes/src/wall/floorplan.ts @@ -4,13 +4,23 @@ import { type FloorplanGeometry, type FloorplanPoint, type GeometryContext, - getWallCurveLength, + getWallAssemblyThickness, getWallMidpointHandlePoint, getWallPlanFootprint, isCurvedWall, + type WallAssemblyLayer, type WallMiterData, type WallNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' +import { constructionDimensionStandard } from '../shared/construction-dimension-standards' +import { + buildCurvedWallConstructionDimensions, + buildLevelWallConstructionDimensionPlan, + buildWallConstructionDimensions, + renderPlannedConstructionDimensions, + type WallConstructionDimensionPlan, +} from './construction-dimensions' // Same constants the legacy `getFloorplanWall` uses (editor/lib/floorplan/walls.ts). // Slightly exaggerates thin walls so the 2D plan stays legible without @@ -18,9 +28,13 @@ import { const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18 const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13 const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035 +const FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING = 0.06 +const WALL_DIMENSION_REFERENCES = ['finished-faces', 'centerline', 'stud-faces'] as const + +type WallDimensionReference = (typeof WALL_DIMENSION_REFERENCES)[number] function floorplanWallThickness(wall: WallNode): number { - const baseThickness = wall.thickness ?? 0.1 + const baseThickness = getWallAssemblyThickness(wall) const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE return Math.min( baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS, @@ -32,17 +46,48 @@ function exaggerateWallThickness(wall: WallNode): WallNode { return { ...wall, thickness: floorplanWallThickness(wall) } } -function formatLengthMetric(meters: number): string { - return `${Number.parseFloat(meters.toFixed(2))}m` +function wallWithModeledAssemblyThickness(wall: WallNode): WallNode { + return { ...wall, thickness: getWallAssemblyThickness(wall) } +} + +export type WallFloorplanLevelData = { + miters: WallMiterData + documentMiters: WallMiterData + constructionDimensionsByReference: Record } export function computeWallFloorplanLevelData({ siblings, + nodes, }: { siblings: ReadonlyArray nodes: Record -}): WallMiterData { - return calculateLevelMiters(siblings.map(exaggerateWallThickness)) +}): WallFloorplanLevelData { + const walls = siblings.map(exaggerateWallThickness) + return { + miters: calculateLevelMiters(walls), + documentMiters: calculateLevelMiters([...siblings]), + constructionDimensionsByReference: { + 'finished-faces': buildLevelWallConstructionDimensionPlan( + siblings, + nodes, + constructionDimensionStandard({ + datumPolicy: 'wall-face', + intersectionReferencePolicy: 'both-faces', + }), + ), + centerline: buildLevelWallConstructionDimensionPlan( + siblings, + nodes, + constructionDimensionStandard({ datumPolicy: 'centerline' }), + ), + 'stud-faces': buildLevelWallConstructionDimensionPlan( + siblings, + nodes, + constructionDimensionStandard({ datumPolicy: 'structural-face' }), + ), + }, + } } /** @@ -55,26 +100,29 @@ export function computeWallFloorplanLevelData({ * wall body easily. * 4. Two endpoint handles (start + end) when selected — the registry * layer hosts the 5-circle stack + hover transitions + 2D drag. - * 5. A small dimension label at the midpoint when selected. + * 5. Exterior facade strings plus interior wall spans and hosted-opening widths. * * `ctx.levelData` provides the shared level miter graph when the floor-plan * dispatcher precomputes it; `ctx.siblings` remains the fallback path for * direct builder callers. */ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null { - const self = exaggerateWallThickness(node) + const { metricNotation, purpose, wallDimensionReference } = readFloorplanContext(ctx) + const documentMode = purpose === 'document' + const wallForPurpose = (wall: WallNode) => + documentMode ? wallWithModeledAssemblyThickness(wall) : exaggerateWallThickness(wall) + const self = wallForPurpose(node) // Prefer the level-batch miter graph the floor-plan dispatcher precomputes // once per pass (`computeWallFloorplanLevelData`). Only the fallback path — // a direct builder caller with no shared data — pays the O(N) exaggerate + // level-wide miter calc per wall; the dispatcher path is O(1) here, which is // what keeps a wall drag from being O(N²) across the level. + const levelData = ctx.levelData as WallFloorplanLevelData | undefined const miters = - (ctx.levelData as WallMiterData | undefined) ?? + (documentMode ? levelData?.documentMiters : levelData?.miters) ?? calculateLevelMiters([ self, - ...ctx.siblings - .filter((s): s is AnyNode & WallNode => s.type === 'wall') - .map(exaggerateWallThickness), + ...ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall').map(wallForPurpose), ]) const polygon = getWallPlanFootprint(self, miters) @@ -109,6 +157,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp stroke, strokeWidth: showSelectedChrome ? 0.03 : 0.02, opacity: 0.92, + metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }), // Once the wall is selected, the body keeps catching the pointer // so the cursor stays neutral (no drag/pointer affordance from // the slab below leaking through), but only the side-arrows and @@ -118,6 +167,56 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp }, ] + children.push(...buildWallAssemblyFloorplanGraphics(self)) + + const dimensionStroke = + isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155') + const dimensionStandard = constructionDimensionStandard({ + datumPolicy: wallDimensionDatumPolicy(wallDimensionReference), + metricNotation, + }) + const exteriorCornerDimensionStandard = constructionDimensionStandard({ + datumPolicy: 'structural-face', + metricNotation, + }) + if (isCurvedWall(node)) { + children.push( + ...buildCurvedWallConstructionDimensions(self, { + unit: view?.unit ?? 'metric', + stroke: dimensionStroke, + profile: documentMode ? 'document' : 'editor', + standard: exteriorCornerDimensionStandard, + siblings: ctx.siblings.filter( + (sibling): sibling is AnyNode & WallNode => sibling.type === 'wall', + ), + }), + ) + } else { + const planned = levelData?.constructionDimensionsByReference[wallDimensionReference].get( + node.id, + ) + if (planned) { + children.push( + ...renderPlannedConstructionDimensions( + planned, + view?.unit ?? 'metric', + dimensionStroke, + documentMode ? 'document' : 'editor', + dimensionStandard, + ), + ) + } else if (!levelData) { + children.push( + ...buildWallConstructionDimensions(self, ctx, { + unit: view?.unit ?? 'metric', + stroke: dimensionStroke, + profile: documentMode ? 'document' : 'editor', + standard: exteriorCornerDimensionStandard, + }), + ) + } + } + // Selection hatch overlay — only when the wall is *the* selected item // (not when it's just marquee-highlighted), matching the legacy. if (isSelected && palette) { @@ -172,19 +271,18 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp const dz = node.end[1] - node.start[1] const wallLength = Math.hypot(dx, dz) if (wallLength > 1e-6) { - const midX = (node.start[0] + node.end[0]) / 2 - const midZ = (node.start[1] + node.end[1]) / 2 + const midpoint = getWallMidpointHandlePoint(node) const nx = -dz / wallLength const nz = dx / wallLength const offset = floorplanWallThickness(node) / 2 + 0.05 children.push({ kind: 'move-arrow', - point: [midX + nx * offset, midZ + nz * offset], + point: [midpoint.x + nx * offset, midpoint.y + nz * offset], angle: Math.atan2(nz, nx), }) children.push({ kind: 'move-arrow', - point: [midX - nx * offset, midZ - nz * offset], + point: [midpoint.x - nx * offset, midpoint.y - nz * offset], angle: Math.atan2(-nz, -nx), }) } @@ -206,67 +304,438 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp payload: { wallId: node.id }, }) } - - // Length measurement. Curved walls use the simple rounded label - // (the chord-vs-arc thing is hard to express with a dimension line); - // straight walls get the full architect's overlay with extension - // marks + ticks, offset to the side facing away from the level - // centroid (matches the legacy `getWallMeasurementOverlay`). - const length = getWallCurveLength(node) - if (length >= 0.1) { - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] - const midX = (node.start[0] + node.end[0]) / 2 - const midZ = (node.start[1] + node.end[1]) / 2 - - if (isCurvedWall(node)) { - children.push({ - kind: 'dimension-label', - cx: midX, - cy: midZ, - text: formatLengthMetric(length), - angle: Math.atan2(dz, dx), - }) - } else { - // Outward unit normal = perpendicular to (dx, dz), choose the - // side facing away from other walls' centroid so the dimension - // line sits outside the building. - const nx = -dz / length - const nz = dx / length - const wallSiblings = ctx.siblings.filter((s): s is AnyNode & WallNode => s.type === 'wall') - const centroid = wallCentroid([node, ...wallSiblings]) - const cx = midX - centroid[0] - const cz = midZ - centroid[1] - const facingAway = cx * nx + cz * nz >= 0 ? 1 : -1 - children.push({ - kind: 'dimension', - start: [node.start[0], node.start[1]], - end: [node.end[0], node.end[1]], - offsetNormal: [nx * facingAway, nz * facingAway], - offsetDistance: 0.75, - extensionOvershoot: 0.12, - text: formatLengthMetric(length), - }) - } - } } return { kind: 'group', children } } -function wallCentroid(walls: WallNode[]): [number, number] { - // Mean of every wall endpoint — cheap approximation of "where the - // building lives" so we can offset the dimension line away from it. - let sumX = 0 - let sumZ = 0 - let count = 0 - for (const wall of walls) { - sumX += wall.start[0] + wall.end[0] - sumZ += wall.start[1] + wall.end[1] - count += 2 +function wallDimensionDatumPolicy(reference: WallDimensionReference) { + switch (reference) { + case 'centerline': + return 'centerline' as const + case 'stud-faces': + return 'structural-face' as const + case 'finished-faces': + return 'wall-face' as const } - if (count === 0) return [0, 0] - return [sumX / count, sumZ / count] +} + +type WallAssemblyLayerSpan = { + layer: WallAssemblyLayer + interiorOffset: number + exteriorOffset: number +} + +function buildWallAssemblyFloorplanGraphics(wall: WallNode): FloorplanGeometry[] { + if (isCurvedWall(wall)) return [] + + const layers = wall.assemblyLayers ?? [] + if (layers.length === 0) return [] + + const spans = getWallAssemblyLayerSpans(wall) + if (spans.length === 0) return [] + + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length <= 1e-6) return [] + + const tx = dx / length + const ty = dy / length + const nx = -ty + const ny = tx + const startX = wall.start[0] + const startY = wall.start[1] + const endX = wall.end[0] + const endY = wall.end[1] + + const graphics: FloorplanGeometry[] = [] + for (const span of spans) { + const style = wallAssemblyLayerGraphicStyle(span.layer) + const points = wallLayerPolygon(startX, startY, endX, endY, nx, ny, span) + graphics.push({ + kind: 'polygon', + points, + fill: style.fill, + stroke: style.stroke, + strokeWidth: style.strokeWidth, + fillOpacity: style.fillOpacity, + opacity: style.opacity, + pointerEvents: 'none', + }) + graphics.push( + ...buildWallAssemblyLayerHatchLines({ + span, + style, + startX, + startY, + endX, + endY, + tx, + ty, + nx, + ny, + length, + }), + ) + } + + graphics.push(...buildWallAssemblyFaceLines(startX, startY, endX, endY, nx, ny, spans)) + return graphics +} + +function getWallAssemblyLayerSpans(wall: WallNode): 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 +} + +type WallAssemblyLayerGraphicStyle = { + fill: string + stroke: string + strokeWidth: number + fillOpacity: number + opacity?: number + hatch?: 'diagonal' | 'cross' | 'brick' | 'air' | 'furring' + hatchStroke: string + hatchDasharray?: string +} + +function wallAssemblyLayerGraphicStyle(layer: WallAssemblyLayer): WallAssemblyLayerGraphicStyle { + switch (layer.role) { + case 'structure': + return { + fill: '#475569', + stroke: '#111827', + strokeWidth: 0.006, + fillOpacity: 0.34, + hatch: 'diagonal', + hatchStroke: '#0f172a', + } + case 'concrete-block': + case 'structural-masonry': + return { + fill: '#cbd5e1', + stroke: '#334155', + strokeWidth: 0.006, + fillOpacity: 0.82, + hatch: 'cross', + hatchStroke: '#475569', + } + case 'solid-concrete': + return { + fill: '#94a3b8', + stroke: '#334155', + strokeWidth: 0.006, + fillOpacity: 0.78, + hatch: 'diagonal', + hatchStroke: '#64748b', + } + case 'masonry-veneer': + return { + fill: '#fca5a5', + stroke: '#7f1d1d', + strokeWidth: 0.004, + fillOpacity: 0.45, + hatch: 'brick', + hatchStroke: '#991b1b', + } + case 'air-space': + return { + fill: '#ffffff', + stroke: '#94a3b8', + strokeWidth: 0.004, + fillOpacity: 0.15, + hatch: 'air', + hatchStroke: '#64748b', + hatchDasharray: '0.035 0.025', + } + case 'furring': + return { + fill: '#fde68a', + stroke: '#92400e', + strokeWidth: 0.004, + fillOpacity: 0.42, + hatch: 'furring', + hatchStroke: '#92400e', + hatchDasharray: '0.04 0.02', + } + case 'interior-finish': + case 'exterior-finish': + case 'exterior-sheathing': + return { + fill: '#f8fafc', + stroke: '#94a3b8', + strokeWidth: 0.003, + fillOpacity: 0.72, + hatch: layer.role === 'exterior-sheathing' ? 'diagonal' : undefined, + hatchStroke: '#94a3b8', + } + } +} + +function wallLayerPolygon( + startX: number, + startY: number, + endX: number, + endY: number, + nx: number, + ny: number, + span: WallAssemblyLayerSpan, +): FloorplanPoint[] { + return [ + [startX + nx * span.interiorOffset, startY + ny * span.interiorOffset], + [endX + nx * span.interiorOffset, endY + ny * span.interiorOffset], + [endX + nx * span.exteriorOffset, endY + ny * span.exteriorOffset], + [startX + nx * span.exteriorOffset, startY + ny * span.exteriorOffset], + ] +} + +function buildWallAssemblyLayerHatchLines({ + span, + style, + startX, + startY, + tx, + ty, + nx, + ny, + length, +}: { + span: WallAssemblyLayerSpan + style: WallAssemblyLayerGraphicStyle + startX: number + startY: number + endX: number + endY: number + tx: number + ty: number + nx: number + ny: number + length: number +}): FloorplanGeometry[] { + if (!style.hatch) return [] + + const layerWidth = span.exteriorOffset - span.interiorOffset + if (layerWidth <= 1e-6) return [] + + const interval = Math.max(FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING, layerWidth * 1.8) + const insetAlong = Math.min(0.035, length * 0.08) + const lines: FloorplanGeometry[] = [] + + if (style.hatch === 'air') { + const midOffset = (span.interiorOffset + span.exteriorOffset) / 2 + lines.push( + wallAssemblyLine( + startX + tx * insetAlong, + startY + ty * insetAlong, + startX + tx * (length - insetAlong), + startY + ty * (length - insetAlong), + nx, + ny, + midOffset, + style.hatchStroke, + style.hatchDasharray, + ), + ) + return lines + } + + if (style.hatch === 'brick') { + for (let along = interval; along < length; along += interval) { + lines.push( + wallCrossLine(startX, startY, tx, ty, nx, ny, along, span, style.hatchStroke, undefined), + ) + } + const thirds = [ + span.interiorOffset + layerWidth / 3, + span.interiorOffset + (layerWidth * 2) / 3, + ] + for (const offset of thirds) { + lines.push( + wallAssemblyLine( + startX + tx * insetAlong, + startY + ty * insetAlong, + startX + tx * (length - insetAlong), + startY + ty * (length - insetAlong), + nx, + ny, + offset, + style.hatchStroke, + style.hatchDasharray, + ), + ) + } + return lines + } + + if (style.hatch === 'furring') { + for (let along = interval; along < length; along += interval) { + lines.push( + wallCrossLine( + startX, + startY, + tx, + ty, + nx, + ny, + along, + span, + style.hatchStroke, + style.hatchDasharray, + ), + ) + } + return lines + } + + const emitDiagonal = (flip: boolean) => { + for (let along = interval / 2; along < length; along += interval) { + const centerOffset = (span.interiorOffset + span.exteriorOffset) / 2 + const halfAlong = Math.min(interval * 0.35, length * 0.08) + const halfAcross = layerWidth * 0.42 + const sign = flip ? -1 : 1 + lines.push({ + kind: 'line', + x1: startX + tx * Math.max(0, along - halfAlong) + nx * (centerOffset - sign * halfAcross), + y1: startY + ty * Math.max(0, along - halfAlong) + ny * (centerOffset - sign * halfAcross), + x2: + startX + + tx * Math.min(length, along + halfAlong) + + nx * (centerOffset + sign * halfAcross), + y2: + startY + + ty * Math.min(length, along + halfAlong) + + ny * (centerOffset + sign * halfAcross), + stroke: style.hatchStroke, + strokeWidth: 0.55, + strokeDasharray: style.hatchDasharray, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + }) + } + } + + emitDiagonal(false) + if (style.hatch === 'cross') emitDiagonal(true) + return lines +} + +function wallAssemblyLine( + x1: number, + y1: number, + x2: number, + y2: number, + nx: number, + ny: number, + offset: number, + stroke: string, + strokeDasharray: string | undefined, +): FloorplanGeometry { + return { + kind: 'line', + x1: x1 + nx * offset, + y1: y1 + ny * offset, + x2: x2 + nx * offset, + y2: y2 + ny * offset, + stroke, + strokeWidth: 0.5, + strokeDasharray, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + } +} + +function wallCrossLine( + startX: number, + startY: number, + tx: number, + ty: number, + nx: number, + ny: number, + along: number, + span: WallAssemblyLayerSpan, + stroke: string, + strokeDasharray: string | undefined, +): FloorplanGeometry { + return { + kind: 'line', + x1: startX + tx * along + nx * span.interiorOffset, + y1: startY + ty * along + ny * span.interiorOffset, + x2: startX + tx * along + nx * span.exteriorOffset, + y2: startY + ty * along + ny * span.exteriorOffset, + stroke, + strokeWidth: 0.5, + strokeDasharray, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + } +} + +function buildWallAssemblyFaceLines( + startX: number, + startY: number, + endX: number, + endY: number, + nx: number, + ny: number, + spans: WallAssemblyLayerSpan[], +): FloorplanGeometry[] { + const offsets = new Set() + for (const span of spans) { + offsets.add(span.interiorOffset) + offsets.add(span.exteriorOffset) + } + + const sortedOffsets = [...offsets].sort((a, b) => a - b) + const minOffset = sortedOffsets[0] + const maxOffset = sortedOffsets.at(-1) + + return sortedOffsets.map((offset) => ({ + kind: 'line', + x1: startX + nx * offset, + y1: startY + ny * offset, + x2: endX + nx * offset, + y2: endY + ny * offset, + stroke: offset === minOffset || offset === maxOffset ? '#111827' : '#64748b', + strokeWidth: offset === minOffset || offset === maxOffset ? 0.85 : 0.45, + vectorEffect: 'non-scaling-stroke', + pointerEvents: 'none', + })) } /** diff --git a/packages/nodes/src/wall/measurement.test.ts b/packages/nodes/src/wall/measurement.test.ts index a2cdcab2..36d8e6ce 100644 --- a/packages/nodes/src/wall/measurement.test.ts +++ b/packages/nodes/src/wall/measurement.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import { WallNode } from '@pascal-app/core' -import { matchWallMeasurementFeature } from './measurement' +import { + matchWallMeasurementFeature, + resolveWallMeasurementFeature, + wallMeasurementFeatures, +} from './measurement' describe('matchWallMeasurementFeature', () => { test('keeps an exact plan corner bound to the wall endpoint instead of its face', () => { @@ -17,4 +21,30 @@ describe('matchWallMeasurementFeature', () => { expect(matchWallMeasurementFeature(wall, [4, 1, 0.1], 0.2)?.featureId).toBe('wall:face:left') }) + + test('publishes a stable center feature only for curved walls', () => { + const curved = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 1 }) + const straight = WallNode.parse({ start: [0, 0], end: [4, 0] }) + + expect( + wallMeasurementFeatures(curved).find((feature) => feature.id === 'wall:curve:center'), + ).toMatchObject({ + snapKind: 'center', + geometry: { kind: 'point', point: [2, 0, 1.5] }, + }) + expect( + wallMeasurementFeatures(straight).find((feature) => feature.id === 'wall:curve:center'), + ).toBeUndefined() + }) + + test('resolves the curved-wall center from the current wall shape', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], curveOffset: 0.5 }) + + expect( + resolveWallMeasurementFeature(wall, { + nodeId: wall.id, + featureId: 'wall:curve:center', + }), + ).toMatchObject({ geometry: { kind: 'point', point: [2, 0, 3.75] } }) + }) }) diff --git a/packages/nodes/src/wall/measurement.ts b/packages/nodes/src/wall/measurement.ts index d9d9645f..0d918f90 100644 --- a/packages/nodes/src/wall/measurement.ts +++ b/packages/nodes/src/wall/measurement.ts @@ -1,18 +1,21 @@ import { - DEFAULT_WALL_HEIGHT, + getWallArcData, getWallCurveFrameAt, getWallThickness, type MeasurementFeature, type MeasurementFeatureBinding, type MeasurementFeatureReference, sampleWallCenterline, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number] export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { - const height = wall.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(wall, useScene.getState().nodes) + const arc = getWallArcData(wall) const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y)) const midpoint = getWallCurveFrameAt(wall, 0.5).point const halfThickness = getWallThickness(wall) / 2 @@ -62,6 +65,17 @@ export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] { priority: 90, geometry: { kind: 'point', point: point(midpoint.x, 0, midpoint.y) }, }, + ...(arc + ? [ + { + id: 'wall:curve:center', + label: 'Wall arc center', + snapKind: 'center' as const, + priority: 90, + geometry: { kind: 'point' as const, point: point(arc.center.x, 0, arc.center.y) }, + }, + ] + : []), { id: 'wall:face:left', label: 'Wall face', @@ -168,7 +182,10 @@ export function matchWallMeasurementFeature( const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ) const threshold = Math.max(maxDistance, halfThickness + 0.03) if (faceDistance <= threshold && (!best || faceDistance < best.distance)) { - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1])) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), hit[1]), + ) best = { featureId: side > 0 ? 'wall:face:left' : 'wall:face:right', point: point(faceX, height, faceZ), @@ -204,7 +221,10 @@ export function resolveWallMeasurementFeature( if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') { return normal ? { ...feature, normal } : feature } - const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue)) + const height = Math.max( + 0, + Math.min(resolveWallOpeningCeiling(wall, useScene.getState().nodes), heightValue), + ) return { ...feature, ...(normal ? { normal } : {}), diff --git a/packages/nodes/src/wall/move-endpoint-tool.tsx b/packages/nodes/src/wall/move-endpoint-tool.tsx index fcb7bfd4..d81d8d8d 100644 --- a/packages/nodes/src/wall/move-endpoint-tool.tsx +++ b/packages/nodes/src/wall/move-endpoint-tool.tsx @@ -3,13 +3,13 @@ import { type AnyNodeId, collectAlignmentAnchors, - DEFAULT_WALL_HEIGHT, emitter, type GridEvent, getWallCurveLength, getWallThickness, pauseSceneHistory, resolveAlignment, + resolveWallSupportSlabPatch, resumeSceneHistory, runAsSingleSceneHistoryStep, useLiveNodeOverrides, @@ -39,6 +39,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Wall endpoint move tool (kind-owned). @@ -228,6 +229,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ const originalStart = originalStartRef.current const originalEnd = originalEndRef.current const fixedPoint = fixedPointRef.current + const movingOriginalPoint = target.endpoint === 'start' ? originalStart : originalEnd + // Walls attached to the MOVING corner cascade with the drag, but the snap + // pipeline reads the scene store, which keeps their pre-drag coordinates + // until commit. Their stale corners would recreate the old junction as a + // snap/alignment target: inside the connect radius the endpoint could + // never land closer than ~5cm to where it started, making sub-5cm + // corrections (e.g. squaring a scan-imported 91° corner) impossible. + // Excluded while attached; under Alt-detach they stay put and remain + // legitimate targets. + const movingLinkedWallIds = linkedOriginalsRef.current + .filter( + (wall) => + samePoint(wall.start, movingOriginalPoint) || samePoint(wall.end, movingOriginalPoint), + ) + .map((wall) => wall.id) const levelWalls = Object.values(useScene.getState().nodes).filter( (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null), @@ -237,14 +253,24 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // fences, items, slabs, ceilings, columns), gathered once (the set is // stable during the drag). Coords are building-local, the same frame as // the cursor and the 3D guide layer, so the published guide lines up. + // The attached variant additionally drops anchors owned by walls that + // follow the moving corner (see `movingLinkedWallIds` above) — their + // scene coordinates are stale during the drag. const wallAlignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, nodeId) + const movingLinkedIdSet = new Set(movingLinkedWallIds) + const attachedAlignmentCandidates = wallAlignmentCandidates.filter( + (anchor) => !movingLinkedIdSet.has(anchor.nodeId), + ) pauseSceneHistory(useScene) let wasCommitted = false - // Last point handed to `applyPreview` — lets the Alt keydown/keyup - // handlers re-run the preview immediately on a modifier change instead of - // waiting for the next mousemove. - let lastMovedPoint: WallPlanPoint | null = null + // Last RAW cursor point from `grid:move` — lets the Alt keydown/keyup + // handlers re-run the FULL snap pipeline immediately on a modifier change + // instead of waiting for the next mousemove. The raw point (not the + // snapped one) matters: the snap/alignment candidate set depends on Alt + // (stale-junction exclusion above), so a point snapped under the previous + // modifier state must not be reused as-is. + let lastRawPoint: WallPlanPoint | null = null // The first pointer-up is the *grab* of a click-to-move; later ones are // drops. See the `!hasChanged` branch in `onPointerUp`. let hasReleasedOnce = false @@ -288,7 +314,6 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ } const applyPreview = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => { - lastMovedPoint = movingPoint const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint const linkedUpdates = detachLinkedWalls @@ -352,16 +377,18 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) } - const onGridMove = (event: GridEvent) => { - const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] - // Endpoint move honours the active snapping mode (the HUD chip): grid → - // lattice; lines → magnetic corner/alignment snap; angles → lock the - // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass — - // Shift cycles the mode now, and Off is the bypass. + // Full snap pipeline from a RAW cursor point to the applied endpoint — + // shared by `grid:move` and the Alt keydown/keyup handlers, since the + // candidate set (stale-junction exclusion) flips with the modifier. + // Endpoint move honours the active snapping mode (the HUD chip): grid → + // lattice; lines → magnetic corner/alignment snap; angles → lock the + // segment to 15° rays from the FIXED corner; off → raw. No Shift bypass — + // Shift cycles the mode now, and Off is the bypass. + const resolveDragPoint = (planPoint: WallPlanPoint): WallPlanPoint => { const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls: levelWalls, - ignoreWallIds: [nodeId], + ignoreWallIds: altPressedRef.current ? [nodeId] : [nodeId, ...movingLinkedWallIds], start: fixedPoint, angleSnap: isAngleSnapActive(), magnetic: isMagneticSnapActive(), @@ -378,10 +405,13 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ // (isAlignmentGuideActive); the magnetic pull onto them is applied only in // 'lines' mode (isMagneticSnapActive). let alignedPoint = snappedPoint - if (isAlignmentGuideActive() && wallAlignmentCandidates.length > 0) { + const alignmentCandidates = altPressedRef.current + ? wallAlignmentCandidates + : attachedAlignmentCandidates + if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { const ar = resolveAlignment({ moving: [{ nodeId, kind: 'corner', x: snappedPoint[0], z: snappedPoint[1] }], - candidates: wallAlignmentCandidates, + candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) const magnetic = isMagneticSnapActive() @@ -427,14 +457,21 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ : null, ) + return alignedPoint + } + + const onGridMove = (event: GridEvent) => { + const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] + lastRawPoint = planPoint // The keydown listener can't observe an Alt press that predates the // tool mounting; the pointer event can. Sync the shared ref (single Alt - // source for preview, HUD badge, and commit) before applying. + // source for snap targets, preview, HUD badge, and commit) before the + // snap pipeline reads it. if (event.nativeEvent.altKey !== altPressedRef.current) { altPressedRef.current = event.nativeEvent.altKey setAltPressed(event.nativeEvent.altKey) } - applyPreview(alignedPoint, altPressedRef.current) + applyPreview(resolveDragPoint(planPoint), altPressedRef.current) } const onPointerUp = () => { @@ -521,6 +558,16 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ }, })), ]) + const affectedIds = [nodeId as AnyNodeId, ...linkedUpdates.map((u) => u.id as AnyNodeId)] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) useScene.getState().markDirty(nodeId as AnyNodeId) for (const u of linkedUpdates) { useScene.getState().markDirty(u.id as AnyNodeId) @@ -546,16 +593,17 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ exitMoveMode() } - // Single Alt writer for keyboard transitions. Re-running the preview on - // the flip keeps geometry and the HUD badge in lockstep — detach reverts - // the linked walls instantly, re-attach snaps them onto the dragged point - // — without waiting for the next mousemove. + // Single Alt writer for keyboard transitions. Re-running the FULL snap + // pipeline from the raw cursor on the flip keeps geometry and the HUD + // badge in lockstep — detach reverts the linked walls instantly and + // re-snaps against their (now live) corners, re-attach drops them from + // the candidate set again — without waiting for the next mousemove. const setAltState = (pressed: boolean) => { if (altPressedRef.current === pressed) return altPressedRef.current = pressed setAltPressed(pressed) - if (lastMovedPoint) { - applyPreview(lastMovedPoint, pressed) + if (lastRawPoint) { + applyPreview(resolveDragPoint(lastRawPoint), pressed) } } @@ -613,7 +661,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ end: previewEnd, curveOffset: target.wall.curveOffset, }) - const wallHeight = target.wall.height ?? DEFAULT_WALL_HEIGHT + const wallHeight = resolveWallOpeningCeiling(target.wall, useScene.getState().nodes) const dimMidX = (previewStart[0] + previewEnd[0]) / 2 const dimMidZ = (previewStart[1] + previewEnd[1]) / 2 diff --git a/packages/nodes/src/wall/move-shared.ts b/packages/nodes/src/wall/move-shared.ts index aebea59f..3ae07a32 100644 --- a/packages/nodes/src/wall/move-shared.ts +++ b/packages/nodes/src/wall/move-shared.ts @@ -1,6 +1,5 @@ import { type AnyNodeId, - DEFAULT_WALL_HEIGHT, getMaterialPresetByRef, parseMaterialRef, resolveMaterial, @@ -12,6 +11,7 @@ import { WallNode as WallSchema, } from '@pascal-app/core' import { isSegmentLongEnough } from '@pascal-app/editor' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Pure helpers shared by the 3D `MoveWallTool` and the 2D @@ -241,7 +241,7 @@ export function buildBridgeWallPreviews(args: { start: [...plan.originalPoint] as WallPlanPoint, end: [...nextPoint] as WallPlanPoint, color: getWallGhostColor(plan.wall), - height: plan.wall.height ?? DEFAULT_WALL_HEIGHT, + height: resolveWallOpeningCeiling(plan.wall, useScene.getState().nodes), } previews.push({ ghost, wall }) wallsForDuplicateCheck.push(wall) diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 0a3b71ec..95744fa7 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -8,13 +8,16 @@ import { detectSpacesForLevel, emitter, type GridEvent, + getCeilingClampBound, getPerpendicularWallMoveAxis, getPlannedLinkedWallUpdates, + getStoredLevelHeight, + type LevelNode, pauseSceneHistory, planAutoCeilingsForLevel, planAutoSlabsForLevel, planWallMoveJunctions, - projectAutoSlabsForPlan, + resolveWallSupportSlabPatch, resumeSceneHistory, type SlabNode, useLiveNodeOverrides, @@ -284,12 +287,14 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // re-flowed through the planner. const existingSlabs = getLevelSlabs(levelId, sceneState.nodes) const slabPlan = planAutoSlabsForLevel(roomPolygons, existingSlabs) + const levelNode = sceneState.nodes[levelId as AnyNodeId] const ceilingPlan = planAutoCeilingsForLevel( roomPolygons, getLevelCeilings(levelId, sceneState.nodes), { - walls: levelWalls, - slabs: projectAutoSlabsForPlan(existingSlabs, slabPlan), + storeyHeight: + levelNode?.type === 'level' ? getStoredLevelHeight(levelNode as LevelNode) : undefined, + ceilingClampBound: (polygon) => getCeilingClampBound(levelId, sceneState.nodes, polygon), }, ) @@ -594,6 +599,19 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // undoable step. Then drop the live overrides — the renderer // now reads the committed walls + polygons directly. commitSurfacesToStore() + const affectedWallIds = [ + ...commitUpdates.map((entry) => entry.id), + ...bridgeCreates.map((entry) => entry.node.id as AnyNodeId), + ] + const committedNodes = useScene.getState().nodes + useScene.getState().updateNodes( + affectedWallIds.flatMap((id) => { + const wall = committedNodes[id] + return wall?.type === 'wall' + ? [{ id, data: resolveWallSupportSlabPatch(wall, committedNodes) }] + : [] + }), + ) clearSurfaceOverrides() clearWallOverrides() diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index b914561f..9c41e857 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -23,6 +23,7 @@ import { createSlotPaintCapability, previewSlotByUserData, } from '../shared/slot-paint' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' const WALL_SLOT_IDS = new Set(Object.keys(WALL_SURFACE_SLOT_DEFAULTS)) const WALL_ARRAY_SLOT_INDEX: Partial> = { @@ -104,9 +105,13 @@ export function resolveWallRole(args: { } if (sideFromIndex && localPosition) { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return sideFromIndex - return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + sideFromIndex, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } if (sideFromIndex) return sideFromIndex @@ -128,15 +133,23 @@ export function resolveWallRole(args: { const semantic = hitFace === 'front' ? node.frontSide : node.backSide if (semantic === 'interior' || semantic === 'exterior') { - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return semantic - return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + semantic, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } const side = hitFace === 'front' ? 'interior' : 'exterior' - const bands = getWallFaceBandConfig(node) + const effectiveWallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) + const bands = getWallFaceBandConfig(node, effectiveWallHeight) if (!bands.enabled) return side - return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1])) + return getWallBandSlotId( + side, + getWallFaceBandForHeight(node, localPosition[1], effectiveWallHeight), + ) } /** diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 72342657..a532eb72 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -15,6 +15,9 @@ import { WALL_CROWN_DEFAULT, WALL_FACE_BAND_DEFAULT, WALL_SKIRTING_DEFAULT, + type WallAssemblyLayer, + type WallAssemblyLayerRole, + type WallDimensionDatum, type WallNode, type WallTrimProfile, } from '@pascal-app/core' @@ -22,6 +25,7 @@ import { ActionButton, ActionGroup, curveReshapeScope, + formatLinearMeasurement, getLinearUnitLabel, linearControlValueToMeters, metersToLinearUnit, @@ -33,8 +37,9 @@ import { useInteractionScope, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Spline } from 'lucide-react' +import { Plus, Spline, Trash2 } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' type WallTrimKey = 'skirting' | 'crown' | 'chairRail' @@ -104,6 +109,15 @@ export default function WallPanel() { }) }) + // Effective height while the wall is plane-bound (`height` absent): the + // storey plane minus the elected slab base — what the wall currently + // renders at. `undefined` for walls with an explicit custom height. + const planeBoundHeightMeters = useScene((s) => { + const wall = selectedId ? (s.nodes[selectedId as AnyNodeId] as WallNode | undefined) : undefined + if (wall?.type !== 'wall' || wall.height != null) return undefined + return resolveWallOpeningCeiling(wall, s.nodes) + }) + // Mirror the latest node into a ref so the slider handlers below have // stable identities across re-renders. Without this, every store tick // (one per pointermove during a slider drag) rebuilt the handler @@ -145,6 +159,24 @@ export default function WallPanel() { [handleUpdate], ) + const handleTopModeChange = useCallback( + (mode: 'storey' | 'custom') => { + const n = nodeRef.current + if (!n) return + const isCustom = n.height != null + if (mode === 'custom' && !isCustom) { + // Seed from the current effective height so the geometry doesn't + // jump at the moment of detaching from the storey plane. + const seeded = resolveWallOpeningCeiling(n, useScene.getState().nodes) + handleUpdate({ height: Math.max(0.1, seeded) }) + } else if (mode === 'storey' && isCustom) { + // Absent `height` = plane-bound; the store strips undefined keys. + handleUpdate({ height: undefined }) + } + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -160,7 +192,8 @@ export default function WallPanel() { const length = getWallCurveLength(node) - const height = node.height ?? 2.5 + const isPlaneBound = node.height == null + const height = node.height ?? planeBoundHeightMeters ?? 2.5 const thickness = node.thickness ?? 0.1 const curveOffset = getClampedWallCurveOffset(node) const maxCurveOffset = getMaxWallCurveOffset(node) @@ -171,7 +204,7 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) - const wallHeightMeters = node.height ?? 2.5 + const wallHeightMeters = height const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } @@ -199,20 +232,37 @@ export default function WallPanel() { unit={unitLabel} value={displayLength} /> - - handleUpdate({ - height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), - }) - } - precision={2} - step={0.1} - unit={unitLabel} - value={Math.round(displayHeight * 100) / 100} +
+ Top +
+ + {isPlaneBound ? ( +
+ Currently {formatLinearMeasurement(height, unit)} +
+ ) : ( + + handleUpdate({ + height: linearControlValueToMeters(v, unit, { maxMeters: 6, minMeters: 0.1 }), + }) + } + precision={2} + step={0.1} + unit={unitLabel} + value={Math.round(displayHeight * 100) / 100} + /> + )} + + = [ + { label: 'Structure', value: 'structure' }, + { label: 'Interior finish', value: 'interior-finish' }, + { label: 'Exterior sheathing', value: 'exterior-sheathing' }, + { label: 'Exterior finish', value: 'exterior-finish' }, + { label: 'Masonry veneer', value: 'masonry-veneer' }, + { label: 'Air space', value: 'air-space' }, + { label: 'Concrete block', value: 'concrete-block' }, + { label: 'Structural masonry', value: 'structural-masonry' }, + { label: 'Solid concrete', value: 'solid-concrete' }, + { label: 'Furring', value: 'furring' }, +] + +const WALL_DATUM_OPTIONS: Array<{ label: string; value: WallDimensionDatum }> = [ + { label: 'Structural', value: 'structural-face' }, + { label: 'Finish', value: 'finish-face' }, + { label: 'Veneer', value: 'veneer-face' }, +] + +function WallAssemblySection({ + node, + onUpdate, + unit, + unitLabel, +}: { + node: WallNode + onUpdate: (updates: Partial) => void + unit: 'metric' | 'imperial' + unitLabel: string +}) { + const layers = node.assemblyLayers ?? [] + const updateLayer = (index: number, patch: Partial) => + onUpdate({ + assemblyLayers: layers.map((layer, layerIndex) => + layerIndex === index ? { ...layer, ...patch } : layer, + ), + }) + const addLayer = () => { + const number = layers.length + 1 + onUpdate({ + assemblyLayers: [ + ...layers, + { + id: `layer-${number}`, + role: layers.length === 0 ? 'structure' : 'exterior-finish', + side: layers.length === 0 ? 'core' : 'exterior', + thickness: 0.1, + materialRef: '', + datumEligible: layers.length === 0 ? ['structural-face'] : ['finish-face'], + }, + ], + }) + } + + return ( + +
+ {layers.map((layer, index) => ( +
+
+ { + const id = event.currentTarget.value.trim() + if (id && id !== layer.id) updateLayer(index, { id }) + }} + defaultValue={layer.id} + /> + +
+
+ + +
+ +
+ {WALL_DATUM_OPTIONS.map((option) => ( + + ))} +
+
+ ))} + +
+
+ ) +} + function WallFaceBandSection({ node, onUpdate, @@ -318,7 +529,7 @@ function WallFaceBandSection({ unitLabel: string wallHeightMeters: number }) { - const bandConfig = getWallFaceBandConfig(node) + const bandConfig = getWallFaceBandConfig(node, wallHeightMeters) const bandCount = bandConfig.count const lowerHeight = bandConfig.lowerHeight const middleHeight = bandConfig.middleHeight diff --git a/packages/nodes/src/wall/parametrics.ts b/packages/nodes/src/wall/parametrics.ts index 6c6fcd06..1056b0da 100644 --- a/packages/nodes/src/wall/parametrics.ts +++ b/packages/nodes/src/wall/parametrics.ts @@ -19,6 +19,8 @@ export const wallParametrics: ParametricDescriptor = { label: 'Dimensions', fields: [ { key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 0.6, step: 0.01 }, + // `height` may be absent (plane-bound top); the custom panel owns the + // Follows storey / Custom height mode switch, so this is metadata only. { key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 }, { key: 'curveOffset', kind: 'number', unit: 'm', min: -3, max: 3, step: 0.05 }, ], diff --git a/packages/nodes/src/wall/quick-measurement.ts b/packages/nodes/src/wall/quick-measurement.ts index cbe9b1ee..c7f97f03 100644 --- a/packages/nodes/src/wall/quick-measurement.ts +++ b/packages/nodes/src/wall/quick-measurement.ts @@ -1,15 +1,16 @@ import { - DEFAULT_WALL_HEIGHT, getWallCurveFrameAt, getWallCurveLength, getWallThickness, type QuickMeasurementReport, + useScene, type WallNode, } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport { const length = getWallCurveLength(node) - const height = node.height ?? DEFAULT_WALL_HEIGHT + const height = resolveWallOpeningCeiling(node, useScene.getState().nodes) const frame = getWallCurveFrameAt(node, 0.5) return { diff --git a/packages/nodes/src/wall/schema.ts b/packages/nodes/src/wall/schema.ts index fdd964f0..e96cea2e 100644 --- a/packages/nodes/src/wall/schema.ts +++ b/packages/nodes/src/wall/schema.ts @@ -8,5 +8,20 @@ * imports a single canonical type. */ -export type { WallNode as WallNodeType } from '@pascal-app/core' -export { WallNode } from '@pascal-app/core' +export type { + WallAssemblyDatumReference, + WallAssemblyDatumSide, + WallAssemblyLayer, + WallNode as WallNodeType, +} from '@pascal-app/core' +export { + getWallAssemblyDatumReferenceId, + getWallAssemblyLayers, + getWallAssemblyThickness, + getWallDatumEligibleLayers, + resolveWallAssemblyDatumReference, + resolveWallAssemblyDatumReferences, + WallAssemblyLayerRole, + WallDimensionDatum, + WallNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index d5ce8243..7f880f34 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -44,7 +44,7 @@ const WallTreatmentMiterSystem = () => { * * - **`WallSystem`** — reads `dirtyNodes`, batches by level, runs * `calculateLevelMiters(levelWalls)`, rebuilds geometry via - * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments)`, + * `generateExtrudedWall(node, children, miterData, slabElevation, baseElevation, baseSegments, storeyHeight)`, * and cascades to adjacent walls that share a junction. This is the * bulk of the wall runtime (~820 lines in viewer). * - **`WallCutout`** — cutaway-mode hide/show logic based on camera diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index f5acb490..80070590 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -2,6 +2,7 @@ import { type AnyNode, calculateLevelMiters, collectAlignmentAnchors, + DEFAULT_LEVEL_HEIGHT, emitter, type GridEvent, getWallMiterBoundaryPoints, @@ -17,6 +18,7 @@ import { import { CursorSphere, chainEndJoinsExistingWall, + clearPlacementSurface, createWallOnCurrentLevel, EDITOR_LAYER, formatAngleRadians, @@ -28,6 +30,8 @@ import { isAngleSnapActive, isMagneticSnapActive, markToolCancelConsumed, + publishPlacementSurface, + resolvePointerSupportSurface, type SegmentAngleReference, snapWallDraftPointDetailed, triggerSFX, @@ -41,9 +45,17 @@ import { type WallPlanPoint, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' -import { Html } from '@react-three/drei' -import { useEffect, useMemo, useRef, useState } from 'react' -import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef, useState } from 'react' +import { BoxGeometry, DoubleSide, type Group, type Mesh, Vector3 } from 'three' +import { + DraftAngleArc, + type DraftAngleLabel, + type DraftAxisGuideState, + DraftAxisGuides, + DraftMeasurementLabel, + getNearestAxisAngleLabel, +} from '../shared/draft-axis-guides' /** * Phase 5 Stage D — wall placement tool (kind-owned). @@ -58,7 +70,6 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3 * * Mounted via `def.tool` from `wall/definition.ts`. */ -const WALL_HEIGHT = 2.5 const DRAFT_WALL_THICKNESS = 0.1 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -69,32 +80,11 @@ const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08 const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012 const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32 const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72 -const DRAFT_ANGLE_ARC_SEGMENTS = 24 -const DRAFT_AXIS_GUIDE_LENGTH = 2000 -const DRAFT_AXIS_GUIDE_WIDTH = 0.035 -const DRAFT_AXIS_GUIDE_HEIGHT = 0.004 -const DRAFT_AXIS_GUIDE_Y_OFFSET = 0.026 -const DRAFT_AXIS_ANGLE_ARC_Y_OFFSET = 0.05 -const DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET = 0.16 -const DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS = 0.36 -const DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS = 0.82 -const AXIS_ANGLE_REFERENCES: SegmentAngleReference[] = [ - { vector: [1, 0], orientation: 'axis' }, - { vector: [0, 1], orientation: 'axis' }, -] -type DraftAngleLabel = { - id: string - label: string - position: [number, number, number] - arc: { - center: WallPlanPoint - radius: number - startAngle: number - endAngle: number - y: number - } -} +// Grid-plane surface publish (pointer-decided): scratch + constant normal so +// per-move publishes don't allocate. +const SURFACE_UP = new Vector3(0, 1, 0) +const surfacePointScratch = new Vector3() type DraftMeasurementState = { lengthLabel: string @@ -102,21 +92,6 @@ type DraftMeasurementState = { angleLabels: DraftAngleLabel[] } | null -type DraftAxisGuideState = { - origin: WallPlanPoint - y: number - angleLabel: DraftAngleLabel | null -} | null - -type AxisAngleCandidate = { - angle: number - arc: { - startAngle: number - endAngle: number - midAngle: number - } -} - type FaceAngleCandidate = { index: number point: WallPlanPoint @@ -157,53 +132,6 @@ function isWithinWallJoinSnapRadius(point: WallPlanPoint, vertex: Vector3) { return dx * dx + dz * dz <= WALL_JOIN_SNAP_RADIUS * WALL_JOIN_SNAP_RADIUS } -function getNearestAxisAngleLabel( - start: WallPlanPoint, - end: WallPlanPoint, - y: number, -): DraftAngleLabel | null { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length < 0.01) return null - - const draftVector: WallPlanPoint = [dx, dz] - const axisCandidates: AxisAngleCandidate[] = [] - for (const reference of AXIS_ANGLE_REFERENCES) { - const angle = getAngleToSegmentReference(draftVector, reference) - const arc = getAngleArcToSegmentReference(draftVector, reference) - if (!(angle === null || arc === null)) { - axisCandidates.push({ angle, arc }) - } - } - const nearestAxisAngle = axisCandidates.sort((a, b) => a.angle - b.angle)[0] - if (!nearestAxisAngle) return null - - const radius = clamp( - length * 0.22, - DRAFT_AXIS_ANGLE_ARC_MIN_RADIUS, - DRAFT_AXIS_ANGLE_ARC_MAX_RADIUS, - ) - const { angle, arc } = nearestAxisAngle - - return { - id: 'axis', - label: formatAngleRadians(angle), - position: [ - start[0] + Math.cos(arc.midAngle) * (radius + 0.16), - y + DRAFT_AXIS_ANGLE_LABEL_Y_OFFSET, - start[1] + Math.sin(arc.midAngle) * (radius + 0.16), - ], - arc: { - center: start, - radius, - startAngle: arc.startAngle, - endAngle: arc.endAngle, - y: y + DRAFT_AXIS_ANGLE_ARC_Y_OFFSET, - }, - } -} - function toWallPlanPoint(point: Point2D): WallPlanPoint { return [point.x, point.y] } @@ -225,6 +153,7 @@ function buildDraftWall(start: WallPlanPoint, end: WallPlanPoint): WallNode { visible: true, metadata: {}, children: [], + assemblyLayers: [], start, end, thickness: DRAFT_WALL_THICKNESS, @@ -513,13 +442,24 @@ function getBelowLevelWalls(): WallNode[] { export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const activeLevelId = useViewer((state) => state.selection.levelId) + const activeLevelHeight = useScene((state) => { + const level = activeLevelId ? state.nodes[activeLevelId] : undefined + return level?.type === 'level' ? (level.height ?? DEFAULT_LEVEL_HEIGHT) : DEFAULT_LEVEL_HEIGHT + }) // A placed wall preset seeds `toolDefaults.wall` (height / thickness …) // before the tool mounts, so the draft preview is drawn at the preset's // dimensions rather than the generic fallbacks — matching the wall that // will be created. Read through refs so the live event handlers below see // the latest values without re-subscribing. const wallDefaults = useEditor((s) => s.toolDefaults.wall) - const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT + // Camera for the pointer-support resolution (deck top vs floor) — read + // through a ref so the event handlers below see the live camera. + const camera = useThree((state) => state.camera) + const cameraRef = useRef(camera) + cameraRef.current = camera + const previewHeight = + typeof wallDefaults?.height === 'number' ? wallDefaults.height : activeLevelHeight const previewThickness = typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS const previewHeightRef = useRef(previewHeight) @@ -591,6 +531,16 @@ export const WallTool: React.FC = () => { : point } + // The walking surface the pointer actually aims at (deck top when over + // the deck, floor/ground underneath it) — only for genuine 3D pointer + // events. The 2D floor plan emits synthetic grid events with no camera + // ray behind them; those keep the uncapped max election and leave the + // grid plane alone. + const pointedSurfaceFor = (event: GridEvent) => + event.nativeEvent?.target instanceof HTMLCanvasElement + ? resolvePointerSupportSurface(cameraRef.current, event.position) + : null + const stopDrafting = () => { buildingState.current = 0 chainFirstVertex.current = null @@ -611,6 +561,19 @@ export const WallTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!(cursorRef.current && wallPreviewRef.current)) return + // Ride the grid event plane on the pointed surface: aiming at an + // elevated deck lifts the plane to the deck top, so the draft's XZ + // lands where the cursor points and the preview/cursor Y + // (`event.localPosition[1]`) sits at the base the committed wall + // will elect. Aiming past the deck edge drops it back to the floor. + const pointed = pointedSurfaceFor(event) + if (pointed) { + publishPlacementSurface( + surfacePointScratch.set(event.position[0], pointed.worldY, event.position[2]), + SURFACE_UP, + ) + } + const walls = getCurrentLevelWalls() // Add walls on the floor below as extra snap references so the new wall // can align with the level beneath it. Kept separate from `walls` so the @@ -647,6 +610,7 @@ export const WallTool: React.FC = () => { cursorRef.current.position.copy(endingPoint.current) setAxisGuide({ origin: [startingPoint.current.x, startingPoint.current.z], + endOrigin: snappedLocal, y: startingPoint.current.y, angleLabel: getNearestAxisAngleLabel( [startingPoint.current.x, startingPoint.current.z], @@ -718,6 +682,7 @@ export const WallTool: React.FC = () => { draftPreview.setWallDraftEnd(snappedStart) setAxisGuide({ origin: snappedStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -745,10 +710,12 @@ export const WallTool: React.FC = () => { const dx = snappedEnd[0] - startingPoint.current.x const dz = snappedEnd[1] - startingPoint.current.z if (dx * dx + dz * dz < 0.01 * 0.01) return + const pointed = pointedSurfaceFor(event) // Both start and end are building-local ✓ const createdWall = createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], snappedEnd, + { supportCap: pointed ? pointed.elevation : null }, ) if (!createdWall) return chainWallIds.current.push(createdWall.id) @@ -801,6 +768,7 @@ export const WallTool: React.FC = () => { buildingState.current = 1 setAxisGuide({ origin: nextStart, + endOrigin: null, y: event.localPosition[1], angleLabel: null, }) @@ -831,6 +799,7 @@ export const WallTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) + clearPlacementSurface() useAlignmentGuides.getState().clear() useWallSnapIndicator.getState().clear() useSegmentDraftChain.getState().clear('wall') @@ -842,7 +811,7 @@ export const WallTool: React.FC = () => { return ( - { ) } -function WallAxisGuides({ - guide, - labelColor, - labelShadowColor, -}: { - guide: DraftAxisGuideState - labelColor: string - labelShadowColor: string -}) { - if (!guide) return null - - const [x, z] = guide.origin - - return ( - <> - - - - - {guide.angleLabel && ( - <> - - - - )} - - ) -} - -function WallAxisGuideLine({ axis }: { axis: 'x' | 'z' }) { - return ( - - - - - ) -} - -function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { - const geometry = useMemo(() => { - const segmentCount = Math.max( - 8, - Math.ceil((Math.abs(arc.endAngle - arc.startAngle) / Math.PI) * DRAFT_ANGLE_ARC_SEGMENTS), - ) - - const points = Array.from({ length: segmentCount + 1 }, (_, index) => { - const t = index / segmentCount - const angle = arc.startAngle + (arc.endAngle - arc.startAngle) * t - - return new Vector3( - arc.center[0] + Math.cos(angle) * arc.radius, - arc.y, - arc.center[1] + Math.sin(angle) * arc.radius, - ) - }) - - return new BufferGeometry().setFromPoints(points) - }, [arc]) - - return ( - // @ts-expect-error - R3F accepts Three line primitives, matching the other editor drawing tools. - - - - ) -} - -function DraftMeasurementLabel({ - color, - label, - position, - shadowColor, -}: { - color: string - label: string - position: [number, number, number] - shadowColor: string -}) { - return ( - -
- {label} -
- - ) -} - export default WallTool diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index c092875b..6fe6856b 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -7,6 +7,7 @@ import { isCurvedWall, type SceneMaterial, type SceneMaterialId, + useScene, WALL_CHAIR_RAIL_DEFAULT, WALL_CROWN_DEFAULT, WALL_SKIRTING_DEFAULT, @@ -25,6 +26,7 @@ import { import { memo, useEffect, useMemo } from 'react' import * as THREE from 'three' import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data' const CURVE_SEGMENTS = 24 @@ -502,7 +504,7 @@ export function buildTrimGeometry( childrenNodes: OpeningLike[], levelData: WallTreatmentLevelData, ) { - const wallHeight = node.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(node, useScene.getState().nodes) const height = trim.height const yBottom = kind === 'crown' diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts index a61ea3cb..4370cb3c 100644 --- a/packages/nodes/src/window/definition.ts +++ b/packages/nodes/src/window/definition.ts @@ -6,9 +6,15 @@ import type { WallNode, WindowNode as WindowNodeType, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' +import { + buildWindowFloorplanSchedule, + computeWindowFloorplanLevelData, +} from '../shared/opening-documentation' import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' +import { readHostWallCeiling } from '../shared/wall-opening-ceiling' import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides' import { buildWindowFloorplan } from './floorplan' import { windowWidthAffordance } from './floorplan-affordances' @@ -33,12 +39,6 @@ function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unkn return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) } -function readWallHeight(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { - if (!w.wallId) return Number.POSITIVE_INFINITY - const wall = scene.get(w.wallId as AnyNodeId) as WallNode | undefined - return wall?.height ?? Number.POSITIVE_INFINITY -} - function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -96,9 +96,9 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor = { kind: 'window', snapProfile: 'item', facingIndicator: true, - schemaVersion: 1, + schemaVersion: 2, schema: WindowNode, category: 'structure', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildWindowFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, // Same schema-driven defaults trick as door: parse a stub, strip // id/type. Window also has many fields with zod `.default()` set. @@ -216,6 +221,7 @@ export const windowDefinition: NodeDefinition = { // Stage C: floor-plan polygon. ctx.parent gives the wall for direction // + thickness — same shape as door. floorplan: buildWindowFloorplan, + computeFloorplanLevelData: computeWindowFloorplanLevelData, floorplanDependsOnSiblings: true, // Opening symbols position from `ctx.parent` (the host wall); merge the // walls' live drag overrides so the symbol tracks a wall / group drag in diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index 7e591f3f..9fa359cb 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -202,6 +202,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod startLocalY, node.width, node.height, + nodes, ) // One click per real position step, keyed on the SNAPPED along-wall value diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index cb37e717..ddf3e670 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -5,6 +5,11 @@ import type { WallNode, WindowNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata } from '@pascal-app/editor' +import { + buildOpeningMarkAnnotation, + type OpeningFloorplanLevelData, +} from '../shared/opening-documentation' import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions' /** @@ -102,6 +107,7 @@ export function buildWindowFloorplan( strokeWidth: showSelectedChrome ? 1.9 : 1.25, vectorEffect: 'non-scaling-stroke', strokeLinejoin: 'round', + metadata: floorplanGeometryMetadata({ annotationObstacle: 'bounds' }), }, // Inset glass-pane outline. { @@ -169,5 +175,13 @@ export function buildWindowFloorplan( } } + const markAnnotation = buildOpeningMarkAnnotation( + node, + wall, + ctx.levelData as OpeningFloorplanLevelData | undefined, + { stroke: showSelectedChrome ? '#f97316' : '#334155' }, + ) + if (markAnnotation) children.push(markAnnotation) + return { kind: 'group', children } } diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 885560a1..475a5c44 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -356,6 +356,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode targetLocalY, movingWindowNode.width, movingWindowNode.height, + useScene.getState().nodes, ) const valid = !hasWallChildOverlap( diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index a1abde0f..1636f691 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -22,6 +22,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' +import { OpeningDocumentationFields } from '../shared/opening-documentation-fields' function isSameWindowValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { @@ -220,6 +221,8 @@ export default function WindowPanel() { parentId: node.parentId, width: node.width, height: node.height, + roughOpeningWidth: node.roughOpeningWidth, + roughOpeningHeight: node.roughOpeningHeight, windowType: node.windowType, operationState: node.operationState, awningDirection: node.awningDirection, @@ -413,6 +416,21 @@ export default function WindowPanel() { /> + + + + {showWindowTypeSection && (
diff --git a/packages/nodes/src/window/schema.ts b/packages/nodes/src/window/schema.ts index f1f47eaa..addb4039 100644 --- a/packages/nodes/src/window/schema.ts +++ b/packages/nodes/src/window/schema.ts @@ -1 +1,5 @@ -export { WindowNode } from '@pascal-app/core' +export { + WindowConstructionType, + WindowDimensionReference, + WindowNode, +} from '@pascal-app/core' diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index ede4907b..d7d63a76 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -339,7 +339,14 @@ const WindowTool: React.FC = () => { width, height, }) - const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) + const { clampedX, clampedY } = clampToWall( + wall, + localX, + localY, + width, + height, + useScene.getState().nodes, + ) const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) return { clampedX, clampedY, valid } } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index eb3547df..08ff4cb3 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,4 +1,5 @@ -import type { WallNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' /** * Default sill height (metres from the floor to the BOTTOM of a window) for a @@ -35,7 +36,11 @@ export function wallLocalToWorld( } /** - * Clamps window center position so it stays fully within wall bounds. + * Clamps window center position so it stays fully within wall bounds. The Y + * ceiling is the wall's RESOLVED top (storey plane for plane-bound walls, + * stored height for explicit ones, minus the elected slab base) — `nodes` is + * required because a plane-bound wall's top lives on its level, not on the + * wall record. */ export function clampToWall( wallNode: WallNode, @@ -43,11 +48,12 @@ export function clampToWall( localY: number, width: number, height: number, + nodes: Readonly>, ): { clampedX: number; clampedY: number } { const dx = wallNode.end[0] - wallNode.start[0] const dz = wallNode.end[1] - wallNode.start[1] const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = wallNode.height ?? 2.5 + const wallHeight = resolveWallOpeningCeiling(wallNode, nodes) const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index c527825c..bebd7fa9 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -3,6 +3,7 @@ import { resolveAutoZonePolygon, ZoneNode as ZoneNodeSchema, } from '@pascal-app/core' +import type { FloorplanNodeExtension } from '@pascal-app/editor' import { polygonMeasurementFeatures } from '../shared/polygon-measurement' import { buildZoneFloorplan } from './floorplan' import { @@ -14,6 +15,7 @@ import { import { zoneFloorplanMoveTarget } from './floorplan-move' import { zoneParametrics } from './parametrics' import { zoneQuickMeasurement } from './quick-measurement' +import { buildRoomFloorplanSchedule } from './room-documentation' import { ZoneNode } from './schema' /** @@ -25,9 +27,14 @@ import { ZoneNode } from './schema' export const zoneDefinition: NodeDefinition = { kind: 'zone', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 2, schema: ZoneNode, category: 'site', + extensions: { + 'pascal:editor/floorplan': { + schedule: buildRoomFloorplanSchedule, + } satisfies FloorplanNodeExtension, + }, defaults: () => { const stub = ZoneNodeSchema.parse({ id: 'zone_default' as never, type: 'zone' }) diff --git a/packages/nodes/src/zone/floorplan.test.ts b/packages/nodes/src/zone/floorplan.test.ts new file mode 100644 index 00000000..ab666c7c --- /dev/null +++ b/packages/nodes/src/zone/floorplan.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { type FloorplanGeometry, type GeometryContext, ZoneNode } from '@pascal-app/core' +import { readFloorplanGeometryMetadata } from '@pascal-app/editor' +import { buildZoneFloorplan } from './floorplan' + +const context = { + resolve: () => undefined, + children: [], + siblings: [], + parent: null, +} satisfies GeometryContext + +function textChildren(geometry: FloorplanGeometry | null) { + if (geometry?.kind !== 'group') return [] + return geometry.children.filter((child) => child.kind === 'text') +} + +describe('buildZoneFloorplan room documentation', () => { + test('keeps a generic zone label unchanged', () => { + const zone = ZoneNode.parse({ + id: 'zone_landscape', + name: 'Courtyard', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + }) + + expect(textChildren(buildZoneFloorplan(zone, context))).toEqual([ + expect.objectContaining({ kind: 'text', text: 'Courtyard', upright: true }), + ]) + }) + + test('centers room name, number, finish, and height information as room annotations', () => { + const room = ZoneNode.parse({ + id: 'zone_office', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + }) + + const labels = textChildren(buildZoneFloorplan(room, context)) + expect(labels.map((label) => ('text' in label ? label.text : ''))).toEqual([ + 'Office', + '101', + 'FL: Timber · WL: Paint · CL: ACT', + 'CH: 2.7m · Business', + ]) + expect(labels.every((label) => label.kind === 'text' && label.upright)).toBe(true) + expect( + labels.every((label) => readFloorplanGeometryMetadata(label).annotationRole === 'room-label'), + ).toBe(true) + }) +}) diff --git a/packages/nodes/src/zone/floorplan.ts b/packages/nodes/src/zone/floorplan.ts index b8333dbb..8806f6fe 100644 --- a/packages/nodes/src/zone/floorplan.ts +++ b/packages/nodes/src/zone/floorplan.ts @@ -5,6 +5,12 @@ import { resolveAutoZonePolygon, type ZoneNode, } from '@pascal-app/core' +import { floorplanGeometryMetadata, readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + formatConstructionLength, +} from '../shared/construction-length' +import { buildRoomClearDimensions } from './room-clear-dimensions' /** * Stage C floor-plan builder for zone. Zones are colored polygons — @@ -21,6 +27,7 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp if (!ring || ring.length < 3) return null const view = ctx.viewState + const floorplanContext = readFloorplanContext(ctx) const palette = view?.palette const isSelected = view?.selected ?? false const isHighlighted = view?.highlighted ?? false @@ -28,7 +35,8 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp const points: FloorplanPoint[] = ring.map(([x, z]) => [x, z] as FloorplanPoint) const stroke = showSelectedChrome && palette ? palette.selectedStroke : node.color - const fillOpacity = isSelected ? 0.28 : 0.16 + const isRoom = node.spaceRole === 'room' + const fillOpacity = isRoom ? (isSelected ? 0.12 : 0.04) : isSelected ? 0.28 : 0.16 const children: FloorplanGeometry[] = [ { @@ -91,9 +99,22 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp // it). Mirrors the legacy `FloorplanZoneLabel` so the look is // consistent. Centered on the polygon's area-weighted centroid; the // bbox-center fallback handles degenerate rings without throwing. + const [cx, cy] = polygonCentroid(ring) const name = node.name?.trim() - if (name) { - const [cx, cy] = polygonCentroid(ring) + if (isRoom) { + children.push( + ...buildRoomLabels( + node, + cx, + cy, + view?.unit ?? 'metric', + floorplanContext.purpose === 'document' ? 'document' : 'editor', + floorplanContext.metricNotation, + stroke, + ), + ) + children.push(...buildRoomClearDimensions(node, ctx)) + } else if (name) { children.push({ kind: 'text', x: cx, @@ -119,6 +140,61 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp } const ZONE_LABEL_FONT_SIZE = 0.2 +const ROOM_NAME_FONT_SIZE = 0.2 +const ROOM_NUMBER_FONT_SIZE = 0.16 +const ROOM_DETAIL_FONT_SIZE = 0.11 +const ROOM_LABEL_LINE_SPACING = 0.18 + +function buildRoomLabels( + node: ZoneNode, + x: number, + y: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: 'meters' | 'millimeters', + color: string, +): FloorplanGeometry[] { + const lines: Array<{ text: string; fontSize: number; fontWeight: number }> = [] + const name = node.name.trim() + if (name) lines.push({ text: name, fontSize: ROOM_NAME_FONT_SIZE, fontWeight: 700 }) + if (node.roomNumber) { + lines.push({ text: node.roomNumber, fontSize: ROOM_NUMBER_FONT_SIZE, fontWeight: 600 }) + } + + const finishes = [ + node.floorFinish ? `FL: ${node.floorFinish}` : '', + node.wallFinish ? `WL: ${node.wallFinish}` : '', + node.ceilingFinish ? `CL: ${node.ceilingFinish}` : '', + ].filter(Boolean) + if (finishes.length > 0) { + lines.push({ text: finishes.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + } + + const roomDetails = [ + `CH: ${formatConstructionLength(node.ceilingHeight, unit, profile, { metricNotation })}`, + ] + if (node.occupancy) roomDetails.push(node.occupancy) + lines.push({ text: roomDetails.join(' · '), fontSize: ROOM_DETAIL_FONT_SIZE, fontWeight: 500 }) + + const startY = y - ((lines.length - 1) * ROOM_LABEL_LINE_SPACING) / 2 + return lines.map((line, index) => ({ + kind: 'text', + x, + y: startY + index * ROOM_LABEL_LINE_SPACING, + text: line.text, + fontSize: line.fontSize, + fill: color, + stroke: '#ffffff', + strokeWidth: line.fontSize * 0.18, + paintOrder: 'stroke', + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: line.fontWeight, + textAnchor: 'middle', + dominantBaseline: 'central', + upright: true, + metadata: floorplanGeometryMetadata({ annotationRole: 'room-label' }), + })) +} /** * Area-weighted centroid of a simple polygon (Shoelace formula). Falls diff --git a/packages/nodes/src/zone/quantities-panel.tsx b/packages/nodes/src/zone/quantities-panel.tsx index f07837fd..60668030 100644 --- a/packages/nodes/src/zone/quantities-panel.tsx +++ b/packages/nodes/src/zone/quantities-panel.tsx @@ -12,10 +12,12 @@ import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel, + MetricControl, PanelSection, + ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useMemo } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' type Point2D = readonly [number, number] @@ -151,6 +153,167 @@ function QuantityRow({ ) } +function RoomTextField({ + label, + onCommit, + value, +}: { + label: string + onCommit: (value: string) => void + value: string +}) { + const [draft, setDraft] = useState(value) + const cancelRef = useRef(false) + + useEffect(() => setDraft(value), [value]) + + const commit = () => { + if (cancelRef.current) { + cancelRef.current = false + setDraft(value) + return + } + const next = draft.trim() + if (next !== value) onCommit(next) + else setDraft(value) + } + + return ( + + ) +} + +function RoomSelect({ + label, + onChange, + options, + value, +}: { + label: string + onChange: (value: string) => void + options: ReadonlyArray<{ label: string; value: string }> + value: string +}) { + return ( + + ) +} + +function RoomDocumentationPanel({ zone }: { zone: ZoneNode }) { + const updateNode = useScene((state) => state.updateNode) + const update = (patch: Partial) => updateNode(zone.id, patch) + const isRoom = zone.spaceRole === 'room' + + return ( + + update({ spaceRole: checked ? 'room' : 'generic' })} + /> + {isRoom ? ( + <> + update({ name })} + value={zone.name} + /> + update({ roomNumber })} + value={zone.roomNumber} + /> + + update({ enclosureStatus: enclosureStatus as ZoneNode['enclosureStatus'] }) + } + options={[ + { label: 'Auto-detect', value: 'auto' }, + { label: 'Enclosed', value: 'enclosed' }, + { label: 'Open', value: 'open' }, + ]} + value={zone.enclosureStatus} + /> + update({ occupancy })} + value={zone.occupancy} + /> + update({ floorFinish })} + value={zone.floorFinish} + /> + update({ wallFinish })} + value={zone.wallFinish} + /> + update({ ceilingFinish })} + value={zone.ceilingFinish} + /> + update({ ceilingHeight })} + precision={2} + step={0.05} + unit="m" + value={zone.ceilingHeight} + /> + + update({ + clearDimensionPolicy: clearDimensionPolicy as ZoneNode['clearDimensionPolicy'], + }) + } + options={[ + { label: 'None', value: 'none' }, + { label: 'Inside faces', value: 'inside-faces' }, + { label: 'Finish faces', value: 'finish-faces' }, + ]} + value={zone.clearDimensionPolicy} + /> + + ) : null} + + ) +} + export default function ZoneQuantitiesPanel() { const selectedZoneId = useViewer((state) => state.selection.zoneId) const unit = useViewer((state) => state.unit) @@ -193,48 +356,53 @@ export default function ZoneQuantitiesPanel() { if (!effectiveZone || !report) return null return ( - -
-
- {effectiveZone.name} - - {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} - + <> + + +
+
+ {effectiveZone.name} + + {report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'} + +
+
+ A + {formatAreaLabel(report.footprintArea, unit, 2)} + P + {formatLinearMeasurement(report.perimeter, unit)} +
-
- A - {formatAreaLabel(report.footprintArea, unit, 2)} - P - {formatLinearMeasurement(report.perimeter, unit)} + + + +
+ formatAreaLabel(value, unit, 2)} + label="Wall surface" + quantity={report.wallSurface} + /> + formatAreaLabel(value, unit, 2)} + label="Floor surface" + quantity={report.floorSurface} + /> + formatVolumeLabel(value, unit, 2)} + label="Volume" + quantity={report.volume} + />
-
- - - -
- formatAreaLabel(value, unit, 2)} - label="Wall surface" - quantity={report.wallSurface} - /> - formatAreaLabel(value, unit, 2)} - label="Floor surface" - quantity={report.floorSurface} - /> - formatVolumeLabel(value, unit, 2)} - label="Volume" - quantity={report.volume} - /> -
-
+ + ) } diff --git a/packages/nodes/src/zone/room-clear-dimensions.test.ts b/packages/nodes/src/zone/room-clear-dimensions.test.ts new file mode 100644 index 00000000..14f1afcd --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type FloorplanGeometry, + type GeometryContext, + WallNode, + ZoneNode, +} from '@pascal-app/core' +import { createFloorplanContextExtensions } from '@pascal-app/editor' +import { buildRoomClearDimensions } from './room-clear-dimensions' + +function enclosure(points: Array<[number, number]>) { + const walls = points.map((start, index) => + WallNode.parse({ + id: `wall_${index}`, + parentId: 'level_main', + start, + end: points[(index + 1) % points.length], + thickness: 0.2, + }), + ) + const zone = ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: points, + autoFromWalls: true, + boundaryWallIds: walls.map((wall) => wall.id), + spaceRole: 'room', + clearDimensionPolicy: 'inside-faces', + }) + const nodes = Object.fromEntries([...walls, zone].map((node) => [node.id, node])) as Record< + string, + AnyNode + > + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + return { context, nodes, walls, zone } +} + +function withFinishAssembly(wall: WallNode): WallNode { + return WallNode.parse({ + ...wall, + thickness: undefined, + assemblyLayers: [ + { + id: `${wall.id}_core`, + role: 'structure', + side: 'core', + thickness: 0.2, + datumEligible: ['structural-face'], + }, + { + id: `${wall.id}_interior-finish`, + role: 'interior-finish', + side: 'interior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + { + id: `${wall.id}_exterior-finish`, + role: 'exterior-finish', + side: 'exterior', + thickness: 0.02, + datumEligible: ['finish-face'], + }, + ], + }) +} + +function dimensions(geometry: FloorplanGeometry[]) { + return geometry.filter( + (entry): entry is Extract => + entry.kind === 'dimension', + ) +} + +describe('buildRoomClearDimensions', () => { + test('dimensions the proven inside faces of a rectangular room', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.8m', '3.8m']) + expect(result.every((entry) => entry.stroke === '#123456')).toBe(true) + expect(result[0]?.start[0]).toBeCloseTo(1.316) + expect(result[0]?.start[1]).toBeCloseTo(0.1) + expect(result[0]?.end[0]).toBeCloseTo(1.316) + expect(result[0]?.end[1]).toBeCloseTo(2.9) + }) + + test('preserves clear spans when the room is rotated', () => { + const angle = Math.PI / 6 + const rotate = ([x, y]: [number, number]): [number, number] => [ + x * Math.cos(angle) - y * Math.sin(angle), + x * Math.sin(angle) + y * Math.cos(angle), + ] + const { context, zone } = enclosure( + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ].map(rotate), + ) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('consolidates collinear wall segments before proving the clear rectangle', () => { + const { context, zone } = enclosure([ + [0, 0], + [2, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect( + dimensions(buildRoomClearDimensions(zone, context)) + .map((entry) => entry.text) + .sort(), + ).toEqual(['2.8m', '3.8m']) + }) + + test('dimensions finish faces when every boundary wall has assembly finish datums', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + const assembledWalls = walls.map(withFinishAssembly) + const assembledNodes = { ...nodes } + for (const wall of assembledWalls) assembledNodes[wall.id] = wall + + const result = dimensions( + buildRoomClearDimensions( + { ...zone, clearDimensionPolicy: 'finish-faces' }, + { + ...context, + resolve: (id) => assembledNodes[id], + }, + ), + ) + + expect(result).toHaveLength(2) + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m']) + }) + + test('adds a room-to-room finish-face dimension for adjacent rectangular rooms', () => { + const walls = [ + WallNode.parse({ id: 'wall_a_bottom', parentId: 'level_main', start: [0, 0], end: [4, 0] }), + WallNode.parse({ id: 'wall_shared', parentId: 'level_main', start: [4, 0], end: [4, 3] }), + WallNode.parse({ id: 'wall_a_top', parentId: 'level_main', start: [4, 3], end: [0, 3] }), + WallNode.parse({ id: 'wall_a_left', parentId: 'level_main', start: [0, 3], end: [0, 0] }), + WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }), + WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }), + WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }), + ].map(withFinishAssembly) + const zoneA = ZoneNode.parse({ + id: 'zone_a', + parentId: 'level_main', + name: 'A', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_a_bottom', 'wall_shared', 'wall_a_top', 'wall_a_left'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const zoneB = ZoneNode.parse({ + id: 'zone_b', + parentId: 'level_main', + name: 'B', + polygon: [ + [4, 0], + [8, 0], + [8, 3], + [4, 3], + ], + autoFromWalls: true, + boundaryWallIds: ['wall_b_bottom', 'wall_b_right', 'wall_b_top', 'wall_shared'], + spaceRole: 'room', + clearDimensionPolicy: 'finish-faces', + }) + const nodes = Object.fromEntries( + [...walls, zoneA, zoneB].map((node) => [node.id, node]), + ) as Record + const context = { + resolve: (id) => nodes[id], + children: [], + siblings: [zoneB], + parent: null, + viewState: { + selected: false, + highlighted: false, + hovered: false, + moving: false, + unit: 'metric', + palette: { + measurementStroke: '#123456', + } as NonNullable['palette'], + }, + extensions: createFloorplanContextExtensions({ purpose: 'edit' }), + } satisfies GeometryContext + + const result = dimensions(buildRoomClearDimensions(zoneA, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['2.76m', '3.76m', 'R-R 0.24m']) + expect(result.find((entry) => entry.text.startsWith('R-R'))?.text).toBe('R-R 0.24m') + }) + + test('dimensions proven rectilinear room bays beyond simple rectangles', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 2], + [2, 4], + [0, 4], + ]) + + const result = dimensions(buildRoomClearDimensions(zone, context)) + + expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m']) + }) + + test('suppresses dimensions when the requested datum cannot be proven', () => { + const { context, nodes, walls, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ]) + + expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([]) + expect( + buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context), + ).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([]) + expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([]) + + const missingWallNodes = { ...nodes } + delete missingWallNodes[walls[0]!.id] + expect( + buildRoomClearDimensions(zone, { + ...context, + resolve: (id) => missingWallNodes[id], + }), + ).toEqual([]) + }) + + test('suppresses dimensions for a proven enclosure that is not rectangular', () => { + const { context, zone } = enclosure([ + [0, 0], + [4, 0], + [4, 2], + [2, 3], + [0, 2], + ]) + + expect(buildRoomClearDimensions(zone, context)).toEqual([]) + }) +}) diff --git a/packages/nodes/src/zone/room-clear-dimensions.ts b/packages/nodes/src/zone/room-clear-dimensions.ts new file mode 100644 index 00000000..18e2576c --- /dev/null +++ b/packages/nodes/src/zone/room-clear-dimensions.ts @@ -0,0 +1,609 @@ +import { + detectSpacesForLevel, + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getWallAssemblyFaceOffsets, + resolveWallAssemblyDatumReferences, + type SpaceBoundaryFace, + type WallNode, + type ZoneNode, +} from '@pascal-app/core' +import { readFloorplanContext } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionMetricNotation, + formatConstructionLength, +} from '../shared/construction-length' + +const LINE_TOLERANCE = 1e-4 +const ANGLE_TOLERANCE = 1e-3 +const MIN_CLEAR_SPAN = 0.3 +const MIN_ROOM_TO_ROOM_SPAN = 0.03 +const FIRST_DIMENSION_POSITION = 0.32 +const SECOND_DIMENSION_POSITION = 0.68 +const EXTENSION_OVERSHOOT = 0.08 + +type FaceLine = { + start: FloorplanPoint + end: FloorplanPoint +} + +type DimensionGeometry = Extract + +type ClearDimensionPolicy = Extract< + ZoneNode['clearDimensionPolicy'], + 'inside-faces' | 'finish-faces' +> + +export function buildRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, +): FloorplanGeometry[] { + if ( + node.spaceRole !== 'room' || + (node.clearDimensionPolicy !== 'inside-faces' && + node.clearDimensionPolicy !== 'finish-faces') || + node.enclosureStatus === 'open' || + !node.autoFromWalls || + !node.parentId || + node.boundaryWallIds.length < 3 + ) { + return [] + } + + const walls = node.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (walls.length !== node.boundaryWallIds.length) return [] + + const boundaryIds = new Set(node.boundaryWallIds) + const space = detectSpacesForLevel(node.parentId, walls).spaces.find( + (candidate) => + candidate.wallIds.length === boundaryIds.size && + candidate.wallIds.every((id) => boundaryIds.has(id)), + ) + if (!space) return [] + + const wallsById = new Map(walls.map((wall) => [wall.id, wall])) + const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy) + if (!faceLines) return [] + + const unit = ctx.viewState?.unit ?? 'metric' + const floorplanContext = readFloorplanContext(ctx) + const profile: ConstructionLengthProfile = + floorplanContext.purpose === 'document' ? 'document' : 'editor' + const metricNotation = floorplanContext.metricNotation + const stroke = ctx.viewState?.palette.measurementStroke ?? '#475569' + const rectangle = resolveClearFaceRectangle(faceLines) + const dimensions = rectangle + ? buildRectangleClearDimensions(rectangle, unit, profile, metricNotation, stroke) + : buildRectilinearClearDimensions(faceLines, unit, profile, metricNotation, stroke) + if (dimensions.length === 0) return [] + return [ + ...dimensions, + ...buildRoomToRoomClearDimensions( + node, + ctx, + space.boundaryFaces, + wallsById, + unit, + profile, + metricNotation, + stroke, + ), + ] +} + +function resolveClearFaceLines( + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + policy: ClearDimensionPolicy, +): FaceLine[] | null { + const faceLines: FaceLine[] = [] + for (const boundary of boundaryFaces) { + const wall = wallsById.get(boundary.wallId) + if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null + const line = offsetBoundaryFace(boundary, wall, policy) + if (!line) return null + faceLines.push(line) + } + + const merged = mergeCollinearFaces(faceLines) + return merged.length >= 4 ? merged : null +} + +function resolveClearFaceRectangle( + merged: readonly FaceLine[], +): [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] | null { + if (merged.length !== 4) return null + + const vertices = merged.map((line, index) => { + const previous = merged[(index + merged.length - 1) % merged.length]! + return intersectLines(previous, line) + }) + if (vertices.some((vertex) => vertex === null)) return null + const rectangle = vertices as [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint] + const directions = rectangle.map((start, index) => + normalizedDirection(start, rectangle[(index + 1) % rectangle.length]!), + ) + if (directions.some((direction) => direction === null)) return null + const [first, second, third, fourth] = directions as [ + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + FloorplanPoint, + ] + if ( + Math.abs(dot(first, second)) > ANGLE_TOLERANCE || + Math.abs(dot(second, third)) > ANGLE_TOLERANCE || + Math.abs(dot(third, fourth)) > ANGLE_TOLERANCE || + Math.abs(dot(fourth, first)) > ANGLE_TOLERANCE || + dot(first, third) > -1 + ANGLE_TOLERANCE || + dot(second, fourth) > -1 + ANGLE_TOLERANCE + ) { + return null + } + return rectangle +} + +function buildRectangleClearDimensions( + rectangle: [FloorplanPoint, FloorplanPoint, FloorplanPoint, FloorplanPoint], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const first = dimensionAcrossOppositeFaces( + rectangle[0], + rectangle[1], + rectangle[3], + rectangle[2], + FIRST_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + const second = dimensionAcrossOppositeFaces( + rectangle[1], + rectangle[2], + rectangle[0], + rectangle[3], + SECOND_DIMENSION_POSITION, + unit, + profile, + metricNotation, + stroke, + ) + return first && second ? [first, second] : [] +} + +function buildRectilinearClearDimensions( + faceLines: readonly FaceLine[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + const vertices = clearFacePolygon(faceLines) + if (!vertices || !isRectilinearPolygon(vertices)) return [] + + const dimensions: FloorplanGeometry[] = [] + const seen = new Set() + for (let firstIndex = 0; firstIndex < faceLines.length; firstIndex++) { + const first = faceLines[firstIndex]! + const firstDirection = normalizedDirection(first.start, first.end) + if (!firstDirection) return [] + + for (let secondIndex = firstIndex + 1; secondIndex < faceLines.length; secondIndex++) { + const second = faceLines[secondIndex]! + const secondDirection = normalizedDirection(second.start, second.end) + if (!secondDirection) return [] + if (Math.abs(dot(firstDirection, secondDirection)) < 1 - ANGLE_TOLERANCE) continue + + const dimension = dimensionBetweenOverlappingParallelFaces( + first, + second, + firstDirection, + vertices, + unit, + profile, + metricNotation, + stroke, + ) + if (!dimension) continue + const key = dimensionKey(dimension) + if (seen.has(key)) continue + seen.add(key) + dimensions.push(dimension) + } + } + return dimensions +} + +function offsetBoundaryFace( + boundary: SpaceBoundaryFace, + wall: WallNode, + policy: ClearDimensionPolicy, +): FaceLine | null { + const first = boundary.points[0] + const last = boundary.points[boundary.points.length - 1] + if (!(first && last)) return null + + const wallDirection = normalizedDirection(wall.start, wall.end) + if (!wallDirection) return null + const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]] + const side = boundary.face === 'front' ? 1 : -1 + const faces = getWallAssemblyFaceOffsets(wall) + const offset = + policy === 'finish-faces' + ? resolveFinishFaceOffset(wall, side) + : side > 0 + ? faces.exterior + : faces.interior + if (offset === null) return null + return { + start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset], + end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset], + } +} + +function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null { + if ((wall.assemblyLayers ?? []).length === 0) return null + const references = resolveWallAssemblyDatumReferences(wall).filter( + (reference) => reference.datum === 'finish-face', + ) + const matching = references + .filter((reference) => Math.sign(reference.offset) === side) + .map((reference) => reference.offset) + if (matching.length === 0) return null + return side > 0 ? Math.max(...matching) : Math.min(...matching) +} + +function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null { + const vertices = faceLines.map((line, index) => { + const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]! + return intersectLines(previous, line) + }) + return vertices.some((vertex) => vertex === null) ? null : (vertices as FloorplanPoint[]) +} + +function isRectilinearPolygon(vertices: readonly FloorplanPoint[]): boolean { + if (vertices.length < 4) return false + const directions = vertices.map((start, index) => + normalizedDirection(start, vertices[(index + 1) % vertices.length]!), + ) + if (directions.some((direction) => direction === null)) return false + for (let index = 0; index < directions.length; index++) { + const current = directions[index]! + const next = directions[(index + 1) % directions.length]! + if (Math.abs(dot(current, next)) > ANGLE_TOLERANCE) return false + } + return true +} + +function dimensionBetweenOverlappingParallelFaces( + first: FaceLine, + second: FaceLine, + direction: FloorplanPoint, + polygon: readonly FloorplanPoint[], + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const firstStart = dot(first.start, direction) + const firstEnd = dot(first.end, direction) + const secondStart = dot(second.start, direction) + const secondEnd = dot(second.end, direction) + const overlapStart = Math.max(Math.min(firstStart, firstEnd), Math.min(secondStart, secondEnd)) + const overlapEnd = Math.min(Math.max(firstStart, firstEnd), Math.max(secondStart, secondEnd)) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(first, direction, projection) + const end = projectPointToLineProjection(second, direction, projection) + const midpoint: FloorplanPoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] + if (!pointInPolygon(midpoint, polygon)) return null + + const axis = normalizedDirection(start, end) + if (!axis) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function pointInPolygon(point: FloorplanPoint, polygon: readonly FloorplanPoint[]): boolean { + let inside = false + for ( + let index = 0, previousIndex = polygon.length - 1; + index < polygon.length; + previousIndex = index++ + ) { + const current = polygon[index]! + const previous = polygon[previousIndex]! + const intersects = + current[1] > point[1] !== previous[1] > point[1] && + point[0] < + ((previous[0] - current[0]) * (point[1] - current[1])) / (previous[1] - current[1]) + + current[0] + if (intersects) inside = !inside + } + return inside +} + +function dimensionKey(dimension: DimensionGeometry): string { + const first = `${roundKey(dimension.start[0])},${roundKey(dimension.start[1])}` + const second = `${roundKey(dimension.end[0])},${roundKey(dimension.end[1])}` + return first < second ? `${first}|${second}` : `${second}|${first}` +} + +function roundKey(value: number): number { + return Math.round(value / LINE_TOLERANCE) +} + +function buildRoomToRoomClearDimensions( + node: ZoneNode, + ctx: GeometryContext, + boundaryFaces: readonly SpaceBoundaryFace[], + wallsById: ReadonlyMap, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry[] { + if (node.clearDimensionPolicy !== 'finish-faces') return [] + + const neighboringRooms = ctx.siblings.filter( + (sibling): sibling is ZoneNode => + sibling.type === 'zone' && + sibling.id !== node.id && + String(node.id) < String(sibling.id) && + sibling.spaceRole === 'room' && + sibling.clearDimensionPolicy === 'finish-faces' && + sibling.enclosureStatus !== 'open' && + sibling.autoFromWalls && + sibling.parentId === node.parentId, + ) + if (neighboringRooms.length === 0) return [] + + const dimensions: FloorplanGeometry[] = [] + const currentBoundaryByWallId = new Map( + boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const neighbor of neighboringRooms) { + const sharedWallIds = neighbor.boundaryWallIds.filter((wallId) => + currentBoundaryByWallId.has(wallId), + ) + if (sharedWallIds.length === 0) continue + + const neighborWalls = neighbor.boundaryWallIds.flatMap((id) => { + const resolved = ctx.resolve(id) + return resolved && + typeof resolved === 'object' && + 'type' in resolved && + resolved.type === 'wall' + ? [resolved as WallNode] + : [] + }) + if (neighborWalls.length !== neighbor.boundaryWallIds.length) continue + const neighborWallIds = new Set(neighbor.boundaryWallIds) + const neighborSpace = detectSpacesForLevel(neighbor.parentId ?? '', neighborWalls).spaces.find( + (candidate) => + candidate.wallIds.length === neighborWallIds.size && + candidate.wallIds.every((id) => neighborWallIds.has(id)), + ) + if (!neighborSpace) continue + const neighborBoundaryByWallId = new Map( + neighborSpace.boundaryFaces.map((boundary) => [boundary.wallId, boundary]), + ) + + for (const wallId of sharedWallIds) { + const wall = wallsById.get(wallId) + const currentBoundary = currentBoundaryByWallId.get(wallId) + const neighborBoundary = neighborBoundaryByWallId.get(wallId) + if (!(wall && currentBoundary && neighborBoundary)) continue + const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces') + const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces') + if (!(currentLine && neighborLine)) continue + const dimension = dimensionAcrossSharedRoomWall( + currentLine, + neighborLine, + unit, + profile, + metricNotation, + stroke, + ) + if (dimension) dimensions.push(dimension) + } + } + + return dimensions +} + +function dimensionAcrossSharedRoomWall( + currentLine: FaceLine, + neighborLine: FaceLine, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): DimensionGeometry | null { + const direction = normalizedDirection(currentLine.start, currentLine.end) + if (!direction) return null + const neighborDirection = normalizedDirection(neighborLine.start, neighborLine.end) + if (!neighborDirection || Math.abs(dot(direction, neighborDirection)) < 1 - ANGLE_TOLERANCE) { + return null + } + + const currentStart = dot(currentLine.start, direction) + const currentEnd = dot(currentLine.end, direction) + const neighborStart = dot(neighborLine.start, direction) + const neighborEnd = dot(neighborLine.end, direction) + const overlapStart = Math.max( + Math.min(currentStart, currentEnd), + Math.min(neighborStart, neighborEnd), + ) + const overlapEnd = Math.min( + Math.max(currentStart, currentEnd), + Math.max(neighborStart, neighborEnd), + ) + if (overlapEnd - overlapStart < MIN_CLEAR_SPAN) return null + + const projection = (overlapStart + overlapEnd) / 2 + const start = projectPointToLineProjection(currentLine, direction, projection) + const end = projectPointToLineProjection(neighborLine, direction, projection) + const clear = distance(start, end) + if (clear < MIN_ROOM_TO_ROOM_SPAN) return null + const axis = normalizedDirection(start, end) + if (!axis) return null + + return { + kind: 'dimension', + start, + end, + offsetNormal: [-axis[1], axis[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: `R-R ${formatConstructionLength(clear, unit, profile, { metricNotation })}`, + stroke, + } +} + +function projectPointToLineProjection( + line: FaceLine, + direction: FloorplanPoint, + projection: number, +): FloorplanPoint { + const originProjection = dot(line.start, direction) + return [ + line.start[0] + direction[0] * (projection - originProjection), + line.start[1] + direction[1] * (projection - originProjection), + ] +} + +function mergeCollinearFaces(lines: readonly FaceLine[]): FaceLine[] { + const merged: FaceLine[] = [] + for (const line of lines) { + const previous = merged[merged.length - 1] + if (previous && canMerge(previous, line)) previous.end = line.end + else merged.push({ ...line }) + } + + while (merged.length > 1) { + const first = merged[0]! + const last = merged[merged.length - 1]! + if (!canMerge(last, first)) break + first.start = last.start + merged.pop() + } + return merged +} + +function canMerge(first: FaceLine, second: FaceLine): boolean { + const firstDirection = normalizedDirection(first.start, first.end) + const secondDirection = normalizedDirection(second.start, second.end) + if (!(firstDirection && secondDirection)) return false + return ( + dot(firstDirection, secondDirection) > 1 - ANGLE_TOLERANCE && + pointLineDistance(second.start, first) <= LINE_TOLERANCE + ) +} + +function intersectLines(first: FaceLine, second: FaceLine): FloorplanPoint | null { + const firstDirection: FloorplanPoint = [ + first.end[0] - first.start[0], + first.end[1] - first.start[1], + ] + const secondDirection: FloorplanPoint = [ + second.end[0] - second.start[0], + second.end[1] - second.start[1], + ] + const denominator = cross(firstDirection, secondDirection) + if (Math.abs(denominator) <= LINE_TOLERANCE) return null + const delta: FloorplanPoint = [second.start[0] - first.start[0], second.start[1] - first.start[1]] + const parameter = cross(delta, secondDirection) / denominator + return [ + first.start[0] + firstDirection[0] * parameter, + first.start[1] + firstDirection[1] * parameter, + ] +} + +function dimensionAcrossOppositeFaces( + firstStart: FloorplanPoint, + firstEnd: FloorplanPoint, + oppositeStart: FloorplanPoint, + oppositeEnd: FloorplanPoint, + position: number, + unit: 'metric' | 'imperial', + profile: ConstructionLengthProfile, + metricNotation: ConstructionMetricNotation, + stroke: string, +): FloorplanGeometry | null { + const start = interpolate(firstStart, firstEnd, position) + const end = interpolate(oppositeStart, oppositeEnd, position) + const direction = normalizedDirection(start, end) + if (!direction) return null + const length = distance(start, end) + if (length < MIN_CLEAR_SPAN) return null + return { + kind: 'dimension', + start, + end, + offsetNormal: [-direction[1], direction[0]], + offsetDistance: 0, + extensionOvershoot: EXTENSION_OVERSHOOT, + text: formatConstructionLength(length, unit, profile, { metricNotation }), + stroke, + } +} + +function normalizedDirection( + start: readonly [number, number], + end: readonly [number, number], +): FloorplanPoint | null { + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const length = Math.hypot(dx, dy) + return length <= LINE_TOLERANCE ? null : [dx / length, dy / length] +} + +function pointLineDistance(point: FloorplanPoint, line: FaceLine): number { + const direction = normalizedDirection(line.start, line.end) + if (!direction) return Number.POSITIVE_INFINITY + return Math.abs(cross(direction, [point[0] - line.start[0], point[1] - line.start[1]])) +} + +function interpolate(start: FloorplanPoint, end: FloorplanPoint, t: number): FloorplanPoint { + return [start[0] + (end[0] - start[0]) * t, start[1] + (end[1] - start[1]) * t] +} + +function distance(first: FloorplanPoint, second: FloorplanPoint): number { + return Math.hypot(second[0] - first[0], second[1] - first[1]) +} + +function dot(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[0] + first[1] * second[1] +} + +function cross(first: FloorplanPoint, second: FloorplanPoint): number { + return first[0] * second[1] - first[1] * second[0] +} diff --git a/packages/nodes/src/zone/room-documentation.test.ts b/packages/nodes/src/zone/room-documentation.test.ts new file mode 100644 index 00000000..8d5bf2ab --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, LevelNode, ZoneNode } from '@pascal-app/core' +import { buildRoomFloorplanSchedule } from './room-documentation' + +function room(overrides: Partial = {}) { + return ZoneNode.parse({ + id: 'zone_room', + parentId: 'level_main', + name: 'Office', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + ], + spaceRole: 'room', + roomNumber: '101', + floorFinish: 'Timber', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: 2.7, + occupancy: 'Business', + ...overrides, + }) +} + +function nodesFor(zones: ZoneNode[]) { + const level = LevelNode.parse({ + id: 'level_main', + children: zones.map((zone) => zone.id), + }) + return Object.fromEntries([level, ...zones].map((node) => [node.id, node])) as Record< + string, + AnyNode + > +} + +describe('buildRoomFloorplanSchedule', () => { + test('includes only architectural rooms and formats their documented values', () => { + const office = room({ id: 'zone_office', roomNumber: '102' }) + const lobby = room({ + id: 'zone_lobby', + name: 'Lobby', + roomNumber: '101', + polygon: [ + [0, 0], + [5, 0], + [5, 2], + [0, 2], + ], + floorFinish: '', + }) + const courtyard = room({ + id: 'zone_courtyard', + name: 'Courtyard', + roomNumber: '100', + spaceRole: 'generic', + }) + const zones = [office, lobby, courtyard] + + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.title).toBe('ROOM SCHEDULE') + expect(schedule?.rows.map((row) => row.id)).toEqual(['zone_lobby', 'zone_office']) + expect(schedule?.rows[0]?.cells).toMatchObject({ + number: '101', + name: 'Lobby', + area: '10.00 m²', + floorFinish: '—', + wallFinish: 'Paint', + ceilingFinish: 'ACT', + ceilingHeight: '2700', + occupancy: 'Business', + enclosure: 'Open', + }) + }) + + test('formats imperial schedule values', () => { + const office = room({ + polygon: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + }) + const schedule = buildRoomFloorplanSchedule({ + siblings: [office], + nodes: nodesFor([office]), + levelId: 'level_main', + unit: 'imperial', + }) + + expect(schedule?.rows[0]?.cells).toMatchObject({ + area: '10.8 ft²', + ceilingHeight: `8'-10 5/16"`, + }) + }) + + test('reports missing and duplicate room numbers plus unproven enclosure claims', () => { + const unnumbered = room({ + id: 'zone_unnumbered', + name: 'Storage', + roomNumber: '', + }) + const duplicateA = room({ id: 'zone_a', roomNumber: 'A01' }) + const duplicateB = room({ + id: 'zone_b', + name: 'Meeting', + roomNumber: 'a01', + enclosureStatus: 'enclosed', + }) + const zones = [unnumbered, duplicateA, duplicateB] + const schedule = buildRoomFloorplanSchedule({ + siblings: zones, + nodes: nodesFor(zones), + levelId: 'level_main', + unit: 'metric', + }) + + expect(schedule?.issues).toEqual([ + 'Room Storage has no room number', + 'Room a01 is marked enclosed but not proven', + 'Duplicate room number A01 (2 rooms)', + ]) + }) + + test('returns no schedule when the level has no architectural rooms', () => { + const zone = room({ spaceRole: 'generic' }) + expect( + buildRoomFloorplanSchedule({ + siblings: [zone], + nodes: nodesFor([zone]), + levelId: 'level_main', + unit: 'metric', + }), + ).toBeNull() + }) +}) diff --git a/packages/nodes/src/zone/room-documentation.ts b/packages/nodes/src/zone/room-documentation.ts new file mode 100644 index 00000000..7032cca5 --- /dev/null +++ b/packages/nodes/src/zone/room-documentation.ts @@ -0,0 +1,125 @@ +import { + type AnyNode, + deriveZoneQuantityReport, + resolveAutoZonePolygon, + type ZoneNode, +} from '@pascal-app/core' +import type { FloorplanSchedule } from '@pascal-app/editor' +import { + type ConstructionLengthProfile, + type ConstructionLinearUnit, + formatConstructionLength, +} from '../shared/construction-length' + +const SQUARE_FEET_PER_SQUARE_METER = 10.76391041671 +const ROOM_NUMBER_COLLATOR = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }) + +export function buildRoomFloorplanSchedule(args: { + siblings: ReadonlyArray + nodes: Readonly> + levelId: string + unit: ConstructionLinearUnit + profile?: ConstructionLengthProfile +}): FloorplanSchedule | null { + const rooms = args.siblings + .filter((zone) => zone.spaceRole === 'room') + .map((zone) => { + const polygon = resolveAutoZonePolygon(zone, (id) => args.nodes[id]) + const resolvedZone = polygon === zone.polygon ? zone : { ...zone, polygon } + return { zone: resolvedZone, report: deriveZoneQuantityReport(resolvedZone, args.nodes) } + }) + .sort((a, b) => compareRooms(a.zone, b.zone)) + + if (rooms.length === 0) return null + + return { + id: 'rooms', + title: 'ROOM SCHEDULE', + columns: [ + { key: 'number', label: 'NO.', weight: 0.7 }, + { key: 'name', label: 'ROOM NAME', weight: 1.35 }, + { key: 'area', label: 'AREA', weight: 0.9 }, + { key: 'floorFinish', label: 'FLOOR FINISH', weight: 1.15 }, + { key: 'wallFinish', label: 'WALL FINISH', weight: 1.15 }, + { key: 'ceilingFinish', label: 'CEILING FINISH', weight: 1.15 }, + { key: 'ceilingHeight', label: 'CLG. HT.', weight: 0.9 }, + { key: 'occupancy', label: 'OCCUPANCY / USE', weight: 1.25 }, + { key: 'enclosure', label: 'ENCLOSURE', weight: 0.9 }, + ], + rows: rooms.map(({ zone, report }) => ({ + id: zone.id, + cells: { + number: valueOrDash(zone.roomNumber), + name: valueOrDash(zone.name), + area: formatRoomArea(report.footprintArea, args.unit), + floorFinish: valueOrDash(zone.floorFinish), + wallFinish: valueOrDash(zone.wallFinish), + ceilingFinish: valueOrDash(zone.ceilingFinish), + ceilingHeight: formatConstructionLength( + zone.ceilingHeight, + args.unit, + args.profile ?? 'document', + ), + occupancy: valueOrDash(zone.occupancy), + enclosure: resolveEnclosure(zone, report.classification), + }, + })), + issues: collectRoomScheduleIssues(rooms), + } +} + +function compareRooms(a: ZoneNode, b: ZoneNode): number { + const numberComparison = ROOM_NUMBER_COLLATOR.compare(a.roomNumber.trim(), b.roomNumber.trim()) + if (numberComparison !== 0) return numberComparison + const nameComparison = a.name.localeCompare(b.name, 'en', { sensitivity: 'base' }) + return nameComparison !== 0 ? nameComparison : a.id.localeCompare(b.id) +} + +function valueOrDash(value: string): string { + return value.trim() || '—' +} + +function formatRoomArea(squareMeters: number, unit: ConstructionLinearUnit): string { + if (!Number.isFinite(squareMeters)) return '—' + if (unit === 'metric') return `${squareMeters.toFixed(2)} m²` + return `${(squareMeters * SQUARE_FEET_PER_SQUARE_METER).toFixed(1)} ft²` +} + +function resolveEnclosure(zone: ZoneNode, classification: 'footprint' | 'enclosed-room'): string { + if (zone.enclosureStatus === 'enclosed') return 'Enclosed' + if (zone.enclosureStatus === 'open') return 'Open' + return classification === 'enclosed-room' ? 'Enclosed' : 'Open' +} + +function collectRoomScheduleIssues( + rooms: ReadonlyArray<{ + zone: ZoneNode + report: { classification: 'footprint' | 'enclosed-room' } + }>, +): string[] { + const issues: string[] = [] + const numberedRooms = new Map() + + for (const { zone, report } of rooms) { + const number = zone.roomNumber.trim() + if (!number) { + issues.push(`Room ${zone.name.trim() || zone.id} has no room number`) + } else { + const normalized = number.toLocaleUpperCase() + const duplicates = numberedRooms.get(normalized) + if (duplicates) duplicates.push(zone) + else numberedRooms.set(normalized, [zone]) + } + + if (zone.enclosureStatus === 'enclosed' && report.classification !== 'enclosed-room') { + issues.push(`Room ${number || zone.name.trim() || zone.id} is marked enclosed but not proven`) + } + } + + for (const [normalizedNumber, duplicateRooms] of numberedRooms) { + if (duplicateRooms.length < 2) continue + issues.push(`Duplicate room number ${normalizedNumber} (${duplicateRooms.length} rooms)`) + } + + return issues +} diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index d3b26f9f..e2e604dc 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -923,7 +923,7 @@ export function GlbScene({ }) // E or click activates the openable in view. The click also re-locks the - // pointer via WalkthroughControls — harmless overlap; no selection happens. + // pointer through the walkthrough controller; no selection happens. const activateWalkDoor = useCallback(() => { if (walkDoorRef.current) toggleOpenable(walkDoorRef.current) }, [toggleOpenable]) diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 56d52163..0814ba7e 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -17,6 +17,7 @@ import { type Object3D, type PerspectiveCamera, Quaternion, + Raycaster, Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' @@ -25,7 +26,12 @@ import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { SCENE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl' -import { WALKTHROUGH_FOV } from './walkthrough-controls' + +// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels +// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral +// awareness without wide-angle distortion. Applied only while walking — both +// walkthrough controllers read this and restore the orbit FOV on exit. +export const WALKTHROUGH_FOV = 60 // Eye/capsule geometry mirrors the editor's first-person controller so the // baked walkthrough feels identical. The capsule centre sits below the eye; the @@ -37,6 +43,28 @@ const SPAWN_EYE_HEIGHT = 1.65 const LOOK_SENSITIVITY = 0.002 const VOID_FALL_RESPAWN_DEPTH = 12 +// Crouch (hold Ctrl): swap to a short capsule — it shrinks around the centre, +// so a crouch mid-jump also lowers the head AND raises the feet, letting the +// player thread window openings. Standing back up is gated on headroom. +// The float gap counts toward the effective obstacle height (the capsule rides +// floatHeight above the ground), so crouching also lowers it: crouched span is +// CROUCH_FLOAT_HEIGHT + capsule = 0.25 + 0.7 = 0.95 m — fits a 1 m opening. +export const STAND_CAPSULE: [number, number, number, number] = [0.25, 0.8, 4, 8] +export const CROUCH_CAPSULE: [number, number, number, number] = [0.25, 0.2, 4, 8] +export const STAND_FLOAT_HEIGHT = 0.5 +export const CROUCH_FLOAT_HEIGHT = 0.25 +export const CROUCH_EYE_OFFSET = 0.1 +export const CROUCH_WALK_SPEED = 1 +export const CROUCH_RUN_SPEED = 1.4 +// Headroom (from the capsule centre, upward) required before uncrouching: +// standing raises the centre by half the length delta plus the float delta, +// and the standing capsule top sits standLength/2 + radius above the centre. +export const STAND_CLEARANCE = 1.25 +export const EYE_LERP_SPEED = 12 + +const standClearanceRaycaster = new Raycaster() +const UP = new Vector3(0, 1, 0) + // Kinds that must not block the player: room helpers, the spawn marker, the // ceiling/roof shell (you walk under them), and door/window leaves — excluding // the latter lets you pass any doorway whether the leaf is open or shut (the @@ -54,7 +82,7 @@ const keyboardMap: Array<{ name: Exclude; keys: { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, ] -const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraOffset = new Vector3() const cameraEuler = new Euler(0, 0, 0, 'YXZ') const spawnQuat = new Quaternion() const spawnEuler = new Euler(0, 0, 0, 'YXZ') @@ -238,6 +266,10 @@ export function GlbWalkthroughController({ url }: { url: string }) { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const crouchKeyRef = useRef(false) + const suspendRef = useRef(false) + const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) + const [crouched, setCrouched] = useState(false) const [start, setStart] = useState<{ position: [number, number, number] } | null>(null) const [world, setWorld] = useState(null) @@ -333,20 +365,58 @@ export function GlbWalkthroughController({ url }: { url: string }) { if (event.code === 'Escape' && document.pointerLockElement !== canvas) { useViewer.getState().setWalkthroughMode(false) } + // P toggles a cursor pause (advertised in the HUD): frees the pointer + // without leaving the walkthrough — e.g. for an OS screenshot, which + // needs a movable cursor — and click or P resumes. + if (event.code === 'KeyP') { + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } else if (suspendRef.current) { + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } + } + // While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard screenshot) + // must not toggle it under the user. + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = true + } + } + const onKeyUp = (event: KeyboardEvent) => { + if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) { + crouchKeyRef.current = false + } + } + const onBlur = () => { + if (!suspendRef.current) crouchKeyRef.current = false } const onPointerLockChange = () => { - if (document.pointerLockElement === canvas) wasLocked = true - else if (wasLocked) useViewer.getState().setWalkthroughMode(false) + if (document.pointerLockElement === canvas) { + wasLocked = true + suspendRef.current = false + useViewer.getState().setWalkthroughSuspended(false) + } else if (suspendRef.current) { + // Deliberately released (screenshot pause) — stay in walkthrough. + } else if (wasLocked) { + useViewer.getState().setWalkthroughMode(false) + } } document.addEventListener('mousemove', onMouseMove) canvas.addEventListener('click', onClick) document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', onBlur) document.addEventListener('pointerlockchange', onPointerLockChange) return () => { document.removeEventListener('mousemove', onMouseMove) canvas.removeEventListener('click', onClick) document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', onBlur) document.removeEventListener('pointerlockchange', onPointerLockChange) + useViewer.getState().setWalkthroughSuspended(false) if (document.pointerLockElement === canvas) document.exitPointerLock() } }, [gl]) @@ -367,8 +437,16 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current = api }, []) + const hasStandingClearance = useCallback((position: Vector3) => { + const mesh = worldRef.current?.mesh + if (!mesh) return true + standClearanceRaycaster.set(position, UP) + standClearanceRaycaster.far = STAND_CLEARANCE + return standClearanceRaycaster.intersectObject(mesh, false).length === 0 + }, []) + // Drive the camera from the capsule each frame + respawn if it falls into void. - useFrame(() => { + useFrame((_, delta) => { const group = controllerRef.current?.group if (!group) return @@ -377,8 +455,18 @@ export function GlbWalkthroughController({ url }: { url: string }) { controllerRef.current?.resetLinVel() } + // Crouch follows the held key; standing back up waits for headroom. + // Frozen while the cursor pause is active. + if (!suspendRef.current && crouchKeyRef.current !== crouched) { + if (crouchKeyRef.current) setCrouched(true) + else if (hasStandingClearance(group.position)) setCrouched(false) + } + const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET + eyeOffsetRef.current += + (targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED) + group.rotation.y = 0 - camera.position.copy(group.position).add(cameraOffset) + camera.position.copy(group.position).add(cameraOffset.set(0, eyeOffsetRef.current, 0)) cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') camera.quaternion.setFromEuler(cameraEuler) camera.updateMatrixWorld(true) @@ -391,7 +479,7 @@ export function GlbWalkthroughController({ url }: { url: string }) { diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index c1754da6..2de9cade 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -23,6 +23,7 @@ import { applyIsolation, clearIsolation } from '../../lib/isolation' import { ensureKtx2Support } from '../../lib/ktx2-loader' import type { ColorPreset, RenderShading } from '../../lib/materials' import { getSceneTheme } from '../../lib/scene-themes' +import { installTextureNodeNullGuard } from '../../lib/texture-node-guard' import useViewer, { type RenderContext } from '../../store/use-viewer' import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevation-system' import { GeometrySystem } from '../../systems/geometry/geometry-system' @@ -37,6 +38,10 @@ import { SceneBvh } from './scene-bvh' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' +// Must be in place before any node material builds — a null texture pulled by +// a shared override-material pass otherwise kills the render pass outright. +installTextureNodeNullGuard() + declare module '@react-three/fiber' { // The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu` // namespace into JSX — `ThreeToJSXElements` triggers a TS2320 diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index b4e83eaf..74f2f509 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -526,6 +526,7 @@ const PostProcessingPasses = ({ let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { + enabled: () => !useViewer.getState().cameraDragging, primaryObjects: outliner.selectedObjects, secondaryObjects: outliner.hoveredObjects, primaryEdgeThickness: uniform(1), diff --git a/packages/viewer/src/components/viewer/walkthrough-controls.tsx b/packages/viewer/src/components/viewer/walkthrough-controls.tsx deleted file mode 100644 index 65c6b69f..00000000 --- a/packages/viewer/src/components/viewer/walkthrough-controls.tsx +++ /dev/null @@ -1,156 +0,0 @@ -'use client' - -import { PointerLockControls } from '@react-three/drei' -import { useFrame, useThree } from '@react-three/fiber' -import { useCallback, useEffect, useRef } from 'react' -import { type PerspectiveCamera, Vector3 } from 'three' -import useViewer from '../../store/use-viewer' - -const MOVE_SPEED = 5 -const EYE_HEIGHT = 1.6 - -// First-person FOV. The orbit camera is 50° (set on the Canvas), which feels -// cramped on foot; ~60° vertical (~90° horizontal at 16:9) restores peripheral -// awareness without wide-angle distortion. Applied only while walking — both -// walkthrough controllers read this and restore the orbit FOV on exit. -export const WALKTHROUGH_FOV = 60 - -const _direction = new Vector3() -const _forward = new Vector3() -const _right = new Vector3() - -export const WalkthroughControls = () => { - const controlsRef = useRef(null!) - const walkthroughMode = useViewer((s: any) => s.walkthroughMode) - const keys = useRef({ w: false, a: false, s: false, d: false }) - const camera = useThree((s) => s.camera) - - // Set initial eye height - useEffect(() => { - if (walkthroughMode) { - camera.position.y = EYE_HEIGHT - } - }, [walkthroughMode, camera]) - - // Widen FOV while walking; restore the orbit FOV on exit. - useEffect(() => { - if (!walkthroughMode) return - const cam = camera as PerspectiveCamera - if (!cam.isPerspectiveCamera) return - const prevFov = cam.fov - cam.fov = WALKTHROUGH_FOV - cam.updateProjectionMatrix() - return () => { - cam.fov = prevFov - cam.updateProjectionMatrix() - } - }, [walkthroughMode, camera]) - - // Keyboard handlers - useEffect(() => { - if (!walkthroughMode) return - - const onKeyDown = (e: KeyboardEvent) => { - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return - const key = e.key.toLowerCase() - - // ESC exits walkthrough mode completely - if (e.key === 'Escape') { - e.preventDefault() - e.stopPropagation() - useViewer.getState().setWalkthroughMode(false) - return - } - - if (key === 'w' || key === 'arrowup') keys.current.w = true - if (key === 'a' || key === 'arrowleft') keys.current.a = true - if (key === 's' || key === 'arrowdown') keys.current.s = true - if (key === 'd' || key === 'arrowright') keys.current.d = true - } - - const onKeyUp = (e: KeyboardEvent) => { - const key = e.key.toLowerCase() - if (key === 'w' || key === 'arrowup') keys.current.w = false - if (key === 'a' || key === 'arrowleft') keys.current.a = false - if (key === 's' || key === 'arrowdown') keys.current.s = false - if (key === 'd' || key === 'arrowright') keys.current.d = false - } - - window.addEventListener('keydown', onKeyDown) - window.addEventListener('keyup', onKeyUp) - - return () => { - window.removeEventListener('keydown', onKeyDown) - window.removeEventListener('keyup', onKeyUp) - // Reset keys on cleanup - keys.current = { w: false, a: false, s: false, d: false } - } - }, [walkthroughMode]) - - // Release pointer lock when walkthrough mode is turned off - useEffect(() => { - if (!walkthroughMode && document.pointerLockElement) { - document.exitPointerLock() - } - }, [walkthroughMode]) - - // Movement loop - useFrame((_, delta) => { - if (!(walkthroughMode && controlsRef.current)) return - - _direction.set(0, 0, 0) - - // Get camera forward and right vectors (XZ plane only) - camera.getWorldDirection(_forward) - _forward.y = 0 - _forward.normalize() - - _right.crossVectors(_forward, camera.up).normalize() - - if (keys.current.w) _direction.add(_forward) - if (keys.current.s) _direction.sub(_forward) - if (keys.current.d) _direction.add(_right) - if (keys.current.a) _direction.sub(_right) - - if (_direction.lengthSq() > 0) { - _direction.normalize().multiplyScalar(MOVE_SPEED * delta) - camera.position.add(_direction) - // Keep eye height constant - camera.position.y = EYE_HEIGHT - } - }) - - const handleClick = useCallback(() => { - if (walkthroughMode && controlsRef.current) { - // Feature detection: some browsers (Facebook/Instagram in-app, older Safari) - // don't support pointer lock on the canvas element - if (typeof controlsRef.current.lock === 'function') { - try { - controlsRef.current.lock() - } catch { - // Silently ignore — pointer lock unavailable in this browser context - } - } - } - }, [walkthroughMode]) - - // Click to lock - useEffect(() => { - if (!walkthroughMode) return - const canvas = document.querySelector('canvas') - if (!canvas) return - - canvas.addEventListener('click', handleClick) - return () => canvas.removeEventListener('click', handleClick) - }, [walkthroughMode, handleClick]) - - if (!walkthroughMode) return null - - // Skip PointerLockControls on browsers that don't support pointer lock - // (Facebook/Instagram in-app browsers, some iOS WebViews) - if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) { - return null - } - - return -} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 9a7e7165..8aba75c9 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -34,14 +34,25 @@ export { GlbScene, type GlbWalkthrough, } from './components/viewer/glb-scene' -export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller' +export { + CROUCH_CAPSULE, + CROUCH_EYE_OFFSET, + CROUCH_FLOAT_HEIGHT, + CROUCH_RUN_SPEED, + CROUCH_WALK_SPEED, + EYE_LERP_SPEED, + GlbWalkthroughController, + STAND_CAPSULE, + STAND_CLEARANCE, + STAND_FLOAT_HEIGHT, + WALKTHROUGH_FOV, +} from './components/viewer/glb-walkthrough-controller' export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' export { DEFAULT_HOVER_STYLES, SSGI_PARAMS, } from './components/viewer/post-processing' export { SceneEnvironment } from './components/viewer/scene-environment' -export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' @@ -108,9 +119,21 @@ export { SCENE_THEMES, type SceneTheme, } from './lib/scene-themes' +export { + getPascalTextureRef, + type PascalTextureColorSpace, + type PascalTextureMap, + type PascalTextureRef, + stampPascalTextureRef, + textureMapForSlot, +} from './lib/texture-reference' export { packNormalToRGB, unpackRGBToNormal } from './lib/tsl-compat' export { useItemLightPool } from './store/use-item-light-pool' -export { applyCountryUnitDefault, default as useViewer } from './store/use-viewer' +export { + applyCountryUnitDefault, + default as useViewer, + type MetricNotation, +} from './store/use-viewer' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { createColumnBoxGeometry, @@ -161,10 +184,9 @@ export { type SurfaceFrame, } from './systems/roof/roof-system' export { ScanSystem } from './systems/scan/scan-system' -// Slab system follows the wall + fence re-export pattern — composed into -// the registry-driven slab definition's `def.system`. Removed in Phase 6 -// alongside the legacy slab mount point. -export { generateSlabGeometry, SlabSystem } from './systems/slab/slab-system' +// Pure slab geometry generator — composed into the registry-driven slab +// definition's `def.geometry` in `@pascal-app/nodes`. +export { generateSlabGeometry } from './systems/slab/slab-system' export { getStairBodyMaterials, getStairRailingMaterial, diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 504e3034..b4ecb334 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -17,6 +17,7 @@ import { MeshLambertNodeMaterial, MeshStandardNodeMaterial } from 'three/webgpu' import { resolveCdnUrl } from './asset-url' import { isKtx2Url, ktx2Loader, whenKtx2Ready } from './ktx2-loader' import { getSceneTheme } from './scene-themes' +import { stampPascalTextureRef } from './texture-reference' export type RenderShading = 'solid' | 'rendered' export type ColorPreset = 'clay' | 'white' | 'mono' | 'blueprint' @@ -212,7 +213,10 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { const cached = textureCache.get(cacheKey) if (cached) return cached - const texture = pickTextureLoader(textureConfig.url).load(textureConfig.url) + const resolvedUrl = /^(?:asset|blob|data):/.test(textureConfig.url) + ? textureConfig.url + : (resolveCdnUrl(textureConfig.url) ?? textureConfig.url) + const texture = pickTextureLoader(resolvedUrl).load(resolvedUrl) texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping @@ -220,6 +224,11 @@ function getTexture(material?: MaterialSchema): THREE.Texture | undefined { texture.repeat.set(repeatX, repeatY) texture.updateMatrix() texture.colorSpace = THREE.SRGBColorSpace + stampPascalTextureRef(texture, { + kind: 'project-asset', + src: resolvedUrl, + slot: 'map', + }) textureCache.set(cacheKey, texture) return texture @@ -281,6 +290,11 @@ function getPresetTexture( const texture = pickTextureLoader(resolvedPath).load(resolvedPath) applyTextureProperties(texture, props, slot) + stampPascalTextureRef(texture, { + kind: 'material', + src: resolvedPath, + slot: slot ?? 'map', + }) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) return texture @@ -336,6 +350,11 @@ async function loadPresetTexture( const promise = load .then((texture) => { applyTextureProperties(texture, props, slot) + stampPascalTextureRef(texture, { + kind: 'material', + src: resolvedPath, + slot: slot ?? 'map', + }) setTextureCacheKey(texture, cacheKey) textureCache.set(cacheKey, texture) textureLoadPromises.delete(cacheKey) @@ -360,7 +379,13 @@ function queueTextureAssignment( const textureMaterial = material as TextureMaterial if (!path) { - textureMaterial[slot] = null + if (textureMaterial[slot] != null) { + // Rebuild the node graph: a cached WebGPU material keeps a TextureNode + // for the slot, whose per-frame material reference would pull the null + // and crash in TextureNode.update ("null (reading 'matrix')"). + textureMaterial[slot] = null + material.needsUpdate = true + } return } @@ -379,7 +404,14 @@ function queueTextureAssignment( return } - textureMaterial[slot] = null + // Cold load: clear the slot for the fetch window, and rebuild the node + // graph if it previously held a texture — reused cached materials otherwise + // keep a TextureNode whose reference pulls the null and crashes the render + // pass. Cold loads are the norm for freshly generated library materials. + if (textureMaterial[slot] != null) { + textureMaterial[slot] = null + material.needsUpdate = true + } loadPresetTexture(path, props, slot).then((texture) => { if (!texture) return diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts new file mode 100644 index 00000000..34a81b5b --- /dev/null +++ b/packages/viewer/src/lib/merged-outline-node.test.ts @@ -0,0 +1,22 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import { Object3D, PerspectiveCamera, Scene } from 'three' +import { mergedOutline } from './merged-outline-node' + +describe('merged outline rendering', () => { + test('skips outline work while the pass is disabled', () => { + const outline = mergedOutline(new Scene(), new PerspectiveCamera(), { + enabled: () => false, + primaryObjects: [new Object3D()], + }) + const frame = { + get renderer(): never { + throw new Error('outline renderer should not be touched') + }, + } + + expect(() => outline.updateBefore(frame)).not.toThrow() + outline.dispose() + }) +}) diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index 4c621ffb..e7dbdcd8 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -125,6 +125,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlowNode: any secondaryEdgeGlowNode: any downSampleRatio: number + enabled: () => boolean updateBeforeType: string private readonly _depthRT: RenderTarget @@ -189,6 +190,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow?: any secondaryEdgeGlow?: any downSampleRatio?: number + enabled?: () => boolean } = {}, ) { super('vec4') @@ -201,6 +203,7 @@ export class MergedOutlineNode extends TempNode { primaryEdgeGlow = float(0), secondaryEdgeGlow = float(0), downSampleRatio = 2, + enabled = () => true, } = params this.scene = scene @@ -212,6 +215,7 @@ export class MergedOutlineNode extends TempNode { this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) this.downSampleRatio = downSampleRatio + this.enabled = enabled this.updateBeforeType = NodeUpdateType.FRAME this._depthRT = new RenderTarget() @@ -301,8 +305,9 @@ export class MergedOutlineNode extends TempNode { } updateBefore(frame: any) { - const hasPrimary = this.primaryObjects.length > 0 - const hasSecondary = this.secondaryObjects.length > 0 + const enabled = this.enabled() + const hasPrimary = enabled && this.primaryObjects.length > 0 + const hasSecondary = enabled && this.secondaryObjects.length > 0 const hasAny = hasPrimary || hasSecondary // Fast-path: nothing to render and nothing was rendered last frame either, diff --git a/packages/viewer/src/lib/texture-node-guard.ts b/packages/viewer/src/lib/texture-node-guard.ts new file mode 100644 index 00000000..d607674a --- /dev/null +++ b/packages/viewer/src/lib/texture-node-guard.ts @@ -0,0 +1,44 @@ +import type * as THREE from 'three/webgpu' +import { DataTexture, TextureNode } from 'three/webgpu' + +let installed = false +let reported = 0 +let fallbackTexture: THREE.Texture | null = null + +function getFallbackTexture(): THREE.Texture { + if (!fallbackTexture) { + fallbackTexture = new DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1) + fallbackTexture.needsUpdate = true + } + return fallbackTexture +} + +/** + * three's node system pulls texture uniforms from materials each frame via + * reference nodes, and several override-material passes (shadow, depth/normal + * prepasses) copy per-object texture slots onto shared materials whose cached + * per-mesh node graphs can disagree about a slot's presence. When they do, + * `TextureNode.update` dereferences a null texture and the exception kills the + * whole render pass — the scene goes black. Substitute a 1×1 black fallback + * instead (skipping is not enough: the null would still reach the backend's + * texture-binding WeakMap). The slot renders black for a frame and recovers + * as soon as the reference pulls a real value again. + */ +export function installTextureNodeNullGuard(): void { + if (installed) return + installed = true + + const prototype = TextureNode.prototype as { update: () => void } + const originalUpdate = prototype.update + + prototype.update = function update(this: { value: unknown; uuid: string }) { + if (this.value == null) { + if (reported < 5) { + reported += 1 + console.warn(`[viewer] TextureNode ${this.uuid} has no texture — using fallback`) + } + this.value = getFallbackTexture() + } + originalUpdate.call(this) + } +} diff --git a/packages/viewer/src/lib/texture-reference.test.ts b/packages/viewer/src/lib/texture-reference.test.ts new file mode 100644 index 00000000..bb45b612 --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.test.ts @@ -0,0 +1,102 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { getPascalTextureRef, stampPascalTextureRef } from './texture-reference' + +// The module reads the storage origin lazily on first use, so setting the env +// here (before any stamp call) pins it for the whole test file. +process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co' +const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin + +describe('Pascal texture references', () => { + test("resolves 'material' input to library-material for storage-bucket URLs", () => { + const texture = new THREE.Texture() + texture.colorSpace = THREE.SRGBColorSpace + const src = `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/mtl_1/oak_basecolor_512.ktx2` + + const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'map' }) + + expect(ref).toEqual({ + v: 1, + kind: 'library-material', + src, + map: 'basecolor', + colorSpace: 'srgb', + }) + expect(getPascalTextureRef(texture)).toEqual(ref) + }) + + test("resolves 'material' input to app-material for assets-CDN catalog URLs", () => { + const cdnOrigin = new URL(process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app') + .origin + const texture = new THREE.Texture() + const src = `${cdnOrigin}/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2` + + const ref = stampPascalTextureRef(texture, { kind: 'material', src, slot: 'normalMap' }) + + expect(ref).toEqual({ v: 1, kind: 'app-material', src, map: 'normal', colorSpace: 'linear' }) + expect(getPascalTextureRef(texture)).toEqual(ref) + + const foreign = new THREE.Texture() + expect( + stampPascalTextureRef(foreign, { + kind: 'material', + src: 'https://example.com/material/concrete/x/x_basecolor_512.ktx2', + slot: 'map', + }), + ).toBeNull() + }) + + test('stamps the exact project-asset payload for Pascal storage URLs', () => { + const texture = new THREE.Texture() + texture.colorSpace = THREE.SRGBColorSpace + + const ref = stampPascalTextureRef(texture, { + kind: 'project-asset', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`, + slot: 'map', + }) + + expect(ref).toEqual({ + v: 1, + kind: 'project-asset', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/project-assets/project/asset.png`, + map: 'basecolor', + colorSpace: 'srgb', + }) + expect(getPascalTextureRef(texture)).toEqual(ref) + }) + + test('keeps local and non-Pascal URLs unstamped', () => { + for (const src of [ + 'asset://project/asset', + 'blob:https://editor.pascal.app/asset', + 'data:image/png;base64,AAAA', + 'https://example.com/storage/v1/object/public/project-assets/project/asset.png', + ]) { + const texture = new THREE.Texture() + expect(stampPascalTextureRef(texture, { kind: 'project-asset', src, slot: 'map' })).toBeNull() + expect(texture.userData.pascalTextureRef).toBeUndefined() + } + }) + + test('includes imageIndex only for item GLB references', () => { + const texture = new THREE.Texture() + const ref = stampPascalTextureRef(texture, { + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`, + slot: 'normalMap', + imageIndex: 2, + }) + + expect(ref).toEqual({ + v: 1, + kind: 'item-glb', + src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/model.glb`, + imageIndex: 2, + map: 'normal', + colorSpace: 'linear', + }) + }) +}) diff --git a/packages/viewer/src/lib/texture-reference.ts b/packages/viewer/src/lib/texture-reference.ts new file mode 100644 index 00000000..a11cbbda --- /dev/null +++ b/packages/viewer/src/lib/texture-reference.ts @@ -0,0 +1,185 @@ +import type * as THREE from 'three' +import { ASSETS_CDN_URL } from './asset-url' + +export type PascalTextureMap = + | 'basecolor' + | 'normal' + | 'roughness' + | 'metalness' + | 'height' + | 'other' + +export type PascalTextureColorSpace = 'srgb' | 'linear' + +type PascalTextureRefBase = { + v: 1 + src: string + map: PascalTextureMap + colorSpace: PascalTextureColorSpace +} + +export type PascalTextureRef = + | (PascalTextureRefBase & { + kind: 'library-material' | 'app-material' | 'project-asset' + }) + | (PascalTextureRefBase & { + kind: 'item-glb' + imageIndex: number + }) + +const STORAGE_BUCKET_BY_KIND = { + 'library-material': 'materials', + 'item-glb': 'items', + 'project-asset': 'project-assets', +} as const + +let cachedStorageOrigin: string | null | undefined +function pascalStorageOrigin(): string | null { + if (cachedStorageOrigin !== undefined) return cachedStorageOrigin + try { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL + cachedStorageOrigin = url ? new URL(url).origin : null + } catch { + cachedStorageOrigin = null + } + return cachedStorageOrigin +} + +/** Static catalog materials ship in the app's public dir and resolve through + * the assets CDN (`/material/{category}/{slug}/{slug}_{map}_{size}.ktx2`) — + * a server-known KTX2 source like the storage buckets, just app-hosted. */ +function isAppMaterialUrl(src: string): boolean { + try { + const url = new URL(src) + return url.origin === new URL(ASSETS_CDN_URL).origin && url.pathname.startsWith('/material/') + } catch { + return false + } +} + +const TEXTURE_MAPS = new Set([ + 'basecolor', + 'normal', + 'roughness', + 'metalness', + 'height', + 'other', +]) + +function isPascalStorageUrl(src: string, kind: keyof typeof STORAGE_BUCKET_BY_KIND): boolean { + const origin = pascalStorageOrigin() + if (!origin) return false + try { + const url = new URL(src) + const bucket = STORAGE_BUCKET_BY_KIND[kind] + return url.origin === origin && url.pathname.startsWith(`/storage/v1/object/public/${bucket}/`) + } catch { + return false + } +} + +export function textureMapForSlot(slot: string): PascalTextureMap { + switch (slot) { + case 'map': + return 'basecolor' + case 'normalMap': + return 'normal' + case 'roughnessMap': + return 'roughness' + case 'metalnessMap': + return 'metalness' + case 'displacementMap': + case 'bumpMap': + return 'height' + default: + return 'other' + } +} + +function textureColorSpace(texture: THREE.Texture): PascalTextureColorSpace { + return texture.colorSpace === 'srgb' ? 'srgb' : 'linear' +} + +export function stampPascalTextureRef( + texture: THREE.Texture, + input: + | { + /** 'material' resolves to library-material (storage bucket) or + * app-material (static catalog on the assets CDN) by URL shape. */ + kind: 'material' | 'project-asset' + src: string + slot: string + } + | { + kind: 'item-glb' + src: string + slot: string + imageIndex: number + }, +): PascalTextureRef | null { + const base = { + v: 1 as const, + src: input.src, + map: textureMapForSlot(input.slot), + colorSpace: textureColorSpace(texture), + } + + let ref: PascalTextureRef + if (input.kind === 'item-glb') { + if (!isPascalStorageUrl(input.src, 'item-glb')) return null + if (!Number.isInteger(input.imageIndex) || input.imageIndex < 0) return null + ref = { ...base, kind: 'item-glb', imageIndex: input.imageIndex } + } else { + const kind = + input.kind === 'material' + ? isPascalStorageUrl(input.src, 'library-material') + ? 'library-material' + : isAppMaterialUrl(input.src) + ? 'app-material' + : null + : isPascalStorageUrl(input.src, 'project-asset') + ? 'project-asset' + : null + if (!kind) return null + ref = { ...base, kind } + } + texture.userData.pascalTextureRef = ref + return ref +} + +export function getPascalTextureRef(texture: THREE.Texture): PascalTextureRef | null { + const raw = texture.userData.pascalTextureRef + if (!raw || typeof raw !== 'object') return null + + const candidate = raw as Record + const kind = candidate.kind + if ( + candidate.v !== 1 || + (kind !== 'library-material' && + kind !== 'app-material' && + kind !== 'item-glb' && + kind !== 'project-asset') || + typeof candidate.src !== 'string' || + !(kind === 'app-material' + ? isAppMaterialUrl(candidate.src) + : isPascalStorageUrl(candidate.src, kind)) || + typeof candidate.map !== 'string' || + !TEXTURE_MAPS.has(candidate.map as PascalTextureMap) || + (candidate.colorSpace !== 'srgb' && candidate.colorSpace !== 'linear') + ) { + return null + } + + const base: PascalTextureRefBase = { + v: 1, + src: candidate.src, + map: candidate.map as PascalTextureMap, + colorSpace: candidate.colorSpace, + } + if (kind === 'item-glb') { + if (!Number.isInteger(candidate.imageIndex) || (candidate.imageIndex as number) < 0) return null + return { ...base, kind, imageIndex: candidate.imageIndex as number } + } + if (candidate.imageIndex !== undefined) return null + return { ...base, kind } +} diff --git a/packages/viewer/src/store/use-viewer.test.ts b/packages/viewer/src/store/use-viewer.test.ts index 817b4045..4218d004 100644 --- a/packages/viewer/src/store/use-viewer.test.ts +++ b/packages/viewer/src/store/use-viewer.test.ts @@ -10,6 +10,7 @@ const resetMeasurementPreferences = () => { projectPreferences: {}, showMeasurements: true, unit: 'metric', + metricNotation: 'meters', }) } @@ -49,6 +50,17 @@ describe('measurement display preferences', () => { expect(useViewer.getState().projectPreferences).toEqual(preferences) expect(useViewer.getState().showMeasurements).toBe(false) }) + + test('selects millimeters as a metric display notation', () => { + useViewer.getState().setUnit('imperial') + useViewer.getState().setMetricNotation('millimeters') + + expect(useViewer.getState()).toMatchObject({ + unit: 'metric', + metricNotation: 'millimeters', + unitExplicit: true, + }) + }) }) describe('external selection highlights', () => { diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 085efb59..7190c178 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -10,6 +10,7 @@ import type { ColorPreset, RenderShading } from '../lib/materials' import { SCENE_THEME_IDS } from '../lib/scene-themes' export type RenderContext = 'editor' | 'viewer' +export type MetricNotation = 'meters' | 'millimeters' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -83,6 +84,8 @@ type ViewerState = { unit: 'metric' | 'imperial' setUnit: (unit: 'metric' | 'imperial') => void + metricNotation: MetricNotation + setMetricNotation: (notation: MetricNotation) => void /** True once the user explicitly picked a unit. Until then `unit` is a * locale-derived default and is not persisted, so the default can keep * tracking the browser locale across sessions. */ @@ -153,6 +156,11 @@ type ViewerState = { walkthroughMode: boolean setWalkthroughMode: (mode: boolean) => void + /** Pointer lock temporarily released mid-walkthrough (⌘/PrintScreen — OS + * screenshot needs a movable cursor); clicking the canvas re-locks. */ + walkthroughSuspended: boolean + setWalkthroughSuspended: (suspended: boolean) => void + cameraDragging: boolean setCameraDragging: (dragging: boolean) => void @@ -181,6 +189,7 @@ type PersistedViewerState = Partial< | 'edges' | 'shadows' | 'unit' + | 'metricNotation' | 'unitExplicit' | 'levelMode' | 'wallMode' @@ -193,6 +202,7 @@ const RENDER_SHADINGS = ['solid', 'rendered'] as const const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const const EDGE_MODES = ['off', 'soft', 'strong'] as const const UNITS = ['metric', 'imperial'] as const +const METRIC_NOTATIONS = ['meters', 'millimeters'] as const const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const @@ -304,6 +314,7 @@ function normalizePersistedViewerState(value: unknown): PersistedViewerState { edges: pickString(state.edges, EDGE_MODES, 'soft'), shadows: typeof state.shadows === 'boolean' ? state.shadows : true, unit: pickString(state.unit, UNITS, detectDefaultUnit()), + metricNotation: pickString(state.metricNotation, METRIC_NOTATIONS, 'meters'), unitExplicit: typeof state.unit === 'string' && UNITS.includes(state.unit as ViewerState['unit']), levelMode: pickString(state.levelMode, LEVEL_MODES, 'stacked'), @@ -386,8 +397,11 @@ const useViewer = create()( setShadows: (shadows) => set({ shadows }), unit: detectDefaultUnit(), + metricNotation: 'meters', unitExplicit: false, setUnit: (unit) => set({ unit, unitExplicit: true }), + setMetricNotation: (metricNotation) => + set({ unit: 'metric', metricNotation, unitExplicit: true }), levelMode: 'stacked', setLevelMode: (mode) => set({ levelMode: mode }), @@ -515,7 +529,10 @@ const useViewer = create()( setDebugColors: (enabled) => set({ debugColors: enabled }), walkthroughMode: false, - setWalkthroughMode: (mode) => set({ walkthroughMode: mode }), + setWalkthroughMode: (mode) => set({ walkthroughMode: mode, walkthroughSuspended: false }), + + walkthroughSuspended: false, + setWalkthroughSuspended: (suspended) => set({ walkthroughSuspended: suspended }), cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), @@ -537,6 +554,7 @@ const useViewer = create()( edges: state.edges, shadows: state.shadows, ...(state.unitExplicit ? { unit: state.unit } : {}), + metricNotation: state.metricNotation, levelMode: state.levelMode, wallMode: state.wallMode, projectPreferences: state.projectPreferences, diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index b1fc6623..e490ecf6 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -3,6 +3,7 @@ import { type CeilingNode, getEffectiveNode, nodeRegistry, + resolveCeilingHeight, sceneRegistry, useLiveTransforms, useScene, @@ -44,7 +45,7 @@ export const CeilingSystem = () => { // the final value on commit. Mirrors WallSystem / GeometrySystem. const effective = getEffectiveNode(node as CeilingNode) const itemHoles = collectCeilingHoles(effective, nodes) - updateCeilingGeometry(effective, mesh, itemHoles) + updateCeilingGeometry(effective, mesh, itemHoles, nodes) clearDirty(id as AnyNodeId) } // If mesh not found, keep it dirty for next frame @@ -88,6 +89,7 @@ function updateCeilingGeometry( node: CeilingNode, mesh: THREE.Mesh, extraHoles: Array> = [], + nodes: SceneNodes = useScene.getState().nodes, ) { const newGeo = generateCeilingGeometry(node, extraHoles) @@ -108,7 +110,11 @@ function updateCeilingGeometry( const liveTransform = useLiveTransforms.getState().get(node.id) mesh.position.x = liveTransform?.position[0] ?? 0 mesh.position.z = liveTransform?.position[2] ?? 0 - mesh.position.y = (node.height ?? 2.5) - 0.01 + (liveTransform?.position[1] ?? 0) // Slight offset to avoid z-fighting with upper-level slabs + // Resolved height: explicit when stored, else the level-top bound — so a + // follows-mode ceiling re-parks under the current plane on every rebuild + // (level-height edits / covering-slab changes dirty-mark ceilings). + // Slight offset to avoid z-fighting with upper-level slabs. + mesh.position.y = resolveCeilingHeight(node, nodes) - 0.01 + (liveTransform?.position[1] ?? 0) } /** diff --git a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx index 2098875e..33976fae 100644 --- a/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx +++ b/packages/viewer/src/systems/floor-elevation/floor-elevation-system.tsx @@ -3,6 +3,7 @@ import { type AnyNodeId, getEffectiveNode, getFloorStackedPosition, + type LiveTransform, nodeRegistry, sceneRegistry, useLiveTransforms, @@ -16,8 +17,7 @@ type PositionedNode = AnyNode & { rotation?: [number, number, number] | number } -function withLiveTransform(node: AnyNode, id: string): AnyNode { - const liveTransform = useLiveTransforms.getState().get(id) +function withLiveTransform(node: AnyNode, liveTransform: LiveTransform | undefined): AnyNode { if (!liveTransform) return node const currentRotation = (node as PositionedNode).rotation @@ -75,7 +75,8 @@ export const FloorElevationSystem = () => { const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D | undefined if (!mesh) return - const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), id) + const liveTransform = useLiveTransforms.getState().get(id) + const effectiveNode = withLiveTransform(getEffectiveNode(node as AnyNode), liveTransform) const position = (effectiveNode as PositionedNode).position if (!position) return @@ -91,6 +92,10 @@ export const FloorElevationSystem = () => { node: effectiveNode, nodes: resolverNodes, position, + // 3D drags publish the pointer-decided surface cap with their live + // transform; honoring it here keeps this system's per-frame Y in + // agreement with the tool's preview (no deck/floor flicker). + maxElevation: liveTransform?.supportElevationCap, }) mesh.position.y = visualPosition[1] diff --git a/packages/viewer/src/systems/level/level-stacking.test.ts b/packages/viewer/src/systems/level/level-stacking.test.ts deleted file mode 100644 index c71d7668..00000000 --- a/packages/viewer/src/systems/level/level-stacking.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not -// include Bun ambient types in its production declaration build. -import { describe, expect, test } from 'bun:test' -import { getLevelBuildingId, getLevelStackPositions, type LevelStackEntry } from './level-stacking' - -describe('getLevelBuildingId', () => { - const buildings = [ - { id: 'building_a', children: ['level_a0'] }, - { id: 'building_b', children: ['level_b0'] }, - ] - - test('uses an explicit building parent', () => { - expect(getLevelBuildingId('level_a0', 'building_a', buildings)).toBe('building_a') - }) - - test('falls back to building children for legacy levels without a parentId', () => { - expect(getLevelBuildingId('level_b0', null, buildings)).toBe('building_b') - }) - - test('ignores a non-building parent before checking building children', () => { - expect(getLevelBuildingId('level_a0', 'site_main', buildings)).toBe('building_a') - }) -}) - -describe('getLevelStackPositions', () => { - test('stacks levels within one building by level index', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_second', buildingId: 'building_a', index: 2, height: 3.4 }, - { levelId: 'level_ground', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_first', buildingId: 'building_a', index: 1, height: 3.1 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_ground: 0, - level_first: 2.5, - level_second: 5.6, - }) - }) - - test('starts each building on its own ground plane', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_a0', buildingId: 'building_a', index: 0, height: 2.5 }, - { levelId: 'level_b0', buildingId: 'building_b', index: 0, height: 3 }, - { levelId: 'level_a1', buildingId: 'building_a', index: 1, height: 2.8 }, - { levelId: 'level_b1', buildingId: 'building_b', index: 1, height: 3.2 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_a0: 0, - level_b0: 0, - level_a1: 2.5, - level_b1: 3, - }) - }) - - test('keeps orphan levels in one legacy stack', () => { - const entries: LevelStackEntry[] = [ - { levelId: 'level_0', buildingId: null, index: 0, height: 2.7 }, - { levelId: 'level_1', buildingId: null, index: 1, height: 3 }, - ] - - expect(Object.fromEntries(getLevelStackPositions(entries))).toEqual({ - level_0: 0, - level_1: 2.7, - }) - }) -}) diff --git a/packages/viewer/src/systems/level/level-stacking.ts b/packages/viewer/src/systems/level/level-stacking.ts deleted file mode 100644 index b966e383..00000000 --- a/packages/viewer/src/systems/level/level-stacking.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LevelStackEntry = { - levelId: string - buildingId: string | null - index: number - height: number -} - -type BuildingOwnership = { id: string; children: readonly string[] } - -export function getLevelBuildingId( - levelId: string, - parentId: string | null, - buildings: readonly BuildingOwnership[], -): string | null { - const directParent = parentId ? buildings.find((building) => building.id === parentId) : undefined - if (directParent) return directParent.id - - return buildings.find((building) => building.children.includes(levelId))?.id ?? null -} - -export function getLevelStackPositions(entries: readonly LevelStackEntry[]): Map { - const positions = new Map() - const cumulativeYByBuilding = new Map() - - for (const entry of [...entries].sort((a, b) => a.index - b.index)) { - const baseY = cumulativeYByBuilding.get(entry.buildingId) ?? 0 - positions.set(entry.levelId, baseY) - cumulativeYByBuilding.set(entry.buildingId, baseY + entry.height) - } - - return positions -} diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 80af5af3..3a20f6cb 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,16 +1,9 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' const EXPLODED_GAP = 5 @@ -26,44 +19,31 @@ export const LevelSystem = () => { const levelMode = useViewer.getState().levelMode const selectedLevel = useViewer.getState().selection.levelId - // Collect level heights so each building can compute its own cumulative offsets. - // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc. + const levelElevations = getLevelElevations(nodes) type LevelEntry = { levelId: string - buildingId: string | null index: number - height: number obj: NonNullable> } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) const selectedIndex = selectedLevel ? entries.find((e) => e.levelId === selectedLevel)?.index : undefined for (const { levelId, index, obj } of entries) { const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - const baseY = stackPositions.get(levelId) ?? 0 + const baseY = levelElevations.get(levelId)?.baseY ?? 0 const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 const targetY = baseY + explodedExtra diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index 25c03ba9..cfa29b3b 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,11 +1,4 @@ -import { - type BuildingNode, - getLevelHeight, - type LevelNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' -import { getLevelBuildingId, getLevelStackPositions } from './level-stacking' +import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' /** * Instantly snaps all level Objects3D to their true stacked Y positions @@ -25,33 +18,20 @@ export function snapLevelsToTruePositions(): () => void { type LevelEntry = { obj: NonNullable> levelId: string - buildingId: string | null - index: number - height: number } const entries: LevelEntry[] = [] - const buildings = Object.values(nodes).filter( - (node): node is BuildingNode => node?.type === 'building', - ) sceneRegistry.byType.level!.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined if (obj && level) { entries.push({ levelId, - buildingId: getLevelBuildingId(levelId, level.parentId, buildings), - index: level.level, - height: getLevelHeight( - levelId, - nodes, - (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, - ), obj, }) } }) - const stackPositions = getLevelStackPositions(entries) + const levelElevations = getLevelElevations(nodes) // Snapshot current Y and visibility so we can restore them after the render const snapshot = new Map( @@ -60,7 +40,7 @@ export function snapLevelsToTruePositions(): () => void { // Snap to true stacked positions and make all levels visible for (const { levelId, obj } of entries) { - obj.position.y = stackPositions.get(levelId) ?? 0 + obj.position.y = levelElevations.get(levelId)?.baseY ?? 0 obj.visible = true } diff --git a/packages/viewer/src/systems/slab/slab-system.test.ts b/packages/viewer/src/systems/slab/slab-system.test.ts index a6e58aa2..910c1dcc 100644 --- a/packages/viewer/src/systems/slab/slab-system.test.ts +++ b/packages/viewer/src/systems/slab/slab-system.test.ts @@ -7,6 +7,13 @@ import { generateSlabGeometry } from './slab-system' const EMPTY_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } +const SQUARE: Array<[number, number]> = [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], +] + function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { const positions = geometry.getAttribute('position') for (let index = 0; index < positions.count; index += 1) { @@ -17,16 +24,20 @@ function hasVertexAt(geometry: THREE.BufferGeometry, x: number, z: number) { return false } +function uniqueSortedYs(geometry: THREE.BufferGeometry): number[] { + const positions = geometry.getAttribute('position') + const ys = new Set() + for (let index = 0; index < positions.count; index += 1) { + ys.add(Math.round(positions.getY(index) * 1e4) / 1e4) + } + return [...ys].sort((a, b) => a - b) +} + describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation', () => { const slab = SlabNode.parse({ elevation: 0.05, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -47,12 +58,8 @@ describe('generateSlabGeometry', () => { test('renders a boundary-overlapping hole as an open indentation on recessed slabs', () => { const slab = SlabNode.parse({ elevation: -0.2, - polygon: [ - [0, 0], - [4, 0], - [4, 3], - [0, 3], - ], + recessed: true, + polygon: SQUARE, holes: [ [ [1, -0.5], @@ -69,4 +76,47 @@ describe('generateSlabGeometry', () => { expect(hasVertexAt(geometry, 1, 1)).toBe(true) expect(hasVertexAt(geometry, 3, 1)).toBe(true) }) + + test('solid slab occupies [elevation − thickness, elevation]', () => { + const slab = SlabNode.parse({ elevation: 0.3, thickness: 0.1, polygon: SQUARE }) + + const ys = uniqueSortedYs(generateSlabGeometry(slab, EMPTY_CONTEXT)) + + expect(ys).toEqual([0.2, 0.3]) + }) + + test('migrated legacy slab (thickness = elevation) reproduces the [0, elevation] extrusion', () => { + const slab = SlabNode.parse({ elevation: 0.05, thickness: 0.05, polygon: SQUARE }) + + const geometry = generateSlabGeometry(slab, EMPTY_CONTEXT) + + // Old-style expectations: extrude-from-zero put the bottom cap at 0 and + // the top cap at `elevation`, with 8 cap verts + 4 side quads (16 verts) + // and 12 triangles for a plain quad slab. + expect(uniqueSortedYs(geometry)).toEqual([0, 0.05]) + expect(geometry.getAttribute('position').count).toBe(24) + expect((geometry.index?.count ?? 0) / 3).toBe(12) + for (const [x, z] of SQUARE) { + expect(hasVertexAt(geometry, x, z)).toBe(true) + } + }) + + test('recess is keyed by the flag, not the elevation sign', () => { + const belowPlaneSolid = SlabNode.parse({ elevation: -0.2, thickness: 0.05, polygon: SQUARE }) + + // Without `recessed`, a negative elevation is just a solid placed below + // the level plane: closed body at [-0.25, -0.2], world-space Y baked in. + expect(uniqueSortedYs(generateSlabGeometry(belowPlaneSolid, EMPTY_CONTEXT))).toEqual([ + -0.25, -0.2, + ]) + + // The recessed shell is authored at local Y=0 (floor) up to |elevation| + // (rim), with no top cap: 4 floor verts + 4 wall quads = 20 verts, + // 2 floor + 8 wall triangles. + const pool = SlabNode.parse({ elevation: -0.2, recessed: true, polygon: SQUARE }) + const poolGeometry = generateSlabGeometry(pool, EMPTY_CONTEXT) + expect(uniqueSortedYs(poolGeometry)).toEqual([0, 0.2]) + expect(poolGeometry.getAttribute('position').count).toBe(20) + expect((poolGeometry.index?.count ?? 0) / 3).toBe(10) + }) }) diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index f1d220e2..00ba8c88 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -1,129 +1,33 @@ import { - type AnyNode, - type AnyNodeId, - getEffectiveNode, getRenderableSlabPolygon, type PolygonPoint2D, pointInPolygon2D, polygonsIntersect, type SlabNode, type SlabPolygonContext, - sceneRegistry, - useScene, - type WallNode, } from '@pascal-app/core' -import { useFrame } from '@react-three/fiber' -import { useEffect } from 'react' import * as THREE from 'three' import { subtractPolygonsFromPolygon } from '../../lib/polygon-union' import { mergeSurfaceHolePolygons } from '../surface-hole-geometry' -function ensureUv2Attribute(geometry: THREE.BufferGeometry) { - const uv = geometry.getAttribute('uv') - if (!uv) return - - geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) -} - // ============================================================================ -// SLAB SYSTEM +// SLAB GEOMETRY GENERATORS // ============================================================================ -export const SlabSystem = () => { - const dirtyNodes = useScene((state) => state.dirtyNodes) - const clearDirty = useScene((state) => state.clearDirty) - const markDirty = useScene((state) => state.markDirty) - - useEffect(() => { - const nodes = useScene.getState().nodes - for (const node of Object.values(nodes)) { - if (node.type === 'slab') { - markDirty(node.id) - } - } - }, [markDirty]) - - useFrame(() => { - if (dirtyNodes.size === 0) return - - const nodes = useScene.getState().nodes - const contextByLevel = new Map() - - // Process dirty slabs - dirtyNodes.forEach((id) => { - const node = nodes[id] - if (node?.type !== 'slab') return - - const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh - if (mesh) { - const slab = node as SlabNode - const levelContext = - contextByLevel.get(slab.parentId) ?? buildLevelSlabContext(slab.parentId, nodes) - contextByLevel.set(slab.parentId, levelContext) - updateSlabGeometry( - getEffectiveNode(slab), - excludeSlabFromContext(levelContext, slab.id), - mesh, - ) - clearDirty(id as AnyNodeId) - } - // If mesh not found, keep it dirty for next frame - }) - }, 1) - - return null -} - -function buildLevelSlabContext( - levelId: string | null, - nodes: Record, -): SlabPolygonContext { - const walls: WallNode[] = [] - const siblingSlabs: SlabNode[] = [] - for (const node of Object.values(nodes)) { - if (node.parentId !== levelId) continue - if (node.type === 'wall') walls.push(node as WallNode) - else if (node.type === 'slab') siblingSlabs.push(node as SlabNode) - } - return { walls, siblingSlabs } -} - -function excludeSlabFromContext(context: SlabPolygonContext, slabId: string): SlabPolygonContext { - return { - walls: context.walls, - siblingSlabs: context.siblingSlabs.filter((slab) => slab.id !== slabId), - } -} - /** - * Updates the geometry for a single slab - */ -function updateSlabGeometry(node: SlabNode, context: SlabPolygonContext, mesh: THREE.Mesh) { - const newGeo = generateSlabGeometry(node, context) - ensureUv2Attribute(newGeo) - - mesh.geometry.dispose() - mesh.geometry = newGeo - - // For negative elevation, shift the mesh down so the top face sits at Y=elevation - // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). - const elevation = node.elevation ?? 0.05 - mesh.position.y = elevation < 0 ? elevation : 0 -} - -/** - * Generates extruded slab geometry from polygon. `context` carries the - * slab's level neighbourhood (walls + sibling slabs) driving the per-edge - * render offsets — see `getRenderableSlabPolygon`. + * Generates slab geometry from polygon. `context` carries the slab's level + * neighbourhood (walls + sibling slabs) driving the per-edge render offsets — + * see `getRenderableSlabPolygon`. Branches on the explicit `recessed` intent: + * a recessed slab is an open shell (pool), everything else a solid occupying + * `[elevation − thickness, elevation]`. */ export function generateSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { - const elevation = slabNode.elevation ?? 0.05 - return elevation < 0 + return slabNode.recessed ? generatePoolGeometry(slabNode, context) - : generatePositiveSlabGeometry(slabNode, context) + : generateSolidSlabGeometry(slabNode, context) } // Earcut normalizes cap triangulation regardless of input winding, but the side @@ -175,7 +79,8 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) } /** - * Standard slab: flat extrusion upward from Y=0 by elevation thickness. + * Solid slab occupying `[elevation − thickness, elevation]`: the top cap is + * the walking surface at `elevation`, the body grows downward by `thickness`. * * Built directly in 3D (Y-up) rather than via ExtrudeGeometry so the hole side * walls can be emitted double-sided. The slab material is forced to FrontSide @@ -186,12 +91,14 @@ function buildSlabRegions(contour: PolygonPoint2D[], holes: PolygonPoint2D[][]) * thickness visible from any angle: the two coincident triangles never z-fight * because exactly one faces the camera under FrontSide culling. */ -function generatePositiveSlabGeometry( +function generateSolidSlabGeometry( slabNode: SlabNode, context: SlabPolygonContext, ): THREE.BufferGeometry { const polygon = ensureCounterClockwisePolygon(getRenderableSlabPolygon(slabNode, context)) const elevation = slabNode.elevation ?? 0.05 + const thickness = slabNode.thickness ?? 0.05 + const bottom = elevation - thickness const holePolygons = mergeSurfaceHolePolygons(slabNode.holes ?? []) if (polygon.length < 3) return new THREE.BufferGeometry() @@ -207,14 +114,14 @@ function generatePositiveSlabGeometry( const addWall = (a: THREE.Vector2, b: THREE.Vector2, flipped: boolean) => { const base = positions.length / 3 const len = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 0.001) - positions.push(a.x, 0, a.y) + positions.push(a.x, bottom, a.y) uvs.push(0, 0) - positions.push(b.x, 0, b.y) + positions.push(b.x, bottom, b.y) uvs.push(len, 0) positions.push(b.x, elevation, b.y) - uvs.push(len, elevation) + uvs.push(len, thickness) positions.push(a.x, elevation, a.y) - uvs.push(0, elevation) + uvs.push(0, thickness) // Standard winding on a CCW polygon gives inward-facing normals (see pool // path), so the unflipped quad faces outward; flipped is its back face. if (!flipped) { @@ -244,7 +151,7 @@ function generatePositiveSlabGeometry( } const bottomBase = positions.length / 3 for (const p of capPoints) { - positions.push(p.x, 0, p.y) + positions.push(p.x, bottom, p.y) uvs.push(p.x, -p.y) } @@ -303,7 +210,7 @@ function generatePoolGeometry( const pushFloorVertex = (x: number, y: number, z: number) => { positions.push(x, y, z) - // Floor UVs in metres (shape-space x, -z), matching generatePositiveSlabGeometry's + // Floor UVs in metres (shape-space x, -z), matching generateSolidSlabGeometry's // cap mapping so a finish tiles at the same world scale on every surface. uvs.push(x, -z) } diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index ebee5f3b..929accfe 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -2,7 +2,11 @@ import { type AnyNodeId, emitter, getWallFaceBandConfig, + getWallPlaneTop, + resolveLevelId, + resolveWallEffectiveHeight, sceneRegistry, + spatialGridManager, useScene, type WallNode, } from '@pascal-app/core' @@ -130,8 +134,22 @@ export const WallCutout = () => { const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const levelId = resolveLevelId(wallNode, sceneState.nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + wallNode.start, + wallNode.end, + wallNode.curveOffset ?? 0, + wallNode.thickness, + wallNode.supportSlabId, + ) + const effectiveWallHeight = resolveWallEffectiveHeight( + wallNode, + getWallPlaneTop(wallNode, levelId, sceneState.nodes), + support.elevation, + ) const shouldSelectionHighlight = - isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled + isSelectionHighlighted && !getWallFaceBandConfig(wallNode, effectiveWallHeight).enabled const materials = getMaterialsForWall( wallNode, shading, diff --git a/packages/viewer/src/systems/wall/wall-support-extension.test.ts b/packages/viewer/src/systems/wall/wall-support-extension.test.ts index 881fb29e..3c8516b0 100644 --- a/packages/viewer/src/systems/wall/wall-support-extension.test.ts +++ b/packages/viewer/src/systems/wall/wall-support-extension.test.ts @@ -1,7 +1,13 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { calculateLevelMiters, WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + calculateLevelMiters, + getWallPlaneTop, + WallNode, +} from '@pascal-app/core' import { generateExtrudedWall } from './wall-system' describe('wall support extension', () => { @@ -31,6 +37,96 @@ describe('wall support extension', () => { geometry.dispose() }) + test('plane-bound wall tops out at the storey plane regardless of slab elevation', () => { + const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.1 }) + + const flat = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0, 0, undefined, 3) + flat.computeBoundingBox() + expect(flat.boundingBox?.max.y).toBeCloseTo(3) + expect(flat.boundingBox?.min.y).toBeCloseTo(0) + flat.dispose() + + const raised = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0.6, + 0.6, + undefined, + 3, + ) + raised.computeBoundingBox() + // Mesh sits at Y=0.6, so a 2.4 local top keeps the world top at the 3m + // plane — the raised slab shortens the wall instead of lifting its top. + expect(raised.boundingBox?.max.y).toBeCloseTo(2.4) + expect(raised.boundingBox?.min.y).toBeCloseTo(0) + raised.dispose() + }) + + test('plane-bound wall under a flush thick deck tops out at the deck underside', () => { + // level_1 carries a flush deck occupying [-0.3, 0] above the storey + // plane: the covering-clamped plane for level_0 is 2.5 − 0.3 = 2.2. + const wall = WallNode.parse({ + start: [0.5, 2], + end: [3.5, 2], + thickness: 0.1, + parentId: 'level_0', + }) + const base = { object: 'node', parentId: null, visible: true, metadata: {}, children: [] } + const nodes = { + level_0: { + ...base, + id: 'level_0', + type: 'level', + level: 0, + height: 2.5, + children: [wall.id], + }, + level_1: { + ...base, + id: 'level_1', + type: 'level', + level: 1, + height: 2.5, + children: ['slab_deck'], + }, + slab_deck: { + ...base, + id: 'slab_deck', + type: 'slab', + parentId: 'level_1', + polygon: [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + ], + holes: [], + elevation: 0, + thickness: 0.3, + }, + } as unknown as Record + + const planeTop = getWallPlaneTop(wall, 'level_0', nodes) + expect(planeTop).toBeCloseTo(2.2) + + const geometry = generateExtrudedWall( + wall, + [], + calculateLevelMiters([wall]), + 0, + 0, + undefined, + planeTop, + ) + geometry.computeBoundingBox() + // Mesh sits at Y=0, so the world top lands at the deck underside instead + // of colliding with the slab solid above. + expect(geometry.boundingBox?.max.y).toBeCloseTo(2.2) + expect(geometry.boundingBox?.min.y).toBeCloseTo(0) + geometry.dispose() + }) + test('raises only the high-supported part of a mixed wall run', () => { const wall = WallNode.parse({ start: [0, 0], end: [4, 0], height: 2.5, thickness: 0.1 }) const geometry = generateExtrudedWall(wall, [], calculateLevelMiters([wall]), 0.6, 0.05, [ diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 20b0eaa8..754b02ef 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -2,7 +2,7 @@ import { type AnyNode, type AnyNodeId, calculateLevelMiters, - DEFAULT_WALL_HEIGHT, + DEFAULT_LEVEL_HEIGHT, type DoorNode, getAdjacentWallIds, getEffectiveNode, @@ -11,6 +11,7 @@ import { getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, + getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, @@ -18,6 +19,7 @@ import { type Point2D, pointToKey, resolveLevelId, + resolveWallTop, sceneRegistry, spatialGridManager, useLiveNodeOverrides, @@ -222,15 +224,16 @@ function getWallFaceMaterialIndex( wall: Pick, face: 'front' | 'back', y: number, + effectiveWallHeight: number, ): number { const semantic = face === 'front' ? wall.frontSide : wall.backSide const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior' const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback - const bands = getWallFaceBandConfig(wall) + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side] - const band = getWallFaceBandForHeight(wall, y) + const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight) return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)] } @@ -238,6 +241,7 @@ function assignWallMaterialGroups( geometry: THREE.BufferGeometry, wall: WallNode, boundaryEdges: TaggedWallBoundaryEdge[], + effectiveWallHeight: number, ) { const position = geometry.getAttribute('position') if (!position) return @@ -317,7 +321,12 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex( + wall, + nearestTag, + centroid.y, + effectiveWallHeight, + ) } geometry.clearGroups() @@ -445,15 +454,15 @@ function splitGeometryAtHorizontalPlanes( return split } -function getWallBandSplitPlanes(wall: WallNode): number[] { - const bands = getWallFaceBandConfig(wall) +function getWallBandSplitPlanes(wall: WallNode, effectiveWallHeight: number): number[] { + const bands = getWallFaceBandConfig(wall, effectiveWallHeight) if (!bands.enabled) return [] const planes = [bands.lowerTop] if (bands.count >= 3) planes.push(bands.middleTop) if (bands.count >= 4) planes.push(bands.upperTop) return planes.filter( (plane) => - plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON, + plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON, ) } @@ -695,12 +704,16 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { if (!mesh) return const levelId = resolveLevelId(node, nodes) + // Covering-clamped plane: a flush/thick slab on the level above shortens + // the plane-bound walls below it (explicit-height walls ignore the value). + const planeTop = getWallPlaneTop(node, levelId, nodes) const slabSupport = spatialGridManager.getSlabSupportForWall( levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, + node.supportSlabId, ) const slabElevation = slabSupport.elevation @@ -731,6 +744,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). @@ -755,6 +769,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, + planeTop, ) collisionMesh.geometry.dispose() collisionMesh.geometry = collisionGeo @@ -845,14 +860,20 @@ export function generateExtrudedWall( baseSegments: readonly WallSlabSupportSegment[] = [ { start: 0, end: 1, elevation: baseElevation }, ], + storeyHeight = DEFAULT_LEVEL_HEIGHT, ): THREE.BufferGeometry { const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] } const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] } - const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT - const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight + const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation) + const effectiveWallHeight = topElevation - slabElevation const effectiveBaseElevation = Math.min(baseElevation, slabElevation) const localBottom = effectiveBaseElevation - slabElevation const height = topElevation - effectiveBaseElevation + // A slab at or above the storey plane leaves a plane-bound wall with no + // body — bail before ExtrudeGeometry sees a non-positive depth. + if (height <= 1e-9) { + return new THREE.BufferGeometry() + } const thickness = getWallThickness(wallNode) @@ -913,7 +934,7 @@ export function generateExtrudedWall( geometry.rotateX(-Math.PI / 2) if (Math.abs(localBottom) > 1e-9) geometry.translate(0, localBottom, 0) geometry.computeVertexNormals() - assignWallMaterialGroups(geometry, wallNode, boundaryEdges) + assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(geometry) // Start with the lowest required wall prism, then remove the volume below @@ -1015,10 +1036,10 @@ export function generateExtrudedWall( if (cutoutBrushes.length === 0) { const splitGeometry = splitGeometryAtHorizontalPlanes( geometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitGeometry.computeVertexNormals() - assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitGeometry) return splitGeometry } @@ -1052,10 +1073,10 @@ export function generateExtrudedWall( const resultGeometry = csgGeometry(resultBrush) const splitResultGeometry = splitGeometryAtHorizontalPlanes( resultGeometry, - getWallBandSplitPlanes(wallNode), + getWallBandSplitPlanes(wallNode, effectiveWallHeight), ) splitResultGeometry.computeVertexNormals() - assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight) ensureRenderableGeometryAttributes(splitResultGeometry) return splitResultGeometry diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index 8680b786..4b9bb1ce 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -21,6 +21,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | | [spatial-queries](spatial-queries.md) | Placement validation (`canPlaceOnFloor`/`Wall`/`Ceiling`) for tools | | [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` | +| [vertical-model](vertical-model.md) | Stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, clamp rules, and the load migration | | [events](events.md) | Typed event bus — emitting and listening to node and grid events | | [creating-rules](creating-rules.md) | How to add or update a page in this folder | diff --git a/wiki/architecture/measurements.md b/wiki/architecture/measurements.md index 29f81085..f9b7d935 100644 --- a/wiki/architecture/measurements.md +++ b/wiki/architecture/measurements.md @@ -60,7 +60,7 @@ Measurement nodes must remain in `AnyNode`, `LevelNode.children`, the built-in n These functions are pure and receive the same read-only `GeometryContext` used by registered geometry. They must not import Three.js, editor state, or `useScene`. Feature IDs describe semantic roles (`wall:face:left`, `wall:height`, `roof:ridge:0`); labels never act as identifiers. -The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. +The wall contribution samples the existing curved-wall centerline and resolves face hits with normalized `t` plus clamped height. Exact plan-level wall corners bind to `wall:start` or `wall:end` before the thickness-aware face matcher runs. Curved walls additionally publish `wall:curve:center`, allowing radius, center-mark, chord, arc-length, and angular construction dimensions to bind their complete defining geometry and follow later curve edits. Arc-length and angular drafting use four explicit clicks: first arc/ray point, center/vertex, second arc/ray point, then label-line position; their anchors persist in point-center-point order. The roof-segment contribution reuses `getRoofSegmentPlanLinework` and the existing roof surface-height calculation, then applies segment and parent-roof transforms. Slab, ceiling, zone, and site use one shared polygon contribution with stable `vertex:`, `boundary`, and `center` roles. An exact corner uses the point feature so it remains a corner when the polygon changes shape; continuous boundary anchors store normalized perimeter position. Do not duplicate any of those topology implementations in measurement code. `resolveMeasurementNode` derives current free-point geometry from the scene snapshot. Renderers subscribe to referenced nodes, their parents, and ephemeral node overrides; the floor-plan cache uses `def.floorplanDependencies` and the same override-merged resolver. A host edit therefore changes measurement geometry and value during the drag and after commit without writing the measurement node or adding history entries. diff --git a/wiki/architecture/systems.md b/wiki/architecture/systems.md index c90500bb..87ae82c8 100644 --- a/wiki/architecture/systems.md +++ b/wiki/architecture/systems.md @@ -17,13 +17,14 @@ Pure logic: no rendering, no Three.js objects. They read nodes from `useScene`, | System | Responsibility | |---|---| | `WallSystem` | Wall mitering, corner joints | -| `SlabSystem` | Polygon-based floor/roof generation | | `CeilingSystem` | Polygon-based ceiling generation | | `RoofSystem` | Pitched roof shape | | `DoorSystem` | Placement constraints on walls | | `WindowSystem` | Placement constraints on walls | | `ItemSystem` | Item transforms, collision | +Slab geometry has no dedicated system: it renders through the registry `def.geometry` (`packages/nodes/src/slab/geometry.ts`, calling the pure generators in `packages/viewer/src/systems/slab/slab-system.tsx`) with a small `def.system` for dirty tracking. + ### Viewer Systems — `packages/viewer/src/systems/` Access Three.js objects (via `useRegistry`) and manage rendering side-effects. diff --git a/wiki/architecture/vertical-model.md b/wiki/architecture/vertical-model.md new file mode 100644 index 00000000..757aa776 --- /dev/null +++ b/wiki/architecture/vertical-model.md @@ -0,0 +1,81 @@ +# Vertical Model + +*How buildings stack: stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, and the clamp rules that keep it all coherent.* + +Applies to: anything that reads or writes vertical geometry — levels, walls, slabs, ceilings, stairs, fences, floor-placed items. + +The invariant, in one sentence: + +> Wall tops are pinned to the level plane; floors and platforms move what stands on +> them, never the walls or the storey above; anything that doesn't fit is clamped, +> never asked. + +**Sources**: `packages/core/src/services/storey.ts`, `packages/core/src/systems/wall/wall-top.ts`, `packages/core/src/systems/slab/slab-support.ts`, `packages/core/src/systems/stair/stair-rise.ts`, `packages/core/src/store/use-scene.ts` (migration Pass 3) + +## Stored truth + +| Field | Meaning | Absent means | +|---|---|---| +| `level.height` | Storey height in meters, floor-to-floor. Level world Y = per-building prefix sum of stored heights, ordered by the `level` ordinal (`getLevelElevations`). | Unmigrated legacy data (never seen post-load; the migration writes it). Consumers fall back to `DEFAULT_LEVEL_HEIGHT` (2.5). | +| `wall.height` | Explicit custom height (half wall, parapet). Top = elected base + height. | **Plane-bound** (the default): the top follows `getWallPlaneTop` — `min(level height, lowest covering-slab underside over the span)`. Slabs lift only the base. | +| `ceiling.height` | Explicit custom height, write-clamped to the bound. | **Follows the level**: resolves live to `getCeilingClampBound` = `min(level height, covering underside) − 0.01`. | +| `slab.elevation` | The walking surface (top), level-local. | Default 0.05. | +| `slab.thickness` | Grows **downward**: the solid occupies `[elevation − thickness, elevation]`. | Default 0.05. | +| `slab.recessed` | Pool intent: open shell, floor at (negative) `elevation`, inner walls up to the plane. Excluded from "covering" queries and wall-face adoption. | Solid slab. | +| `supportSlabId` | Persisted support host on walls and all floor-placed kinds. Written at commit **only when overlapping supports disagree on elevation**; `'ground'` sentinel pins bare ground under a deck. | Support is elected per query (coverage election for walls, footprint max for items). | +| `stair.deckSlabId` | Destination deck: rise follows `deck.elevation − the stair's own elected base` live; cutout sync disabled while attached. | Destination is a level. | +| `stair.totalRise` | Explicit custom rise (wins over everything). | Follows: derived from the deck or the containing level; `syncStairRises` converges straight-stair segments to the resolved rise. | + +Two schema rules protect these semantics: + +- **No Zod defaults on meaning-bearing fields.** `level.height`, `wall.height`, `ceiling.height`, `stair.totalRise` are `.optional()` with no `.default()` — absence is data. Creation sites write values explicitly; `migrateNodes` output is cast, not parsed, so a schema default would never materialize on legacy load anyway. +- **The store deletes explicit-`undefined` keys.** `updateNode(id, { height: undefined })` removes the key (see `mergeNodeUpdate` in `node-actions.ts`); that is how "Follows level/deck" mode switches work. UI mode controls derive state from field presence — no persisted mode enums. + +## Resolution helpers (use these, never `?? 2.5`) + +| Helper | Home | Resolves | +|---|---|---| +| `getStoredLevelHeight`, `getLevelElevations`, `getLevelAbove/Below` | `services/storey.ts` | Level heights, per-building stacking, neighbors | +| `getWallPlaneTop` | `services/storey.ts` | A plane-bound wall's top: level height clamped to covering-slab undersides, span-sampled with boundary-inclusive band overlap | +| `resolveWallTop`, `resolveWallEffectiveHeight`, `MIN_WALL_HEIGHT` | `systems/wall/wall-top.ts` | A wall's top / effective height given plane + elected base | +| `getWallEffectiveHeightForNodes` | spatial-grid manager | The above with the real slab election, for UI overlays | +| `getCeilingClampBound`, `getCoveringSlabUndersideAt` | `services/storey.ts` | Ceiling bound; the cross-level covering query (level above, non-recessed slabs) | +| `resolveCeilingHeight` | `services/level-height.ts` | A ceiling's effective height (explicit or follows) | +| `resolveStairTotalRise`, `syncStairRises` | `systems/stair/stair-rise.ts` | Stair rise precedence + straight-flight convergence | +| `computeWallSlabSupport`, `getSlabSupportForItem`, `getSupportCandidatesForFootprint` | `systems/slab/slab-support.ts` + spatial-grid manager | Support election (rendered polygons, host-preferring, optional `maxElevation` cap) | +| `clampSlabElevationForWalls`, `applySlabTopChange`, `SLAB_UNSTICK_THRESHOLD` | slab-support + `nodes/slab/elevation-limit.ts` | Slab edit clamps and the adaptive drag/panel rules | + +## Clamp rules (clamp, never ask) + +- A slab under plane-bound walls clamps its elevation to `level height − MIN_WALL_HEIGHT` (0.5). +- Ceilings clamp (at write time, and reactively downward via space-detection) to `min(level top, covering-slab underside) − 0.01`. +- Plane-bound wall tops clamp to covering-slab undersides — a thick or flush upper-level slab shortens the walls below it (Revit's attach-to-floor-bottom, automatic). Explicit-height walls are exempt. +- Slab vertical editing is adaptive: the panel moves placement (thickness untouched); the viewport drag stretches a grounded slab (elevation and thickness together) up to `SLAB_UNSTICK_THRESHOLD` (0.4), then unsticks it into a 0.05-thick deck; floating decks move with thickness preserved and re-ground at underside 0; pools keep the drag-through-zero gesture. +- Wall-face adoption in `getRenderableSlabPolygon` applies only to grounded slabs (`elevation − thickness ≤ 0.01`, not recessed) — floating decks keep their drawn polygon and are skipped as seam candidates. + +## Pointer-decided placement + +Grid events intersect a plane that rides the ghost's elevation, so any stacked-surface decision must come from the true camera ray, not the plane hit: `getPointedSupportSurface` returns the nearest slab plane the ray crosses inside its rendered polygon plus the crossing point, and both the support-election cap (`maxElevation`) and the cursor XZ derive from that single computation. Pointing under a deck elects the floor; pointing at the deck top elects the deck; commits persist the capped winner (or `'ground'`). 2D floorplan placement has no camera ray and keeps max-election. + +## Load migration (lives in `migrateNodes` Pass 3, indefinitely) + +Because community autosave only persists after the first post-load edit, the migration must remain in `migrateNodes`: + +- Writes each legacy level's **exact** derived height (a default legacy storey stores 2.55 = 0.05 slab + 2.5 wall) — never snapped to presets. +- Compacts `level` ordinals per building, anchored at zero (non-negatives → 0,1,2…; negatives → −1,−2… — basements stay basements). Runs every load; idempotent. +- Classifies wall tops against the derived plane: `|plane − top| < 0.20` **strict** → plane-bound (height key removed); else explicit (materializing 2.5 on absent-height short walls). ε calibrated by a prod census: intentional 0.20-short walls exist and must not snap. +- Ceilings within ε of the bound (and all `autoFromWalls` ceilings) drop their height → follows mode; stairs drop the legacy blind `totalRise: 2.5`. Both gated on the scene being legacy (some level lacked `height`). +- Slabs get `thickness := elevation` (byte-identical occupied interval, including degenerate zero); negative-elevation pools become `recessed: true` with elevation unchanged. + +## Gotchas + +- **Ordinals are semantic.** `level < 0` renders "Basement N"; `level === 0` is the ground-floor lookup. Never renumber without the zero anchor. +- **Boundary geometry.** Auto slabs derive polygons from wall centerlines, so wall/ceiling clamp samples sit exactly on polygon edges — always use the boundary-inclusive band-overlap helpers (`wallOverlapsSlabFootprint`, `slabCoversPoint`), never raw ray-cast point-in-polygon on those paths. +- **Straight stairs build from stored segment heights**, not the resolved rise — any rise change must go through `syncStairRises` (applied by `StairOpeningSystem`, history-paused, one microtask after store updates so the spatial grid has settled). +- **Reactivity is explicit.** A `level.height` change dirties that level's walls/stairs/ceilings/fences; a slab change dirties the level below's walls/ceilings and deck-attached stairs (`spatial-grid-sync.ts`). If a new consumer reads these bounds, wire its dirty rule there. +- **Host lifecycle.** Deleting a slab strips `supportSlabId`/`deckSlabId` from survivors in the same undo commit; a host merely reshaped away falls back silently and resumes if the slab returns. +- **Clone paths differ.** `clone-scene-graph.ts` remaps `supportSlabId`/`deckSlabId`; the editor clipboard (`scene-clipboard.ts`) intentionally does not (it re-elects); room placement remaps them (fixed in the private repo's `room-placement.ts`). When adding a new clone/instantiation path, remap both fields. + +## Deferred by decision (see the private repo's plan archive) + +Persistent Room identity, partial-storey navigation, slab reference-face enums, suspended ceilings, and a site datum for sloped terrain all have named gates in `plans/` — none block this model. Decks ship as catalog rooms/presets; the one-gesture mezzanine/balcony tools were removed (code preserved at editor `e30042db`). diff --git a/wiki/floorplan-chapter-17-assessment.md b/wiki/floorplan-chapter-17-assessment.md new file mode 100644 index 00000000..6dfb85cd --- /dev/null +++ b/wiki/floorplan-chapter-17-assessment.md @@ -0,0 +1,203 @@ +# Floor Plan Chapter 17 Assessment + +## Purpose + +This document compares the guidance in `Chapter_17_Floor_Plan_Dimensions_and_Notes.pdf` with Pascal's current floor-plan implementation. It records what the chapter teaches, what the editor already supports, and the remaining construction-document gaps. + +The review covered the full 19-page chapter and the floor-plan stack across: + +- Core floor-plan, wall, opening, and measurement schemas. +- The registry-owned `FloorplanGeometry` contract. +- Editor 2D rendering and interaction layers. +- Node-specific floor-plan builders. +- Automatic wall and opening dimension planning. +- Persistent measurements and smart measurement. +- Door/window documentation and schedules. +- Per-level PDF export. + +## What the chapter is teaching + +The chapter is primarily about construction communication, not merely measuring geometry. Its main principles are: + +1. A drawing must locate and size every construction-critical feature without requiring field workers to guess, scale the drawing, or perform unnecessary arithmetic. +2. Dimensions must be organized into consistent strings that remain readable and uncrowded. +3. The selected datum must match the construction method: centerline, face of stud, face of finish, masonry opening, rough opening, or another explicit reference. +4. Dimension graphics must follow a consistent standard: thin lines, extension-line gaps, extension-line overshoot, uniform terminators, readable aligned text, and predictable spacing. +5. Exterior strings normally progress from detailed opening/partition information to the overall building dimension. +6. Local or specific notes identify individual features through leaders. General notes apply to the whole drawing and are normally numbered in a dedicated sheet area. +7. Door/window schedules and feature notes may replace repeated dimensions when they communicate the information more clearly. +8. Drawing scale, paper-space text size, line weight, and sheet composition are part of the construction-document contract. +9. Curved, circular, masonry, concrete, and foundation-related construction require different dimension semantics from ordinary wood-frame walls. + +## Current implementation + +### Automatic construction dimensions + +`packages/nodes/src/wall/construction-dimensions.ts` already produces coordinated level-wide construction dimensions. The exterior hierarchy includes: + +1. Opening widths. +2. Door and window center locations. +3. Intersecting partition references. +4. Structural columns. +5. Facade jogs, projections, and recesses. +6. Overall facade dimensions. +7. A structural overall dimension when an exterior column row extends beyond the wall envelope. + +The planner also supports: + +- Collinear wall runs that form one facade. +- Disconnected facade runs. +- Angled exterior walls. +- Exterior-side classification. +- Wall-thickness-aware partition references. +- Interior partition strings, including geometrically enclosed partitions whose side metadata remains stale after wall splitting. +- Subdivision chains on every exterior orientation when internal walls divide a facade into multiple runs. +- Hosted door and window widths. +- Interior clear spans bounded by adjacent wall faces. +- Suppression of very short accidental segments. +- Associative updates when the contributing model geometry changes. + +`packages/nodes/src/wall/floorplan.ts` integrates these dimensions into the registry-driven wall floor-plan builder. + +### Dimension graphics + +`packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx` implements several conventions from the chapter: + +- Aligned dimension lines. +- A gap between the feature and extension line. +- Extension lines that pass beyond the dimension line. +- Consistent 45-degree architectural slash terminators. +- Thin dimension and extension lines. +- Text above the dimension line. +- Text that remains readable when the plan is rotated. +- Explicit aligned baselines for stepped facade dimensions. +- Separate edit and document presentation profiles. +- True modeled wall thickness in document output while retaining interactive legibility in edit mode. +- Paper-space dimension text, tick, extension-gap, overshoot, and label-offset sizing in PDF output. +- Whole-millimetre document notation without an `mm` suffix, while retaining metre notation in the interactive editor. +- Short-segment values outside the dimension ticks when the value cannot fit inside. + +### Automatic annotation layout + +`packages/editor/src/components/editor-2d/renderers/floorplan-annotation-layout.ts` now resolves automatic dimension-value collisions in both the live floor plan and PDF composition. It supports: + +- Label-to-label separation, including dense clusters. +- Stable same-string drawing order and priority for farther-out architectural strings. +- Movement along the dimension string before crossing into an adjacent tier. +- Fixed door/window mark pills as obstacles. +- Semantic architectural obstacles for walls, wall corners, door symbols and swing envelopes, windows, and columns. +- Sampled diagonal wall outlines, avoiding the oversized screen-aligned bounds produced by rotated walls. +- Outside-end placement for short values, followed by outside-start when the end side is blocked. +- Matching baseline extensions when a short value changes sides. +- A leader and true tick-to-tick baseline when both outside positions require further relocation. + +The former orange/red dashed collision overlay was removed because it displayed stale pre-layout conflicts on top of labels that the automatic resolver had already made readable. Any future unresolved-collision reporting should live in a separate preflight surface rather than being painted over the drawing. + +`packages/nodes/src/shared/construction-length.ts` formats imperial construction dimensions using feet, inches, and reduced fractions rounded to the nearest sixteenth. + +### Persistent measurements + +The existing measurement system is broader than the chapter's drafting examples. It supports: + +- Distance. +- Angle. +- Area. +- Perimeter. +- Prism volume. +- Free and associative semantic anchors. +- Wall, roof, slab, ceiling, zone, and site features. +- Live updates when referenced geometry changes. +- Dangling-reference presentation and explicit detach behavior. +- 2D and 3D drafting and editing. +- Smart transient measurement reports. + +The architecture is documented in `wiki/architecture/measurements.md`. These measurements are analysis annotations; they are not yet a complete replacement for architectural construction-dimension strings. + +### Door and window documentation + +`packages/nodes/src/shared/opening-documentation.ts` provides: + +- Deterministic automatic door and window marks. +- Explicit mark overrides. +- Duplicate explicit-mark warnings. +- Mark bubbles and leaders. +- Door schedules. +- Window schedules. +- Nominal dimensions. +- Optional verified rough-opening dimensions. +- Window sill and head heights. +- Door operation, frame, and hardware fields. + +The rough-opening fields intentionally remain optional rather than being invented from the nominal modeled opening size. + +### Rooms, stairs, and other plan graphics + +- Zones render a centered name but currently represent generic colored polygons rather than a complete architectural room model. +- Stairs render footprints, treads, and direction arrows, but do not yet emit a complete construction stair note. +- Columns can contribute structural center references to automatic exterior strings. +- The generic floor-plan registry already renders walls, doors, windows, slabs, ceilings, zones, roofs, stairs, columns, furniture, MEP nodes, and annotation nodes through a common geometry contract. + +### PDF export + +`packages/editor/src/lib/floorplan/floorplan-export.tsx` currently provides: + +- Per-level PDF plan pages. +- North-up orientation that accounts for building rotation. +- Full and structure-only export scopes. +- Door and window schedule pages. +- Registry-driven geometry matching the live floor-plan builders. +- Conversion of non-scaling SVG strokes for PDF output. +- Preservation of persistent measurement value labels in full export. +- Respect for the existing measurement-visibility preference. +- Document-purpose wall rendering at modeled thickness. +- Document metric notation and initial paper-space sizing for construction dimensions and measurement labels. +- The same automatic annotation collision layout used by the live floor plan. + +The plan is fitted to an A4 landscape page. It is not yet plotted at a fixed architectural scale. + +## Important current limitations + +### Interactive measurement and construction dimension are different concepts + +The measurement system stores geometric analysis annotations. The wall planner creates automatic construction strings. There is no dedicated manual construction-dimension object that lets a drafter pick references, place a baseline, add points to a continuous string, and later reposition or suppress individual segments. + +### The current datum is not truly face of stud + +`WallNode` stores total thickness and finish materials but does not describe studs, sheathing, finish layers, veneer, air space, concrete block, or furring. Automatic dimensions can reference a generic wall face, but the model cannot yet prove that this face is a structural stud face or finish face. + +### Paper-space control is only partially implemented + +Exported construction dimensions and measurement labels now resolve their main text, tick, extension-gap, overshoot, and label-offset sizes from paper points. Note text, mark bubbles, room labels, remaining line-weight categories, and fixed user-selectable drawing scales still require the drawing-sheet work. + +### Construction dimensions have no independent visibility layer + +The live floor plan exposes independent visibility controls for automatic dimensions, manual dimensions, measurements, opening marks, structural grids, room labels, and stair annotations. Full export intentionally includes every supported annotation category regardless of the live-view toggles. + +### Automatic collision layout has no persistent manual override + +Automatic placement now handles adjacent labels, short values, opening marks, and the first set of architectural obstacles. It does not yet let a drafter pin a chosen label position, suppress a segment, or persist a view-specific layout override. Broader fixed-symbol coverage and a separate unresolved-collision preflight also remain. + +### Curved and circular construction dimensions + +Curved walls emit an automatic radius leader and center mark in live plans and document output, matching the chapter's curved-wall callout method. Manual associative construction dimensions cover radius, diameter, center, chord, arc-length, coordinate-pattern, and angular-pattern workflows, with curved-wall defining geometry resolved from stable semantic host features. + +### Construction systems are not semantically modeled + +The editor cannot yet apply different documentation rules for wood framing, masonry veneer, concrete block, structural masonry, or solid concrete because those assembly semantics do not exist in the wall model. + +### The floor plan has no drawing-sheet model + +The export layer produces plan and schedule pages, but there is no persistent drawing sheet with view identity, scale, title block, drawing number, note blocks, graphic scale, north arrow, or per-view annotation visibility. + +## Features that should not be copied blindly + +The chapter was published in 2012. Its example sizes and clearances are useful drafting and design references, but they should not be treated as current building-code requirements. + +Any implementation of hallway, fixture, door, stair, appliance, or room-clearance checks should: + +- Be configurable by jurisdiction and standard profile. +- Be presented as an advisory or verification result unless code provenance is known. +- Avoid embedding manufacturer-dependent rough openings or product sizes as universal facts. +- Avoid silently omitting dimensions merely because a feature is commonly considered standard. + +The product should prefer explicit model data, verified manufacturer data, and user-controlled documentation policies. diff --git a/wiki/floorplan-pdf-export-library-research.md b/wiki/floorplan-pdf-export-library-research.md new file mode 100644 index 00000000..69de739f --- /dev/null +++ b/wiki/floorplan-pdf-export-library-research.md @@ -0,0 +1,269 @@ +# Floor-plan PDF export library research + +Date: 2026-07-21 + +## Decision summary + +The missing dimension values are a conversion-boundary problem, not a limitation of PDF text. +The current export builds an SVG in the DOM and asks `svg2pdf.js` to reinterpret that SVG as PDF. +That makes the result depend on how the converter handles nested transforms, inherited SVG styles, +font discovery, text baselines, paint order, and non-scaling strokes. Replacing `svg2pdf.js` with a +second automatic SVG converter leaves those same risks in place. + +The reliable design is to render the existing semantic `FloorplanGeometry` directly into PDF +primitives. Dimension values must be emitted with the PDF library's native text API, and dimension +lines/ticks must be emitted with explicit point widths. That preserves selectable vector text and +removes SVG/CSS interpretation from the critical path. + +Recommended choices: + +1. **Smallest and lowest-risk:** keep jsPDF but stop sending dimension annotations through + `svg2pdf.js`. Draw dimensions as a native jsPDF overlay with `doc.text`, `doc.line`, and + `doc.rect`. This is the best implementation choice even though it is not a library replacement. +2. **If a different library is required:** use **PDFKit directly**, rendering from + `FloorplanGeometry`. It has the strongest current combination of browser support, native vector + drawing, transformation support, font embedding, and active maintenance. +3. **Do not choose another automatic SVG converter** as the primary fix. In particular, + `SVG-to-PDFKit` has been inactive since 2022 and documents unsupported features and browser font + loading caveats. + +No production code was changed as part of this research. + +## Current architecture and why it matters + +The current code already has the right source model for a direct PDF backend: + +- Node builders return semantic `FloorplanGeometry`, including `dimension`, `dimension-string`, + `dimension-label`, lines, paths, polygons, circles, and groups. +- [`floorplan-dimension-renderer.tsx`](../packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx) + resolves each dimension's line endpoints, ticks, label point, label angle, font size, and label + placement. +- [`floorplan-export.tsx`](../packages/editor/src/lib/floorplan/floorplan-export.tsx) currently mounts + a React SVG off-screen and converts it with jsPDF + `svg2pdf.js`. + +That means a new export backend does not need to infer measurements from DOM nodes. It can traverse +the same geometry tree and emit native PDF operations deterministically. + +The current converter itself says that custom fonts must be registered before conversion, calls +itself "by no means perfect," and notes that its visual tests can vary because of text measurement. +Those are material warnings for small, rotated architectural labels. +[Official `svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) + +## Requirements + +The selected approach should provide: + +- visible dimension values at every rotation; +- selectable/searchable PDF text; +- embedded or otherwise deterministic fonts; +- explicit thin vector strokes in PDF points; +- lines, curves, polygons, circles, fills, clips, and nested transforms; +- browser-side generation and Blob/download support; +- compatibility with React and TypeScript in this monorepo; +- an API that can be tested without visual browser automation. + +## Comparison + +| Rank | Approach | Native/selectable text | Fonts | Transforms and thin vectors | Browser/TypeScript fit | Maintenance | Integration cost | +|---:|---|---|---|---|---|---|---| +| 1 | Direct jsPDF drawing, optionally as a hybrid overlay | Yes; `text()` emits PDF text and supports an angle or matrix | Custom TTF through VFS + `addFont` | Explicit `setLineWidth`; advanced mode exposes transformation matrices | Already installed and browser-first; official typings | Active; current 4.x docs and releases | Low for annotation overlay, medium for full renderer | +| 2 | Direct PDFKit renderer | Yes; native PDF text | TTF, OTF, WOFF, WOFF2, TTC, dfont; subsetting | Canvas-like vectors, SVG path data, save/restore, translate/rotate/scale/transform, explicit line width | Browser supported, but Blob stream/bundling and separate TS types add work | Active; current 0.19.x releases | Medium-high | +| 3 | Chromium print-to-PDF | Browser's own text/SVG renderer; generally preserves vector text | Uses loaded web fonts; Puppeteer waits for fonts by default | Browser-native SVG/CSS transforms and strokes | Not a pure browser-side library: needs print UI or a headless-browser service | Very active | Low rendering rewrite, high operational cost | +| 4 | `@react-pdf/renderer` | Native `` and SVG `` | `Font.register`; TTF and WOFF | SVG primitives, group transforms, explicit strokes; `Canvas` wraps PDFKit operations | Browser + server React APIs, Blob provider, bundled typings | Active releases and commits | High because DOM SVG is not reusable as-is | +| 5 | `pdf-lib` direct renderer | Native `drawText` with rotation | Standard fonts; custom fonts through `@pdf-lib/fontkit` | Lines, shapes, individual SVG path data, explicit thickness; lower-level transform work | Browser-compatible and TypeScript-native | Stable but inactive upstream since November 2021 | High | +| 6 | `SVG-to-PDFKit` automatic conversion | Supports SVG text/tspan/textPath | Requires pre-registration or a callback; does not wait for async browser font loading | Supports common transforms, but documents unsupported `vector-effect` | Browser possible through PDFKit; has a declaration file | Last commit August 2022; no published GitHub releases | Medium | +| 7 | Canvg raster fallback | No; text becomes pixels | Whatever the canvas resolved at rasterization time | Visually faithful at sufficient resolution, but all output is raster | Browser-friendly and TypeScript-based | Maintained; 4.0.3 released in 2025 | Low-medium | + +## Approach details + +### 1. Direct jsPDF primitives — recommended incremental implementation + +jsPDF already exposes all operations needed for dimension annotations: + +- `text(text, x, y, { angle, align, baseline })` for actual PDF text; +- transformation matrices in advanced mode; +- `setLineWidth(width)` in the document's declared units; +- custom font registration with `addFileToVFS`, `addFont`, and `setFont`. + +The official source documentation shows that `text()` writes a PDF text object (`BT`, font +selection, text position, `Tj`, `ET`) rather than rasterizing the label. It also documents angle and +matrix transforms. [jsPDF text and line-width documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html), +[font and advanced-mode guide](https://parallax.github.io/jsPDF/docs/index.html) + +Practical design: + +1. Keep the current `svg2pdf.js` pass temporarily for non-annotation geometry. +2. Exclude all `dimension`, `dimension-string`, and `dimension-label` geometry from that SVG pass. +3. Resolve them into a small `PdfDimensionAnnotation` display list containing witness lines, + dimension line, tick segments, label text, label anchor, angle, font size, and background box. +4. Transform model coordinates into page points once. +5. Draw the background plate, lines, ticks, then `doc.text()` with explicit fill color and embedded + font. +6. Later, move walls and other geometry to the same native backend if desired. + +Why this is ranked first: it eliminates the observed failure path while retaining the installed PDF +engine, page setup, headers, schedules, and save flow. It also provides a narrow regression-test +surface: generated PDF content can be inspected for each expected label string. + +### 2. Direct PDFKit — recommended full replacement library + +PDFKit runs in both Node and the browser. Its official documentation includes: + +- selectable text and embedded font support for TTF, OTF, WOFF, WOFF2, TTC, and dfont; +- vector `moveTo`, `lineTo`, Bézier and quadratic curves; +- parsing of SVG **path data** (not an entire SVG DOM); +- save/restore, translate, rotate, scale, and arbitrary transform operations; +- explicit stroke widths and Blob output in the browser. + +[PDFKit browser setup](https://pdfkit.org/docs/getting_started.html), +[vector and transform APIs](https://pdfkit.org/docs/vector.html), +[text and font APIs](https://pdfkit.org/docs/text.html) + +PDFKit 0.19 raised its documented browser floor to Firefox 115 and Safari/iOS 16, and its current +release line continues to include text, font, SVG-path, and browser fixes. +[Official PDFKit releases](https://github.com/foliojs/pdfkit/releases) + +Practical design: + +1. Add an exhaustive `renderFloorplanGeometryToPdfKit` visitor. +2. Give the visitor a coordinate transform from model metres to PDF points, including the page's + Y-axis inversion and plan rotation. +3. Draw every dimension label using `doc.text()` after `save/translate/rotate`. +4. Register an exact project font before rendering and use explicit point sizes. +5. Pipe to a browser Blob stream and keep the existing download UX. + +Tradeoffs: this is a cleaner long-term backend but a larger initial migration. PDFKit's npm package +does not currently advertise bundled declarations in its package manifest, so the TypeScript package +would normally also use `@types/pdfkit`. Browser output uses a Node-style stream, commonly adapted +with `blob-stream`. Both add integration weight compared with the existing jsPDF save flow. + +### 3. Chromium/browser printing + +Printing a dedicated page lets the browser render the same SVG, CSS, transforms, and fonts that it +renders on screen. `window.print()` is widely available, and print-specific CSS can control the +page. [MDN `window.print`](https://developer.mozilla.org/en-US/docs/Web/API/Window/print), +[MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing) + +For automatic downloads, Puppeteer's `Page.pdf()` uses Chromium print output. Its documented +options include CSS page-size preference, background graphics, and waiting for +`document.fonts.ready` (enabled by default). +[Puppeteer PDF guide](https://pptr.dev/guides/pdf-generation), +[Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) + +This is the best path when exact browser-rendering parity outweighs infrastructure cost. It is not +a pure client library: either the user must use the print dialog, or the application needs a trusted +server/desktop process running Chromium. That is a substantial architectural change for the current +browser-side download. + +### 4. `@react-pdf/renderer` + +React-pdf produces PDFs in the browser and server and offers browser Blob/download components. It +has its own PDF primitives, including SVG `Line`, `Path`, `Text`, `Tspan`, and `G`. Its documented +presentation attributes include `strokeWidth`, `transform`, `textAnchor`, and +`dominantBaseline`. Group transforms apply to children. Its `Canvas` painter wraps PDFKit methods, +including `text`, `path`, `rotate`, `lineWidth`, `translate`, and `scale`. +[React-pdf SVG APIs](https://react-pdf.org/svg), +[components and Canvas API](https://react-pdf.org/components), +[font registration](https://react-pdf.org/fonts) + +This is viable and actively maintained. It is not a drop-in renderer for the existing React DOM SVG: +the floor plan must be rebuilt with React-pdf's component types or drawn through its Canvas painter. +For a CAD-like plan, that gives no decisive rendering advantage over direct PDFKit while adding a +second React renderer and layout engine. It is more attractive if the broader drawing-sheet document +will be rebuilt declaratively. + +### 5. `pdf-lib` + +`pdf-lib` runs in browsers and is written in TypeScript. It provides native `drawText` with rotation, +explicit line thickness, rectangles/circles/ellipses, and individual SVG path drawing. Custom fonts +are embedded through `@pdf-lib/fontkit`. +[Official examples](https://pdf-lib.js.org/), +[`PDFPage` drawing API](https://pdf-lib.js.org/docs/api/classes/pdfpage), +[`DrawTextOptions`](https://pdf-lib.js.org/docs/api/interfaces/drawtextoptions) + +It does not parse a complete SVG document; its SVG support is for one path-data string at a time. +Consequently, it requires the same complete `FloorplanGeometry` visitor as PDFKit, with a less +convenient graphics-state/transform API for this use case. The upstream repository's latest commit +and release are from November 2021, so it is not the preferred new dependency for a renderer being +introduced in 2026. [Official commit history](https://github.com/Hopding/pdf-lib/commits/master/), +[official releases](https://github.com/Hopding/pdf-lib/releases) + +### 6. `SVG-to-PDFKit` + +`SVG-to-PDFKit` is the only credible alternate JavaScript full-SVG converter found. Its documented +coverage includes SVG text/tspan/textPath, transforms, paths, clips, masks, fonts, gradients, and +patterns. However, it explicitly does not support `vector-effect`, warns that browser fonts must be +registered before conversion because it does not wait for asynchronous loading, warns that bugs +remain, and has not received a commit since August 2022. +[Official repository and support table](https://github.com/alafr/SVG-to-PDFKit), +[official commit history](https://github.com/alafr/SVG-to-PDFKit/commits/master/) + +It is therefore not a sensible replacement for `svg2pdf.js`. It changes the converter without +removing the converter boundary. + +### 7. Canvg raster fallback + +Canvg parses SVG and renders it to Canvas; its stated purpose includes SVG rasterization. +[Official repository](https://github.com/canvg/canvg), +[official API](https://canvg.js.org/api) + +Rendering the plan at high device-pixel density and embedding the canvas as PNG would make missing +text unlikely after `document.fonts.ready`, because the browser/canvas has already converted the +glyphs to pixels. It is an acceptable emergency fallback or diagnostic control. It does not meet the +core deliverable: dimension text is not selectable, all geometry becomes raster, thin lines depend +on export resolution, and large plans produce larger PDFs. + +## Proposed implementation sequence + +If implementation is approved, use this order: + +1. Add a library-independent PDF display list or visitor over `FloorplanGeometry`. +2. Implement dimensions first: lines, ticks, background plates, and native text. +3. Embed one exact non-variable TTF font and wait for/load it explicitly. +4. Preserve all document sizes in PDF points; do not use CSS pixels for line weights. +5. Keep the existing SVG conversion only for unported geometry during the transition. +6. Add structural tests that inspect the generated PDF for expected text strings and page count. +7. Add fixture coverage for horizontal, vertical, diagonal, rotated-plan, short/outside-label, + metric, and imperial dimensions. +8. Only after the annotation path is proven, decide whether to port the remaining geometry and + remove `svg2pdf.js`. + +For a mandated new library, substitute a direct PDFKit backend at steps 2–5 and port geometry kinds +incrementally. Do not introduce `SVG-to-PDFKit` as an intermediate layer. + +## Acceptance criteria for the eventual implementation + +- Every dimension value in the source geometry is present as extractable text in the generated PDF. +- Horizontal, vertical, and diagonal values remain readable at plan rotations of 0°, 45°, 90°, and + arbitrary building rotations. +- Dimension lines render at an explicit target such as 0.5 pt and ticks at 0.75 pt regardless of + plan scale. +- The chosen font is embedded or a deliberate standard PDF font is used. +- Label backing plates are drawn before text and do not obscure glyphs. +- Text extraction verifies representative metric and imperial labels. +- Geometry remains vector except for explicitly documented raster-only assets. + +## Primary sources + +- [jsPDF repository](https://github.com/parallax/jsPDF) +- [jsPDF documentation](https://parallax.github.io/jsPDF/docs/index.html) +- [jsPDF source/API documentation](https://parallax.github.io/jsPDF/docs/jspdf.js.html) +- [`svg2pdf.js` repository](https://github.com/yWorks/svg2pdf.js) +- [`svg2pdf.js` releases](https://github.com/yWorks/svg2pdf.js/releases) +- [PDFKit repository](https://github.com/foliojs/pdfkit) +- [PDFKit browser setup](https://pdfkit.org/docs/getting_started.html) +- [PDFKit vector graphics](https://pdfkit.org/docs/vector.html) +- [PDFKit text and fonts](https://pdfkit.org/docs/text.html) +- [PDFKit releases](https://github.com/foliojs/pdfkit/releases) +- [React-pdf components](https://react-pdf.org/components) +- [React-pdf SVG primitives](https://react-pdf.org/svg) +- [React-pdf fonts](https://react-pdf.org/fonts) +- [React-pdf releases](https://github.com/diegomura/react-pdf/releases) +- [`pdf-lib` documentation](https://pdf-lib.js.org/) +- [`pdf-lib` `PDFPage` API](https://pdf-lib.js.org/docs/api/classes/pdfpage) +- [`pdf-lib` repository](https://github.com/Hopding/pdf-lib) +- [`SVG-to-PDFKit` repository](https://github.com/alafr/SVG-to-PDFKit) +- [Canvg repository](https://github.com/canvg/canvg) +- [Puppeteer PDF generation](https://pptr.dev/guides/pdf-generation) +- [Puppeteer PDF options](https://pptr.dev/api/puppeteer.pdfoptions) +- [MDN printing guide](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Media_queries/Printing)