diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 7119d31213..e4ccf7ed39 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 1ec86d042b..c43136aa15 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 19d30b8551..0000000000 --- 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 77809e10fb..a707288ecf 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 862df6503f..e9acf4f633 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 5cbd6c7a77..d8ac1e9fe7 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 10012ff435..083e1e251f 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 5684e37809..55fdcfd9ba 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 a477a88f4c..3b633ebae2 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 4f14671f63..09c5dced50 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 f3e3f10d0d..e41cd114c9 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 fd0e8f7f8f..7374b7de8b 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 c1acb7856c..e4a4e470fc 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 216295e1ed..0000000000 --- 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 372addf060..0000000000 --- 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 164a5c37de..4306d0936f 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 82a8142918..a01a98ada4 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 2c980bfca5..70575bcfb9 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 0000000000..40fe4bc50f --- /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 a27991e4ee..60f9a23291 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 d73bd8d41a..637b1840ec 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 02aea9c5fb..a189af4344 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 5c6ec8bca7..e48f13e830 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 80a672db69..422c27a66d 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