From ab76686b8ba77a7e97fc7e238f5252017bb98826 Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Tue, 28 Jul 2026 04:27:59 +0530 Subject: [PATCH] editor: improve floorplan modes and annotations (#549) * Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 * fixed conflict * fix(editor): anchor floorplan cursor to snapped point * fix(nodes): preview floorplan edits through live overrides * feat(editor): add context-aware floorplan modes * fix(nodes): render crisp wall selection hatching * fix(editor): cap floorplan handles at extreme zoom * fix(editor): keep zone labels upright after rotation * refactor(editor): make referenced annotations registry-driven * refactor(nodes): colocate contextual dimension builders * fix(editor): use mode-driven angle snapping * fix(nodes): use mode-driven move snapping * chore(editor): update react scan tooling * refactor(floorplan): streamline construction documentation * refactor(floorplan): remove wall assembly roadmap * fix(floorplan): migrate retired scene data --------- Co-authored-by: Claude Opus 4.6 --- apps/editor/app/page.tsx | 2 - apps/editor/components/build-tab.tsx | 19 +- .../floorplan-construction-preflight.tsx | 70 -- apps/editor/components/viewer-toolbar.tsx | 172 ++-- apps/editor/package.json | 4 +- bun.lock | 19 +- package.json | 1 + packages/core/src/events/bus.ts | 3 - packages/core/src/index.ts | 1 - packages/core/src/registry/subtree.test.ts | 32 - packages/core/src/registry/subtree.ts | 5 - packages/core/src/schema/index.ts | 31 - packages/core/src/schema/nodes/building.ts | 7 +- .../src/schema/nodes/drawing-sheet.test.ts | 222 ----- .../core/src/schema/nodes/drawing-sheet.ts | 263 ------ packages/core/src/schema/nodes/wall.test.ts | 210 ----- packages/core/src/schema/nodes/wall.ts | 260 ------ packages/core/src/schema/types.ts | 2 - ...-scene-retired-floorplan-migration.test.ts | 106 +++ packages/core/src/store/use-scene.ts | 42 +- .../src/systems/wall/wall-mitering.test.ts | 1 - .../core/src/utils/clone-scene-graph.test.ts | 41 - packages/core/src/utils/clone-scene-graph.ts | 9 - .../floorplan-cursor-indicator-overlay.tsx | 47 +- ...loorplan-cursor-indicator-position.test.ts | 31 + .../floorplan-cursor-indicator-position.ts | 39 + .../editor-2d/floorplan-group-move.tsx | 49 +- .../floorplan-registered-tool-layer.tsx | 17 +- .../floorplan-annotation-layout.test.ts | 63 +- .../renderers/floorplan-annotation-layout.ts | 93 +- .../floorplan-registry-layer.test.ts | 115 +++ .../renderers/floorplan-registry-layer.tsx | 235 ++--- .../floorplan-background-selection.test.ts | 18 +- .../editor/floorplan-background-selection.ts | 22 +- .../editor/floorplan-mode-coordinator.tsx | 82 ++ .../src/components/editor/floorplan-panel.tsx | 668 +-------------- .../editor/src/components/editor/index.tsx | 6 + .../editor/use-floorplan-hit-testing.test.ts | 82 ++ .../editor/use-floorplan-hit-testing.ts | 315 ++++--- .../ui/action-menu/measurement-control.tsx | 72 +- .../sidebar/panels/settings-panel/index.tsx | 7 +- packages/editor/src/index.tsx | 13 +- .../floorplan/annotation-visibility.test.ts | 80 +- .../lib/floorplan/annotation-visibility.ts | 41 +- .../lib/floorplan/floorplan-export.test.ts | 366 +------- .../src/lib/floorplan/floorplan-export.tsx | 805 +----------------- .../lib/floorplan/floorplan-extension.test.ts | 6 + .../src/lib/floorplan/floorplan-extension.ts | 16 +- .../src/lib/floorplan/floorplan-mode.test.ts | 120 +++ .../src/lib/floorplan/floorplan-mode.ts | 73 ++ .../src/lib/floorplan/selection-tool.ts | 271 ------ .../editor/src/store/use-drawing-view.test.ts | 18 +- packages/editor/src/store/use-drawing-view.ts | 50 +- .../editor/src/store/use-floorplan-mode.ts | 110 +++ .../src/store/use-floorplan-preflight.test.ts | 49 -- .../src/store/use-floorplan-preflight.ts | 79 -- .../nodes/src/building/definition.test.ts | 6 +- packages/nodes/src/building/definition.ts | 2 +- .../nodes/src/column/floorplan-affordances.ts | 32 +- .../src/construction-dimension/definition.ts | 1 + .../floorplan-tool.test.ts | 63 +- .../construction-dimension/floorplan-tool.tsx | 82 +- .../construction-dimension/floorplan.test.ts | 156 ++-- .../src/construction-dimension/floorplan.ts | 125 ++- .../src/construction-dimension/geometry.ts | 65 +- .../nodes/src/door/contextual-dimensions.ts | 12 + packages/nodes/src/door/definition.ts | 2 + .../nodes/src/door/floorplan-affordances.ts | 26 +- packages/nodes/src/door/floorplan.test.ts | 68 ++ packages/nodes/src/door/floorplan.ts | 77 +- .../src/drawing-sheet/definition.test.ts | 52 -- .../nodes/src/drawing-sheet/definition.ts | 51 -- packages/nodes/src/drawing-sheet/index.ts | 1 - packages/nodes/src/drawing-sheet/schema.ts | 18 - .../src/elevator/floorplan-affordances.ts | 24 +- .../nodes/src/fence/floorplan-affordances.ts | 73 +- packages/nodes/src/fence/tool.tsx | 1 - packages/nodes/src/index.ts | 32 - packages/nodes/src/item/definition.ts | 8 +- packages/nodes/src/item/floorplan-move.ts | 89 +- packages/nodes/src/item/floorplan.ts | 53 ++ .../nodes/src/measurement/definition.test.ts | 4 + packages/nodes/src/measurement/definition.ts | 6 + .../src/roof-segment/floorplan-affordances.ts | 21 +- .../src/shared/clearance-advisories.test.ts | 209 ----- .../nodes/src/shared/clearance-advisories.ts | 513 ----------- .../construction-module-advisories.test.ts | 181 ---- .../shared/construction-module-advisories.ts | 326 ------- .../contextual-floorplan-dimensions.test.ts | 311 +++++++ .../dimension-completeness-audit.test.ts | 340 -------- .../shared/dimension-completeness-audit.ts | 495 ----------- .../shared/opening-width-affordances.test.ts | 144 ++++ .../nodes/src/shelf/floorplan-affordances.ts | 14 +- packages/nodes/src/shelf/floorplan-move.ts | 58 +- .../nodes/src/spawn/floorplan-affordances.ts | 10 +- packages/nodes/src/spawn/floorplan-move.ts | 13 +- .../nodes/src/stair/floorplan-affordances.ts | 36 +- packages/nodes/src/stair/floorplan-move.ts | 34 +- .../nodes/src/structural-grid/definition.ts | 1 + ...ruction-dimension-reference-policy.test.ts | 83 +- .../src/wall/construction-dimensions.test.ts | 36 +- .../nodes/src/wall/construction-dimensions.ts | 13 +- .../nodes/src/wall/contextual-dimensions.ts | 292 +++++++ packages/nodes/src/wall/definition.test.ts | 4 + packages/nodes/src/wall/definition.ts | 5 +- packages/nodes/src/wall/floorplan.test.ts | 203 ++--- packages/nodes/src/wall/floorplan.ts | 608 +++---------- packages/nodes/src/wall/panel.tsx | 171 +--- packages/nodes/src/wall/schema.ts | 19 +- packages/nodes/src/wall/tool.tsx | 1 - .../nodes/src/window/contextual-dimensions.ts | 12 + packages/nodes/src/window/definition.ts | 2 + .../nodes/src/window/floorplan-affordances.ts | 23 +- packages/nodes/src/window/floorplan.ts | 4 +- .../nodes/src/zone/contextual-dimensions.ts | 59 ++ packages/nodes/src/zone/definition.ts | 2 + packages/nodes/src/zone/floorplan.ts | 4 +- .../src/zone/room-clear-dimensions.test.ts | 47 +- .../nodes/src/zone/room-clear-dimensions.ts | 44 +- wiki/floorplan-chapter-17-assessment.md | 59 +- wiki/floorplan-pdf-export-library-research.md | 269 ------ 121 files changed, 3535 insertions(+), 7840 deletions(-) delete mode 100644 apps/editor/components/floorplan-construction-preflight.tsx delete mode 100644 packages/core/src/schema/nodes/drawing-sheet.test.ts delete mode 100644 packages/core/src/schema/nodes/drawing-sheet.ts create mode 100644 packages/core/src/store/use-scene-retired-floorplan-migration.test.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts create mode 100644 packages/editor/src/components/editor-2d/floorplan-cursor-indicator-position.ts create mode 100644 packages/editor/src/components/editor/floorplan-mode-coordinator.tsx create mode 100644 packages/editor/src/components/editor/use-floorplan-hit-testing.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-mode.test.ts create mode 100644 packages/editor/src/lib/floorplan/floorplan-mode.ts delete mode 100644 packages/editor/src/lib/floorplan/selection-tool.ts create mode 100644 packages/editor/src/store/use-floorplan-mode.ts delete mode 100644 packages/editor/src/store/use-floorplan-preflight.test.ts delete mode 100644 packages/editor/src/store/use-floorplan-preflight.ts create mode 100644 packages/nodes/src/door/contextual-dimensions.ts create mode 100644 packages/nodes/src/door/floorplan.test.ts delete mode 100644 packages/nodes/src/drawing-sheet/definition.test.ts delete mode 100644 packages/nodes/src/drawing-sheet/definition.ts delete mode 100644 packages/nodes/src/drawing-sheet/index.ts delete mode 100644 packages/nodes/src/drawing-sheet/schema.ts delete mode 100644 packages/nodes/src/shared/clearance-advisories.test.ts delete mode 100644 packages/nodes/src/shared/clearance-advisories.ts delete mode 100644 packages/nodes/src/shared/construction-module-advisories.test.ts delete mode 100644 packages/nodes/src/shared/construction-module-advisories.ts create mode 100644 packages/nodes/src/shared/contextual-floorplan-dimensions.test.ts delete mode 100644 packages/nodes/src/shared/dimension-completeness-audit.test.ts delete mode 100644 packages/nodes/src/shared/dimension-completeness-audit.ts create mode 100644 packages/nodes/src/shared/opening-width-affordances.test.ts create mode 100644 packages/nodes/src/wall/contextual-dimensions.ts create mode 100644 packages/nodes/src/window/contextual-dimensions.ts create mode 100644 packages/nodes/src/zone/contextual-dimensions.ts delete mode 100644 wiki/floorplan-pdf-export-library-research.md diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 7119d312..e4ccf7ed 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -5,7 +5,6 @@ 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, @@ -90,7 +89,6 @@ 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 1ec86d04..c43136aa 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -2,10 +2,13 @@ import { nodeRegistry } from '@pascal-app/core' import { + type FloorplanMode, getFloorplanNodeExtension, + isFloorplanToolAvailableInMode, MaterialPaintPanel, triggerSFX, useEditor, + useFloorplanMode, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' @@ -73,7 +76,7 @@ const BASE_BUILD_TYPES: BuildType[] = [ { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, ] -function collectBuildTypes(): BuildType[] { +function collectBuildTypes(floorplanMode: FloorplanMode): 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, @@ -86,6 +89,7 @@ function collectBuildTypes(): BuildType[] { if ( baseKinds.has(kind) || !extension?.tool || + !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || !presentation || presentation.hidden || presentation.paletteSection !== 'structure' @@ -126,7 +130,15 @@ const MEP_ITEMS: MepItem[] = [ */ function activateBuildTool(kind: string): void { const ed = useEditor.getState() - const preferredView = getFloorplanNodeExtension(nodeRegistry.get(kind))?.preferredView + const definition = nodeRegistry.get(kind) + const extension = getFloorplanNodeExtension(definition) + if ( + !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) + ) { + useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) + return + } + const preferredView = extension?.preferredView if (preferredView) ed.setViewMode(preferredView) ed.setPhase('structure') ed.setStructureLayer('elements') @@ -184,9 +196,10 @@ const MEP_TOOL_KINDS = new Set([ export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) + const floorplanMode = useFloorplanMode((s) => s.mode) const follow = useLiquidLineToolOptions((s) => s.follow) const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) - const buildTypes = useMemo(collectBuildTypes, []) + const buildTypes = useMemo(() => collectBuildTypes(floorplanMode), [floorplanMode]) // 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 diff --git a/apps/editor/components/floorplan-construction-preflight.tsx b/apps/editor/components/floorplan-construction-preflight.tsx deleted file mode 100644 index 19d30b85..00000000 --- a/apps/editor/components/floorplan-construction-preflight.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'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 77809e10..a707288e 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -2,7 +2,6 @@ import { Icon as IconifyIcon } from '@iconify/react' import { - DRAWING_TYPE_OPTIONS, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -11,9 +10,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, - useDrawingView, useEditor, useFloorplanAnnotationVisibility, + useFloorplanMode, useSidebarStore, type ViewMode, } from '@pascal-app/editor' @@ -150,6 +149,19 @@ const FLOORPLAN_ANNOTATION_OPTIONS = [ { id: 'stairAnnotations', name: 'Stair annotations', icon: Footprints }, ] as const +const FLOORPLAN_MODE_OPTIONS = [ + { + id: 'default', + name: 'Default', + detail: 'Clean plan; dimensions appear with selection', + }, + { + id: 'expert', + name: 'Expert', + detail: 'Full documentation and annotation controls', + }, +] 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' }, @@ -188,49 +200,6 @@ 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) @@ -373,6 +342,8 @@ function DisplayMenu() { const setWallDimensionReference = useFloorplanAnnotationVisibility( (state) => state.setWallDimensionReference, ) + const floorplanMode = useFloorplanMode((state) => state.mode) + const setFloorplanMode = useFloorplanMode((state) => state.setMode) const activeShading = SHADING_OPTIONS.find((option) => option.id === shading) ?? SHADING_OPTIONS[0] @@ -432,62 +403,88 @@ function DisplayMenu() { - 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 + Floor plan mode - { - FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.find( - (option) => option.id === wallDimensionReference, - )?.name - } + {floorplanMode === 'default' ? 'Default' : 'Expert'} - {FLOORPLAN_WALL_DIMENSION_REFERENCE_OPTIONS.map((option) => ( - - keepOpen(event, () => setWallDimensionReference(option.id)) - } - > + {FLOORPLAN_MODE_OPTIONS.map((option) => ( + setFloorplanMode(option.id)}>
{option.name} {option.detail}
- {wallDimensionReference === option.id ? ( + {floorplanMode === option.id ? ( ) : null}
))}
+ {floorplanMode === 'expert' ? ( + <> + + + + 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} ) : null} keepOpen(e, () => setMagneticSnap(!magneticSnap))}> @@ -694,7 +691,6 @@ export function CommunityViewerToolbarLeft() { <> - ) } diff --git a/apps/editor/package.json b/apps/editor/package.json index 862df650..e9acf4f6 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -42,8 +42,8 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", - "react-grab": "^0.1.29", - "react-scan": "^0.5.3", + "react-grab": "^0.1.50", + "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", "typescript": "6.0.3" } diff --git a/bun.lock b/bun.lock index 5cbd6c7a..d8ac1e9f 100644 --- a/bun.lock +++ b/bun.lock @@ -58,8 +58,8 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "agentation": "^3.0.2", - "react-grab": "^0.1.29", - "react-scan": "^0.5.3", + "react-grab": "^0.1.50", + "react-scan": "^0.5.7", "tw-animate-css": "^1.4.0", "typescript": "6.0.3", }, @@ -315,6 +315,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/three": "0.184.1", + "react-grab": "0.1.50", "three": "0.185.1", }, "packages": { @@ -784,7 +785,7 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], - "@react-grab/cli": ["@react-grab/cli@0.1.44", "", { "dependencies": { "agent-install": "^0.0.5", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A=="], + "@react-grab/cli": ["@react-grab/cli@0.1.50", "", { "dependencies": { "agent-install": "^0.0.6", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-Px/Hwhhyk2PubCA4ZaRFsfvwxhbxXsetJyvqC6aFFi8WhJhA+oVC33aTzuAeWmM3fhb4/8ce8YsHXI1d6ChcKg=="], "@react-three/drei": ["@react-three/drei@10.7.7", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", "hls.js": "^1.5.17", "maath": "^0.10.8", "meshline": "^3.3.1", "stats-gl": "^2.2.8", "stats.js": "^0.17.0", "suspend-react": "^0.1.3", "three-mesh-bvh": "^0.8.3", "three-stdlib": "^2.35.6", "troika-three-text": "^0.52.4", "tunnel-rat": "^0.1.2", "use-sync-external-store": "^1.4.0", "utility-types": "^3.11.0", "zustand": "^5.0.1" }, "peerDependencies": { "@react-three/fiber": "^9.0.0", "react": "^19", "react-dom": "^19", "three": ">=0.159" }, "optionalPeers": ["react-dom"] }, "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ=="], @@ -944,7 +945,7 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "agent-install": ["agent-install@0.0.6", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA=="], "agentation": ["agentation@3.0.2", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-iGzBxFVTuZEIKzLY6AExSLAQH6i6SwxV4pAu7v7m3X6bInZ7qlZXAwrEqyc4+EfP4gM7z2RXBF6SF4DeH0f2lA=="], @@ -988,7 +989,7 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "bippy": ["bippy@0.6.1", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ=="], "blob": ["blob@0.0.4", "", {}, "sha512-YRc9zvVz4wNaxcXmiSgb9LAg7YYwqQ2xd0Sj6osfA7k/PKmIGVlnOYs3wOFdkRC9/JpQu8sGt/zHgJV7xzerfg=="], @@ -1644,7 +1645,7 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-grab": ["react-grab@0.1.44", "", { "dependencies": { "@react-grab/cli": "0.1.44", "bippy": "^0.5.41" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A=="], + "react-grab": ["react-grab@0.1.50", "", { "dependencies": { "@react-grab/cli": "0.1.50", "bippy": "^0.6.1" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-zRkHKq/8a1msCpEOp8BDROeQZT50m0OH2XPrP6jk5op+JAHrlsm3pj7eAQMOsct87EZDeGNnu4r+sGsJJzyw1Q=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -1984,8 +1985,12 @@ "postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + "react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + "react-scan/bippy": ["bippy@0.5.41", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg=="], + "react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -2012,6 +2017,8 @@ "next/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "react-doctor/agent-install/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "deslop-js/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/package.json b/package.json index 10012ff4..083e1e25 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/three": "0.184.1", + "react-grab": "0.1.50", "three": "0.185.1" }, "optionalDependencies": { diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5684e378..55fdcfd9 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -14,7 +14,6 @@ import type { DoorNode, DormerNode, DownspoutNode, - DrawingSheetNode, DuctFittingNode, DuctSegmentNode, DuctTerminalNode, @@ -126,7 +125,6 @@ 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 @@ -327,7 +325,6 @@ type EditorEvents = GridEvents & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & NodeEvents<'downspout', DownspoutEvent> & - NodeEvents<'drawing-sheet', DrawingSheetEvent> & NodeEvents<'duct-segment', DuctSegmentEvent> & NodeEvents<'duct-fitting', DuctFittingEvent> & NodeEvents<'duct-terminal', DuctTerminalEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a477a88f..3b633eba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,6 @@ export type { ConstructionDimensionEvent, DoorEvent, DormerEvent, - DrawingSheetEvent, ElevatorEvent, EventSuffix, FenceEvent, diff --git a/packages/core/src/registry/subtree.test.ts b/packages/core/src/registry/subtree.test.ts index 4f14671f..09c5dced 100644 --- a/packages/core/src/registry/subtree.test.ts +++ b/packages/core/src/registry/subtree.test.ts @@ -226,38 +226,6 @@ describe('cloneNodesInto', () => { } }) - 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 f3e3f10d..e41cd114 100644 --- a/packages/core/src/registry/subtree.ts +++ b/packages/core/src/registry/subtree.ts @@ -3,7 +3,6 @@ import { 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 @@ -176,10 +175,6 @@ export function cloneNodesInto( 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) { ;(cloned as { position: [number, number, number] }).position = [ diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index fd0e8f7f..7374b7de 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -85,25 +85,6 @@ 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' @@ -248,9 +229,6 @@ 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, @@ -263,18 +241,11 @@ 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, @@ -285,8 +256,6 @@ export { WALL_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WALL_TRIM_DEFAULTS, - WallAssemblyLayerRole, - WallDimensionDatum, WallNode, WallTreatmentSide, WallTrimProfile, diff --git a/packages/core/src/schema/nodes/building.ts b/packages/core/src/schema/nodes/building.ts index c1acb785..e4a4e470 100644 --- a/packages/core/src/schema/nodes/building.ts +++ b/packages/core/src/schema/nodes/building.ts @@ -1,16 +1,13 @@ 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, DrawingSheetNode.shape.id])) - .default([]), + children: z.array(z.union([LevelNode.shape.id, ElevatorNode.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( @@ -18,7 +15,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, building-level systems such as elevators, and drawing sheets + - children: array of level nodes and building-level systems such as elevators `, ) diff --git a/packages/core/src/schema/nodes/drawing-sheet.test.ts b/packages/core/src/schema/nodes/drawing-sheet.test.ts deleted file mode 100644 index 216295e1..00000000 --- a/packages/core/src/schema/nodes/drawing-sheet.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -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 deleted file mode 100644 index 372addf0..00000000 --- a/packages/core/src/schema/nodes/drawing-sheet.ts +++ /dev/null @@ -1,263 +0,0 @@ -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/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 164a5c37..4306d093 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -2,13 +2,7 @@ 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, @@ -19,7 +13,6 @@ import { WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, - WallNode, type WallNode as WallNodeType, WallTrimConfig, } from './wall' @@ -267,206 +260,3 @@ 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 82a81429..a01a98ad 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -127,48 +127,6 @@ 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'), @@ -191,7 +149,6 @@ 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. @@ -210,7 +167,6 @@ 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 @@ -234,222 +190,6 @@ export type WallBandSurfaceSlotId = | 'upperExterior' | 'topExterior' -export function getWallAssemblyLayers(wall: Pick): WallAssemblyLayer[] { - return wall.assemblyLayers ?? [] -} - -export function getWallAssemblyThickness( - wall: Pick, -): number { - const layers = wall.assemblyLayers ?? [] - if (layers.length === 0) return wall.thickness ?? 0.1 - return layers.reduce((sum, layer) => sum + layer.thickness, 0) -} - -export function getWallAssemblyFaceOffsets(wall: Pick): { - interior: number - exterior: number -} { - const layers = wall.assemblyLayers ?? [] - if (layers.length === 0) { - const halfThickness = (wall.thickness ?? 0.1) / 2 - return { interior: -halfThickness, exterior: halfThickness } - } - - const coreLayers = layers.filter((layer) => layer.side === 'core') - const coreThickness = - coreLayers.length > 0 - ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) - : (wall.thickness ?? 0.1) - const interiorFinishThickness = layers - .filter((layer) => layer.side === 'interior') - .reduce((sum, layer) => sum + layer.thickness, 0) - const exteriorFinishThickness = layers - .filter((layer) => layer.side === 'exterior') - .reduce((sum, layer) => sum + layer.thickness, 0) - - return { - interior: -coreThickness / 2 - interiorFinishThickness, - exterior: coreThickness / 2 + exteriorFinishThickness, - } -} - -export function getWallDatumEligibleLayers( - wall: Pick, - datum: WallDimensionDatum, -): WallAssemblyLayer[] { - return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum)) -} - -export function getWallAssemblyDatumReferenceId( - datum: WallDimensionDatum, - side: WallAssemblyDatumSide, - layerId?: string, -): string { - return ['wall', datum, side, layerId].filter(Boolean).join(':') -} - -type WallAssemblyLayerSpan = { - layer: WallAssemblyLayer - interiorOffset: number - exteriorOffset: number -} - -function getWallAssemblyLayerSpans( - wall: Pick, -): WallAssemblyLayerSpan[] { - const layers = wall.assemblyLayers ?? [] - if (layers.length === 0) return [] - - const coreLayers = layers.filter((layer) => layer.side === 'core') - const coreThickness = - coreLayers.length > 0 - ? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0) - : (wall.thickness ?? 0.1) - const coreInteriorFace = -coreThickness / 2 - const coreExteriorFace = coreThickness / 2 - const spans: WallAssemblyLayerSpan[] = [] - - let coreOffset = coreInteriorFace - for (const layer of coreLayers) { - const interiorOffset = coreOffset - const exteriorOffset = coreOffset + layer.thickness - spans.push({ layer, interiorOffset, exteriorOffset }) - coreOffset = exteriorOffset - } - - let interiorOffset = coreInteriorFace - for (const layer of layers.filter((candidate) => candidate.side === 'interior')) { - const exteriorOffset = interiorOffset - const nextInteriorOffset = exteriorOffset - layer.thickness - spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset }) - interiorOffset = nextInteriorOffset - } - - let exteriorOffset = coreExteriorFace - for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) { - const interiorFaceOffset = exteriorOffset - const nextExteriorOffset = interiorFaceOffset + layer.thickness - spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset }) - exteriorOffset = nextExteriorOffset - } - - return spans -} - -function createWallAssemblyDatumReference( - datum: WallDimensionDatum, - side: WallAssemblyDatumSide, - offset: number, - layerId?: string, -): WallAssemblyDatumReference { - return { - id: getWallAssemblyDatumReferenceId(datum, side, layerId), - datum, - side, - ...(layerId ? { layerId } : {}), - offset, - } -} - -export function resolveWallAssemblyDatumReferences( - wall: Pick, -): WallAssemblyDatumReference[] { - const layers = wall.assemblyLayers ?? [] - const references: WallAssemblyDatumReference[] = [ - createWallAssemblyDatumReference('centerline', 'center', 0), - ] - - if (layers.length === 0) { - const halfThickness = (wall.thickness ?? 0.1) / 2 - return [ - ...references, - createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), - createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), - createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), - createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), - ] - } - - const spans = getWallAssemblyLayerSpans(wall) - - for (const span of spans) { - if (span.layer.datumEligible.includes('structural-face')) { - if (span.layer.side === 'core') { - references.push( - createWallAssemblyDatumReference( - 'structural-face', - 'interior', - span.interiorOffset, - span.layer.id, - ), - createWallAssemblyDatumReference( - 'structural-face', - 'exterior', - span.exteriorOffset, - span.layer.id, - ), - ) - } else { - const side = span.layer.side - references.push( - createWallAssemblyDatumReference( - 'structural-face', - side, - side === 'interior' ? span.interiorOffset : span.exteriorOffset, - span.layer.id, - ), - ) - } - } - - if (span.layer.datumEligible.includes('finish-face')) { - const side = span.layer.side === 'core' ? 'center' : span.layer.side - const offset = - span.layer.side === 'interior' - ? span.interiorOffset - : span.layer.side === 'exterior' - ? span.exteriorOffset - : (span.interiorOffset + span.exteriorOffset) / 2 - references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id)) - } - - if (span.layer.datumEligible.includes('veneer-face')) { - const side = span.layer.side === 'interior' ? 'interior' : 'exterior' - const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset - references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id)) - } - } - - if (!references.some((reference) => reference.datum === 'structural-face')) { - const halfThickness = getWallAssemblyThickness(wall) / 2 - references.push( - createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness), - createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness), - ) - } - - if (!references.some((reference) => reference.datum === 'finish-face')) { - const halfThickness = getWallAssemblyThickness(wall) / 2 - references.push( - createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness), - createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness), - ) - } - - return references -} - -export function resolveWallAssemblyDatumReference( - wall: Pick, - referenceId: string, -): WallAssemblyDatumReference | null { - return ( - resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ?? - null - ) -} - // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the // slot declaration (nodes) and the material resolver (viewer) share one value. diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 2c980bfc..70575bcf 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -10,7 +10,6 @@ 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' @@ -84,7 +83,6 @@ export const AnyNode = z.discriminatedUnion('type', [ SkylightNode, DormerNode, DownspoutNode, - DrawingSheetNode, DuctSegmentNode, DuctFittingNode, DuctTerminalNode, diff --git a/packages/core/src/store/use-scene-retired-floorplan-migration.test.ts b/packages/core/src/store/use-scene-retired-floorplan-migration.test.ts new file mode 100644 index 00000000..40fe4bc5 --- /dev/null +++ b/packages/core/src/store/use-scene-retired-floorplan-migration.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { type AnyNode, AnyNode as AnyNodeSchema } from '../schema' +import useScene from './use-scene' + +function resetScene() { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + } as never) + useScene.temporal.getState().clear() +} + +function baseScene(levelChildren: string[]): Record { + return { + 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'], + position: [0, 0, 0], + rotation: [0, 0, 0], + }, + level_test: { + object: 'node', + id: 'level_test', + type: 'level', + parentId: 'building_test', + visible: true, + metadata: {}, + children: levelChildren, + level: 0, + height: 2.5, + }, + } as unknown as Record +} + +describe('retired floor-plan data migration', () => { + beforeEach(resetScene) + + test('removes drawing-sheet nodes and their parent references', () => { + const nodes = baseScene([]) + ;(nodes.building_test as { children: string[] }).children.push('drawing-sheet_a101') + ;(nodes as Record)['drawing-sheet_a101'] = { + object: 'node', + id: 'drawing-sheet_a101', + type: 'drawing-sheet', + parentId: 'building_test', + visible: true, + metadata: {}, + sheetNumber: 'A1.01', + sheetTitle: 'Floor Plan', + } + + useScene.getState().setScene(nodes, ['site_test'] as never) + + const migrated = useScene.getState().nodes + expect(migrated['drawing-sheet_a101' as keyof typeof migrated]).toBeUndefined() + expect((migrated.building_test as { children: string[] }).children).toEqual(['level_test']) + expect(Object.values(migrated).every((node) => AnyNodeSchema.safeParse(node).success)).toBe( + true, + ) + }) + + test('converts wall assembly width to plain thickness and removes the legacy field', () => { + const nodes = baseScene(['wall_test']) + ;(nodes as Record).wall_test = { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: 'level_test', + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + thickness: 0.1, + assemblyLayers: [ + { id: 'finish', role: 'interior-finish', side: 'interior', thickness: 0.0125 }, + { id: 'stud', role: 'structure', side: 'core', thickness: 0.1 }, + { id: 'sheathing', role: 'exterior-sheathing', side: 'exterior', thickness: 0.02 }, + ], + } + + useScene.getState().setScene(nodes, ['site_test'] as never) + + const wall = useScene.getState().nodes.wall_test as AnyNode & Record + expect(wall.thickness).toBeCloseTo(0.1325) + expect(Object.hasOwn(wall, 'assemblyLayers')).toBe(false) + expect(AnyNodeSchema.safeParse(wall).success).toBe(true) + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a27991e4..60f9a232 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -591,6 +591,42 @@ function migrateConstructionDimension(node: Record) { } } +function removeRetiredDrawingSheets(nodes: Record) { + const retiredIds = new Set( + Object.entries(nodes) + .filter(([, node]) => node?.type === 'drawing-sheet') + .map(([id]) => id), + ) + if (retiredIds.size === 0) return + + for (const id of retiredIds) delete nodes[id] + for (const [id, node] of Object.entries(nodes)) { + if (!Array.isArray(node?.children)) continue + const children = getStringArray(node.children) + if (!children.some((childId) => retiredIds.has(childId))) continue + nodes[id] = { + ...node, + children: children.filter((childId) => !retiredIds.has(childId)), + } + } +} + +function migrateWallAssembly(node: Record) { + if (!Object.hasOwn(node, 'assemblyLayers')) return node + + const assemblyThickness = Array.isArray(node.assemblyLayers) + ? node.assemblyLayers.reduce((total: number, layer: unknown) => { + if (!(layer && typeof layer === 'object')) return total + const thickness = (layer as { thickness?: unknown }).thickness + return typeof thickness === 'number' && Number.isFinite(thickness) && thickness > 0 + ? total + thickness + : total + }, 0) + : 0 + const { assemblyLayers: _assemblyLayers, ...wall } = node + return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall +} + // 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. @@ -608,6 +644,7 @@ function migrateNodes(nodes: Record): { // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) const patchedNodes = { ...healed } as Record + removeRetiredDrawingSheets(patchedNodes) // Scene materials minted while moving legacy wall fields onto `node.slots`; // merged into the scene material map by the caller (`setScene`). @@ -728,7 +765,10 @@ function migrateNodes(nodes: Record): { } if (node.type === 'wall') { - patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) + patchedNodes[id] = migrateWallSurfaceMaterials( + migrateWallAssembly(patchedNodes[id]), + mintedMaterials, + ) } // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index d73bd8d4..637b1840 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -10,7 +10,6 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN visible: true, parentId: 'level_test', children: [], - assemblyLayers: [], start, end, thickness: 0.1, diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index 02aea9c5..a189af43 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -149,47 +149,6 @@ describe('construction-dimension clone references', () => { }) }) -describe('drawing-sheet clone references', () => { - test('remaps placed levels and nested sheet identities in whole-scene clones', () => { - const level = makeNode('level_main', 'level') - const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', { - placedViews: [{ id: 'drawing-view_main', levelId: level.id }], - generalNoteSetIds: [], - generalNoteSets: [], - generalNotes: [], - keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }], - keyedNoteInstances: [ - { - id: 'keyed-note-instance_a', - definitionId: 'keyed-note_a', - placedViewId: 'drawing-view_main', - position: [1, 1], - }, - ], - keyedNoteLegend: [], - documentMarkers: [], - schedules: [], - }) - const cloned = cloneSceneGraph({ - nodes: { [level.id]: level, [sheet.id]: sheet }, - rootNodeIds: [level.id, sheet.id] as AnyNodeId[], - }) - const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level') - const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet') - - expect(clonedLevel).toBeDefined() - expect(clonedSheet?.type).toBe('drawing-sheet') - if (clonedLevel && clonedSheet?.type === 'drawing-sheet') { - expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id) - expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main') - expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe( - clonedSheet.keyedNoteDefinitions[0]?.id, - ) - expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id) - } - }) -}) - describe('supportSlabId remap', () => { test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => { const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] }) diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 5c6ec8bc..e48f13e8 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -6,7 +6,6 @@ import { 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 @@ -114,10 +113,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { if (clonedNode.type === 'construction-dimension') { clonedNode = remapConstructionDimensionReferences(clonedNode, idMap) } - if (clonedNode.type === 'drawing-sheet') { - clonedNode = remapDrawingSheetReferences(clonedNode, idMap) - } - clonedNodes[newId] = clonedNode } @@ -287,10 +282,6 @@ export function cloneLevelSubtree( 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/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx index 80a672db..422c27a6 100644 --- a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -1,15 +1,16 @@ 'use client' import { Icon } from '@iconify/react' -import { memo, useMemo } from 'react' +import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react' import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' +import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview' import { furnishTools } from '../ui/action-menu/furnish-tools' import { tools as structureTools } from '../ui/action-menu/structure-tools' - -type SvgPoint = { - x: number - y: number -} +import { + type FloorplanCursorPoint, + projectFloorplanCursorPoint, + resolveFloorplanCursorIndicatorPosition, +} from './floorplan-cursor-indicator-position' type FloorplanCursorIndicator = | { @@ -22,7 +23,7 @@ type FloorplanCursorIndicator = } type FloorplanCursorIndicatorOverlayProps = { - cursorPosition: SvgPoint | null + cursorPosition: FloorplanCursorPoint | null floorplanSelectionTool: FloorplanSelectionTool movingOpeningType: 'door' | 'window' | null isPanning: boolean @@ -46,6 +47,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi const tool = useEditor((state) => state.tool) const structureLayer = useEditor((state) => state.structureLayer) const catalogCategory = useEditor((state) => state.catalogCategory) + const cursorPoint = useFloorplanDraftPreview((state) => state.cursorPoint) + const anchorRef = useRef(null) + const [projectedCursorPosition, setProjectedCursorPosition] = + useState(null) const activeFloorplanToolConfig = useMemo(() => { if (movingOpeningType) { @@ -83,7 +88,32 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi return null }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) - const position = cursorPosition + useLayoutEffect(() => { + const anchor = anchorRef.current + const overlayHost = anchor?.parentElement + const scene = overlayHost?.querySelector('[data-floorplan-scene]') + const sceneToViewport = scene?.getScreenCTM() + + if (!(cursorPoint && cursorPosition && overlayHost && sceneToViewport)) { + setProjectedCursorPosition(null) + return + } + + const overlayRect = overlayHost.getBoundingClientRect() + const nextPosition = projectFloorplanCursorPoint(cursorPoint, sceneToViewport, { + x: overlayRect.left, + y: overlayRect.top, + }) + setProjectedCursorPosition((currentPosition) => + currentPosition && + currentPosition.x === nextPosition.x && + currentPosition.y === nextPosition.y + ? currentPosition + : nextPosition, + ) + }, [cursorPoint, cursorPosition]) + + const position = resolveFloorplanCursorIndicatorPosition(cursorPosition, projectedCursorPosition) if (!(indicator && position) || isPanning) { return null @@ -93,6 +123,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi