diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 00fd99bd04..d7538ead71 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -9,7 +9,7 @@ import { type SceneGraph, type SidebarTab, } from '@pascal-app/editor' -import { Hammer, Layers } from 'lucide-react' +import { Hammer, Layers, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter, useSearchParams } from 'next/navigation' @@ -66,6 +66,22 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ /> ), }, + { + id: 'settings', + label: 'Settings', + component: () => null, + mobileDefaultSnap: 0.5, + mobileIcon: , + icon: ( + + ), + }, ] interface SceneLoaderProps { diff --git a/bun.lock b/bun.lock index 68c2a21e46..0923f69d6f 100644 --- a/bun.lock +++ b/bun.lock @@ -167,13 +167,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", + "manifold-3d": "3.5.1", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", + "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11", @@ -189,6 +192,7 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", + "fast-xml-parser": "^5.4.2", "typescript": "6.0.3", }, "peerDependencies": { @@ -443,6 +447,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], + + "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="], + + "@gltf-transform/functions": ["@gltf-transform/functions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "@gltf-transform/extensions": "^4.4.2", "ktx-parse": "^1.1.0", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.0.1" } }, "sha512-dclXgv9TshMaWBqPDUYd4xTwBQ2PpuR8p0Y9pokrRzGQDUPXRP6lTDzbqT0UmEmxFSvRyPJjvOWUmzeiRafpvw=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -525,9 +535,11 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@jscadui/3mf-export": ["@jscadui/3mf-export@0.5.0", "", {}, "sha512-y5vZktqCjyi7wA38zqNlLIdZUIRZoOO9vCjLzwmL4bR0hk7B/Zm1IeffzJPFe1vFc0C1IEv3hm8caDv3doRk9g=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], - "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546"], + "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -571,6 +583,8 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -725,11 +739,11 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#73c0812", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-73c0812"], + "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#73c0812", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-73c0812", "sha512-e55N1M+K7InSrLMcnMut3zvVcbeivGsf7FoqKO0K4KDiHmijmOJBM5ODj/j0MYa1ZkMd7G7YJx7cnmtWD2ekWg=="], - "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9"], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:sudhir9297/streetscape-pascal-plugin#1c04ec9", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-streetscape-pascal-plugin-1c04ec9", "sha512-X7Zg7wi0ghZRcTtbH5LF6xye493uSZ2ft3AmAQBtguBCU6VCwCexA7pdKDr5AnVxX/JRj2s6JSd/OX4aIZ9Y6Q=="], - "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c"], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], @@ -905,6 +919,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/ndarray": ["@types/ndarray@1.0.14", "", {}, "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="], + "@types/node": ["@types/node@22.19.20", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw=="], "@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], @@ -1029,6 +1045,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -1147,6 +1165,8 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -1223,6 +1243,8 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "esbuild-wasm": ["esbuild-wasm@0.27.7", "", { "bin": { "esbuild": "bin/esbuild" } }, "sha512-1k03e2/tGz+sLz3/xzoZmUsIqtaGIvJa8k4UqUeqCUry83nHmlxQYZUUES0WBFUYilSQUf7nDUGAciIIklljSg=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1287,6 +1309,10 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], + + "fast-xml-parser": ["fast-xml-parser@5.11.0", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-9IGxMqvqLOnqP+Egi1nqDHKv5k8aZ7r9n558enxcucmyVGEBNPAU+MOg/8jPIS7rO7sSq4gFm1/nHtiaubMruw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -1403,6 +1429,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1415,6 +1443,8 @@ "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], @@ -1457,6 +1487,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], @@ -1501,6 +1533,8 @@ "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -1551,6 +1585,8 @@ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "manifold-3d": ["manifold-3d@3.5.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.0", "@gltf-transform/extensions": "^4.2.0", "@gltf-transform/functions": "^4.2.0", "@jridgewell/resolve-uri": "^3.1.2", "@jridgewell/trace-mapping": "^0.3.31", "@jscadui/3mf-export": "^0.5.0", "commander": "^13.1.0", "convert-source-map": "^2.0.0", "fast-xml-parser": "^5.4.2", "fflate": "^0.8.0", "magic-string": "^0.30.21" }, "peerDependencies": { "esbuild-wasm": "^0.27.3" }, "bin": { "manifold-cad": "bin/manifold-cad" } }, "sha512-/+m6kxYMMhnPutcQ5oSmFJiJ+gyP/0fmuUCb9Qeaunvecm/bfqogKYDDJarsnWiFioSMtKheF+lGmSlnYCik9g=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -1599,6 +1635,14 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="], + + "ndarray-lanczos": ["ndarray-lanczos@0.3.0", "", { "dependencies": { "@types/ndarray": "^1.0.11", "ndarray": "^1.0.19" } }, "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg=="], + + "ndarray-ops": ["ndarray-ops@1.2.2", "", { "dependencies": { "cwise-compiler": "^1.0.0" } }, "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw=="], + + "ndarray-pixels": ["ndarray-pixels@5.2.0", "", { "dependencies": { "@types/ndarray": "^1.0.14", "ndarray": "^1.0.19", "ndarray-ops": "^1.2.2", "sharp": "^0.35.0" } }, "sha512-lTh4tFKziAatVTa9crIsidUyn+lqujVOQpzfdBWvdFu2wo9Uo6z261lVX7SgMyP89xGmj3TMTPbbxl9YDnV4SA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "next": ["next@16.3.0", "", { "dependencies": { "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.3.0", "@next/swc-darwin-x64": "16.3.0", "@next/swc-linux-arm64-gnu": "16.3.0", "@next/swc-linux-arm64-musl": "16.3.0", "@next/swc-linux-x64-gnu": "16.3.0", "@next/swc-linux-x64-musl": "16.3.0", "@next/swc-win32-arm64-msvc": "16.3.0", "@next/swc-win32-x64-msvc": "16.3.0", "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A=="], @@ -1661,6 +1705,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -1697,6 +1743,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1823,6 +1871,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], + "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], @@ -1909,6 +1959,8 @@ "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], + "uniq": ["uniq@1.0.1", "", {}, "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], @@ -1967,6 +2019,8 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -2051,6 +2105,8 @@ "linebreak/base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="], + "manifold-3d/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], diff --git a/packages/editor/package.json b/packages/editor/package.json index 9d3f89bea4..46ffebde14 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -45,13 +45,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "fflate": "^0.8.3", "howler": "^2.2.4", "lucide-react": "^1.7.0", + "manifold-3d": "3.5.1", "mitt": "^3.0.1", "motion": "^12.34.3", "nanoid": "^5.1.6", "pdfkit": "^0.19.1", "tailwind-merge": "^3.5.0", + "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "~0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11" @@ -67,6 +70,7 @@ "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.184.0", + "fast-xml-parser": "^5.4.2", "typescript": "6.0.3" } } diff --git a/packages/editor/scripts/generate-print-golden-house.ts b/packages/editor/scripts/generate-print-golden-house.ts new file mode 100644 index 0000000000..83247791e8 --- /dev/null +++ b/packages/editor/scripts/generate-print-golden-house.ts @@ -0,0 +1,89 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { prepareSceneForExport } from '../src/lib/glb-export' +import { exportSceneLevelsForPrint } from '../src/lib/level-print-export' +import { filterPreparedSceneForPrintContent } from '../src/lib/print-content-scope' +import { createPrintGoldenHouseFixture } from '../src/lib/print-golden-house.test-fixture' +import { compileManifoldMeshData } from '../src/lib/print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from '../src/lib/print-shell-compiler-manifold-worker' + +const outputArgument = process.argv[2] +if (!outputArgument) { + throw new Error( + 'Usage: bun packages/editor/scripts/generate-print-golden-house.ts ', + ) +} + +const outputDirectory = resolve(outputArgument) +const fixture = createPrintGoldenHouseFixture() + +async function sha256(data: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') + const compileShell = (source: Parameters[0]) => + compileSemanticPrintShellWithManifold(source, fixture.nodes, { + runner: compileManifoldMeshData, + }) + const common = { + scale: 100, + plinth: { marginMm: 2, thicknessMm: 3 }, + compileShells: true, + compileShell, + } + const threeMf = await exportSceneLevelsForPrint(structure, fixture.nodes, { + ...common, + format: '3mf', + }) + const stl = await exportSceneLevelsForPrint(structure, fixture.nodes, { + ...common, + format: 'stl', + }) + if (threeMf.report.status === 'blocked' || stl.report.status === 'blocked') { + throw new Error('The golden house failed print preflight and was not written.') + } + + await mkdir(outputDirectory, { recursive: true }) + const files = [ + { name: 'pascal-golden-house-levels.3mf', data: threeMf.data }, + { name: 'pascal-golden-house-levels-stl.zip', data: stl.data }, + ] + for (const file of files) await writeFile(resolve(outputDirectory, file.name), file.data) + + const manifest = { + kind: 'pascal-print-golden-house', + version: 1, + scale: 100, + units: 'millimeter', + files: await Promise.all( + files.map(async (file) => ({ + name: file.name, + bytes: file.data.byteLength, + sha256: await sha256(file.data), + })), + ), + parts: threeMf.report.parts.map((part) => ({ + kind: part.kind, + label: part.label, + sourceBaseMeters: part.sourceBaseMeters, + bounds: part.report.bounds, + triangles: part.report.triangleCount, + connectedComponentCount: part.report.connectedComponentCount, + solidComponentCount: part.report.solidComponentCount, + invertedWinding: part.report.invertedWinding, + volumeMm3: part.report.volumeMm3, + minimumFeatureThicknessMm: part.report.minimumFeatureThicknessMm, + })), + } + await writeFile( + resolve(outputDirectory, 'manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ) + process.stdout.write(`${JSON.stringify({ outputDirectory, ...manifest }, null, 2)}\n`) +} finally { + fixture.dispose() +} diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 1368bcbeb5..6e0475ef9b 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -1,13 +1,21 @@ 'use client' import { emitter, useScene } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { disposeObject3DResources, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' import * as THREE from 'three' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export' +import { exportSceneLevelsForPrint } from '../../lib/level-print-export' +import type { ModelExport, ModelExportArtifact } from '../../lib/model-export' +import { exportSceneToPrint3mf } from '../../lib/print-3mf' +import { filterPreparedSceneForPrintContent } from '../../lib/print-content-scope' +import { exportSceneToPrintStl, mergePrintExportDiagnostics } from '../../lib/print-export' +import { applySemanticPrintFeatureThickness } from '../../lib/print-feature-thickness' +import { compileSemanticPrintShellWithManifold } from '../../lib/print-shell-compiler-manifold-worker' +import useEditor from '../../store/use-editor' // prepareSceneForExport neutralises container meshes (door/window hitbox roots, // material-less renderables) with an attribute-less geometry — GLTFExporter @@ -34,14 +42,15 @@ function ensurePositionAttributes(root: THREE.Object3D) { export function ExportManager() { const scene = useThree((state) => state.scene) const setExportScene = useViewer((state) => state.setExportScene) + const setModelExport = useEditor((state) => state.setModelExport) useEffect(() => { - const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => { + const exportFn: ModelExport = async (format = 'glb', options = {}) => { // Find the scene renderer group by name const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) { console.error('scene-renderer group not found') - return + return null } const date = new Date().toISOString().split('T')[0] @@ -55,10 +64,9 @@ export function ExportManager() { await nextFrames() if (format === 'glb') { - const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, options) const blob = new Blob([buffer], { type: 'model/gltf-binary' }) - downloadBlob(blob, `model_${date}.glb`) - return + return finishArtifact(blob, `model_${date}.glb`, options.download) } // Hide editor affordances that live on the scene layer (selection handles, @@ -66,45 +74,145 @@ export function ExportManager() { // synchronous capture path thumbnails use. We clone the scene inside the // window, so the export snapshots the clean building, then restore. emitter.emit('thumbnail:before-capture', undefined) + const restoreLevels = snapLevelsToTruePositions() + const nodes = useScene.getState().nodes let prepared: ReturnType try { - prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) + prepared = prepareSceneForExport(sceneGroup, nodes, options) } finally { + restoreLevels() emitter.emit('thumbnail:after-capture', undefined) } - const { scene: exportScene } = prepared + let { scene: exportScene } = prepared + const printContent = options.printContent ?? 'structure' + const isPrintFormat = format === 'print-stl' || format === 'print-3mf' + if (isPrintFormat) { + exportScene = filterPreparedSceneForPrintContent(exportScene, nodes, printContent) + } ensurePositionAttributes(exportScene) + if (isPrintFormat) { + const printFormat = format === 'print-3mf' ? '3mf' : 'stl' + const scale = options.printScale ?? 100 + const compileShells = printContent === 'structure' + const minimumFeatureMm = compileShells ? options.printMinimumFeatureMm : undefined + if (options.printScope === 'levels') { + const plinth = + options.printBase === 'plinth' + ? { + marginMm: options.printPlinthMarginMm ?? 2, + thicknessMm: options.printPlinthThicknessMm ?? 2, + } + : undefined + const { data, report } = await exportSceneLevelsForPrint(exportScene, nodes, { + scale, + format: printFormat, + plinth, + minimumFeatureMm, + compileShells, + compileShell: compileShells ? compileSemanticPrintShellWithManifold : undefined, + }) + const blob = new Blob([data], { + type: printFormat === '3mf' ? 'model/3mf' : 'application/zip', + }) + return finishArtifact( + blob, + `print_levels_1-${scale}_${date}.${printFormat === '3mf' ? '3mf' : 'zip'}`, + options.download, + report, + ) + } + if (options.printBase === 'plinth') { + throw new Error('Plinth generation is available only for per-level print packages.') + } + const compiled = compileShells + ? await compileSemanticPrintShellWithManifold(exportScene, nodes) + : null + try { + const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : exportScene + const printOptions = { + scale, + compiled: compiled?.status === 'compiled', + indexedTopology: compiled?.backend === 'manifold-3d', + } + const output = + printFormat === '3mf' + ? exportSceneToPrint3mf(printSource, printOptions) + : exportSceneToPrintStl(printSource, printOptions) + let report = compiled + ? mergePrintExportDiagnostics( + output.report, + compiled.diagnostics, + new Set(['compiler_pending']), + ) + : output.report + if (compiled) { + report = applySemanticPrintFeatureThickness( + report, + nodes, + compiled.sourceNodeIds, + minimumFeatureMm, + ) + } + const { buffer } = output + const blob = new Blob([buffer], { + type: printFormat === '3mf' ? 'model/3mf' : 'model/stl', + }) + return finishArtifact( + blob, + `print_model_1-${scale}_${date}.${printFormat}`, + options.download, + report, + ) + } finally { + if (compiled?.scene) disposeObject3DResources(compiled.scene) + } + } + if (format === 'stl') { const exporter = new STLExporter() const result = exporter.parse(exportScene, { binary: true }) const blob = new Blob([result], { type: 'model/stl' }) - downloadBlob(blob, `model_${date}.stl`) - return + return finishArtifact(blob, `model_${date}.stl`, options.download) } if (format === 'obj') { const exporter = new OBJExporter() const result = exporter.parse(exportScene) const blob = new Blob([result], { type: 'model/obj' }) - downloadBlob(blob, `model_${date}.obj`) - return + return finishArtifact(blob, `model_${date}.obj`, options.download) } + + return null } finally { useViewer.getState().setExporting(false) } } - setExportScene(exportFn) + setModelExport(exportFn) + setExportScene(async (format = 'glb') => { + await exportFn(format, { onlyVisible: true }) + }) return () => { + setModelExport(null) setExportScene(null) } - }, [scene, setExportScene]) + }, [scene, setExportScene, setModelExport]) return null } +function finishArtifact( + blob: Blob, + filename: string, + download: boolean | undefined, + metadata?: unknown, +): ModelExportArtifact { + if (download !== false) downloadBlob(blob, filename) + return { blob, filename, metadata } +} + function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const link = document.createElement('a') diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx index e1ad6a8c63..c46e91ad3b 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -29,6 +29,7 @@ import useFloorplanMode from './../../../../../store/use-floorplan-mode' import { AudioSettingsDialog } from './audio-settings-dialog' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' import { LoadBuildDialog, type PendingImport } from './load-build-dialog' +import { PrintExportCard } from './print-export-card' type SceneNode = Record & { id?: unknown @@ -190,11 +191,12 @@ export function SettingsPanel({ const setScene = useScene((state) => state.setScene) const clearScene = useScene((state) => state.clearScene) const resetSelection = useViewer((state) => state.resetSelection) - const exportScene = useViewer((state) => state.exportScene) + const modelExport = useEditor((state) => state.modelExport) const shadows = useViewer((state) => state.shadows) const setPhase = useEditor((state) => state.setPhase) const floorplanMode = useFloorplanMode((state) => state.mode) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) + const [exportOnlyVisible, setExportOnlyVisible] = useState(true) const [pendingImport, setPendingImport] = useState(null) const sceneGraphValue = useMemo( () => buildSceneGraphValue(nodes as Record, rootNodeIds), @@ -374,9 +376,18 @@ export function SettingsPanel({
3D model
+
+
+
Visible nodes only
+
+ Exclude hidden furniture and other hidden scene nodes +
+
+ +
+ +
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts new file mode 100644 index 0000000000..9998c2309f --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test' +import type { PrintLevelBundleReport } from '../../../../../lib/level-print-export' +import type { ModelExport, ModelExportOptions } from '../../../../../lib/model-export' +import type { PrintExportReport } from '../../../../../lib/print-export' +import { preparePrintExport } from './print-export-card' + +const report: PrintExportReport = { + kind: 'print-export-report', + version: 2, + format: '3mf', + scale: 50, + units: 'millimeter', + orientation: 'z-up', + status: 'pass', + bounds: { + min: { x: -50, y: -30, z: 0 }, + max: { x: 50, y: 30, z: 40 }, + width: 100, + depth: 60, + height: 40, + }, + triangleCount: 12, + invalidTriangleCount: 0, + degenerateTriangleCount: 0, + boundaryEdgeCount: 0, + nonManifoldEdgeCount: 0, + connectedComponentCount: 1, + solidComponentCount: 1, + invertedWinding: false, + volumeMm3: 240_000, + diagnostics: [], +} + +const stlReport: PrintExportReport = { ...report, format: 'stl' } + +const levelReport: PrintLevelBundleReport = { + kind: 'print-level-export-report', + version: 2, + format: 'stl', + scale: 50, + units: 'millimeter', + orientation: 'z-up', + status: 'pass', + partCount: 1, + parts: [ + { + kind: 'level', + levelId: 'level_ground', + label: 'Ground', + objectName: '01 Ground', + filename: '01_ground.stl', + sourceBaseMeters: 0, + report: stlReport, + }, + ], + excludedNodeIds: [], + diagnostics: [], +} + +describe('print export card contract', () => { + test('prepares a visible-only scaled artifact without downloading immediately', async () => { + const calls: { format?: string; options?: ModelExportOptions }[] = [] + const artifact = { blob: new Blob(['3mf']), filename: 'house.3mf', metadata: report } + const modelExport: ModelExport = async (format, options) => { + calls.push({ format, options }) + return artifact + } + + const prepared = await preparePrintExport( + modelExport, + true, + '50', + 'whole', + '3mf', + 'structure', + 'none', + '2', + '2', + '1.8', + ) + + expect(calls).toEqual([ + { + format: 'print-3mf', + options: { + onlyVisible: true, + download: false, + printScale: 50, + printScope: 'whole', + printContent: 'structure', + printBase: 'none', + printMinimumFeatureMm: 1.8, + }, + }, + ]) + expect(prepared).toEqual({ artifact, report }) + }) + + test('rejects invalid scale input before invoking the exporter', async () => { + let invoked = false + const modelExport: ModelExport = async () => { + invoked = true + return null + } + + await expect( + preparePrintExport( + modelExport, + true, + '0', + 'levels', + '3mf', + 'structure', + 'none', + '2', + '2', + '', + ), + ).rejects.toThrow( + 'Enter a positive scale denominator', + ) + expect(invoked).toBe(false) + }) + + test('accepts the per-level archive report contract', async () => { + const calls: { format?: string; options?: ModelExportOptions }[] = [] + const artifact = { blob: new Blob(['zip']), filename: 'levels.zip', metadata: levelReport } + const modelExport: ModelExport = async (format, options) => { + calls.push({ format, options }) + return artifact + } + + const prepared = await preparePrintExport( + modelExport, + true, + '50', + 'levels', + 'stl', + 'everything', + 'plinth', + '3', + '2.5', + '', + ) + + expect(calls).toEqual([ + { + format: 'print-stl', + options: { + onlyVisible: true, + download: false, + printScale: 50, + printScope: 'levels', + printContent: 'everything', + printBase: 'plinth', + printPlinthMarginMm: 3, + printPlinthThicknessMm: 2.5, + }, + }, + ]) + expect(prepared).toEqual({ artifact, report: levelReport }) + }) + + test('rejects invalid plinth dimensions before invoking the exporter', async () => { + let invoked = false + const modelExport: ModelExport = async () => { + invoked = true + return null + } + + await expect( + preparePrintExport( + modelExport, + true, + '50', + 'levels', + '3mf', + 'structure', + 'plinth', + '-1', + '2', + '', + ), + ).rejects.toThrow('non-negative plinth margin') + expect(invoked).toBe(false) + }) + + test('rejects an invalid custom minimum feature target before invoking the exporter', async () => { + let invoked = false + const modelExport: ModelExport = async () => { + invoked = true + return null + } + + await expect( + preparePrintExport( + modelExport, + true, + '50', + 'levels', + '3mf', + 'structure', + 'none', + '2', + '2', + '0', + ), + ).rejects.toThrow('positive minimum feature target') + expect(invoked).toBe(false) + }) + + test('rejects an artifact without the print preflight contract', async () => { + const modelExport: ModelExport = async () => ({ + blob: new Blob(['stl']), + filename: 'house.stl', + }) + + await expect( + preparePrintExport( + modelExport, + false, + '100', + 'whole', + '3mf', + 'structure', + 'none', + '2', + '2', + '', + ), + ).rejects.toThrow('did not return a preflight report') + }) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx new file mode 100644 index 0000000000..38cac03376 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/print-export-card.tsx @@ -0,0 +1,475 @@ +import { useScene } from '@pascal-app/core' +import { AlertTriangle, CheckCircle2, Download, Printer, XCircle } from 'lucide-react' +import { useEffect, useId, useRef, useState } from 'react' +import { Button } from '../../../../../components/ui/primitives/button' +import { + isPrintLevelBundleReport, + type PrintBaseMode, + type PrintLevelBundleReport, +} from '../../../../../lib/level-print-export' +import type { PrintContentScope } from '../../../../../lib/print-content-scope' +import type { ModelExport, ModelExportArtifact } from '../../../../../lib/model-export' +import { + isPrintExportReport, + type PrintArtifactFormat, + type PrintExportReport, +} from '../../../../../lib/print-export' +import useEditor from '../../../../../store/use-editor' + +export type PreparedPrintExport = { + artifact: ModelExportArtifact + report: PrintExportReport | PrintLevelBundleReport +} + +function formatMillimeters(value: number): string { + if (value >= 100) return value.toFixed(1) + if (value >= 10) return value.toFixed(2) + return value.toFixed(3) +} + +function downloadArtifact(artifact: ModelExportArtifact) { + const url = URL.createObjectURL(artifact.blob) + const link = document.createElement('a') + link.href = url + link.download = artifact.filename + link.click() + URL.revokeObjectURL(url) +} + +export async function preparePrintExport( + modelExport: ModelExport, + onlyVisible: boolean, + scaleInput: string, + scope: 'whole' | 'levels', + format: PrintArtifactFormat, + content: PrintContentScope, + base: PrintBaseMode, + plinthMarginInput: string, + plinthThicknessInput: string, + minimumFeatureInput: string, +): Promise { + const scale = Number(scaleInput) + if (!Number.isFinite(scale) || scale <= 0) { + throw new RangeError('Enter a positive scale denominator, such as 25, 50, or 100.') + } + + let minimumFeatureMm: number | undefined + if (content === 'structure' && minimumFeatureInput.trim() !== '') { + minimumFeatureMm = Number(minimumFeatureInput) + if (!Number.isFinite(minimumFeatureMm) || minimumFeatureMm <= 0) { + throw new RangeError('Enter a positive minimum feature target in millimeters.') + } + } + + let plinthMarginMm: number | undefined + let plinthThicknessMm: number | undefined + if (base === 'plinth') { + if (scope !== 'levels') { + throw new RangeError('A plinth is available only for per-level print packages.') + } + plinthMarginMm = Number(plinthMarginInput) + plinthThicknessMm = Number(plinthThicknessInput) + if (!Number.isFinite(plinthMarginMm) || plinthMarginMm < 0) { + throw new RangeError('Enter a non-negative plinth margin in millimeters.') + } + if (!Number.isFinite(plinthThicknessMm) || plinthThicknessMm <= 0) { + throw new RangeError('Enter a positive plinth thickness in millimeters.') + } + } + + const artifact = await modelExport(format === '3mf' ? 'print-3mf' : 'print-stl', { + onlyVisible, + download: false, + printScale: scale, + printScope: scope, + printContent: content, + printBase: base, + ...(minimumFeatureMm === undefined ? {} : { printMinimumFeatureMm: minimumFeatureMm }), + ...(plinthMarginMm === undefined ? {} : { printPlinthMarginMm: plinthMarginMm }), + ...(plinthThicknessMm === undefined ? {} : { printPlinthThicknessMm: plinthThicknessMm }), + }) + if ( + !artifact || + (!isPrintExportReport(artifact.metadata) && !isPrintLevelBundleReport(artifact.metadata)) + ) { + throw new Error('The print exporter did not return a preflight report.') + } + return { artifact, report: artifact.metadata } +} + +export function PrintExportCard({ onlyVisible }: { onlyVisible: boolean }) { + const scaleInputId = useId() + const minimumFeatureInputId = useId() + const nodes = useScene((state) => state.nodes) + const modelExport = useEditor((state) => state.modelExport) + const generationRef = useRef(0) + const [printScale, setPrintScale] = useState('100') + const [scope, setScope] = useState<'whole' | 'levels'>('levels') + const [format, setFormat] = useState('3mf') + const [content, setContent] = useState('structure') + const [base, setBase] = useState('none') + const [plinthMargin, setPlinthMargin] = useState('2') + const [plinthThickness, setPlinthThickness] = useState('2') + const [minimumFeature, setMinimumFeature] = useState('') + const [isPreparing, setIsPreparing] = useState(false) + const [prepared, setPrepared] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + generationRef.current += 1 + setIsPreparing(false) + setPrepared(null) + setError(null) + }, [ + nodes, + onlyVisible, + printScale, + scope, + format, + content, + base, + plinthMargin, + plinthThickness, + minimumFeature, + modelExport, + ]) + + useEffect( + () => () => { + generationRef.current += 1 + }, + [], + ) + + const handlePrepare = async () => { + if (!modelExport) { + setError('The 3D exporter is still loading.') + return + } + + const generation = generationRef.current + 1 + generationRef.current = generation + setIsPreparing(true) + setPrepared(null) + setError(null) + try { + const next = await preparePrintExport( + modelExport, + onlyVisible, + printScale, + scope, + format, + content, + scope === 'levels' ? base : 'none', + plinthMargin, + plinthThickness, + minimumFeature, + ) + if (generation === generationRef.current) setPrepared(next) + } catch (reason) { + if (generation === generationRef.current) { + setError(reason instanceof Error ? reason.message : 'Print export failed.') + } + } finally { + if (generation === generationRef.current) setIsPreparing(false) + } + } + + return ( +
+
+ +
+
Print files
+
+ Experimental millimeter, Z-up export normalized to the print bed +
+
+
+ + + + + + + + + + + + {scope === 'levels' && base === 'plinth' && ( +
+ + +
+ )} + + + + + + {error && ( +
+ + {error} +
+ )} + + {prepared && ( +
+
+ {prepared.report.status === 'blocked' ? ( + + ) : prepared.report.status === 'warning' ? ( + + ) : ( + + )} + + {prepared.report.status === 'blocked' + ? 'Basic preflight blocked this download.' + : prepared.report.status === 'warning' + ? 'Prepared with printability warnings.' + : 'Basic surface checks passed.'} + +
+ + {isPrintLevelBundleReport(prepared.report) ? ( +
+
+ Print parts + {prepared.report.partCount} +
+ {prepared.report.parts.map((part) => ( +
+
+ {part.label} + + {part.report.bounds + ? `${formatMillimeters(part.report.bounds.width)} × ${formatMillimeters( + part.report.bounds.depth, + )} × ${formatMillimeters(part.report.bounds.height)} mm` + : 'No geometry'} + +
+
+ {part.report.triangleCount.toLocaleString()} triangles ·{' '} + {part.report.status === 'pass' ? 'basic checks passed' : part.report.status} +
+
+ Solid components ·{' '} + {part.report.solidComponentCount?.toLocaleString() ?? 'Not checked'} · Surface + shells · {part.report.connectedComponentCount?.toLocaleString() ?? 'Not checked'} +
+ {part.report.minimumFeatureThicknessMm !== undefined && ( +
+ Minimum known feature ·{' '} + {part.report.minimumFeatureThicknessMm === null + ? 'Not measured' + : `${formatMillimeters(part.report.minimumFeatureThicknessMm)} mm`} +
+ )} +
+ ))} +
+ ) : ( +
+
Physical size
+
+ {prepared.report.bounds + ? `${formatMillimeters(prepared.report.bounds.width)} × ${formatMillimeters( + prepared.report.bounds.depth, + )} × ${formatMillimeters(prepared.report.bounds.height)} mm` + : '—'} +
+
Triangles
+
+ {prepared.report.triangleCount.toLocaleString()} +
+
Boundary edges
+
+ {prepared.report.boundaryEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
Non-manifold edges
+
+ {prepared.report.nonManifoldEdgeCount?.toLocaleString() ?? 'Not checked'} +
+
Solid components
+
+ {prepared.report.solidComponentCount?.toLocaleString() ?? 'Not checked'} +
+
Surface shells
+
+ {prepared.report.connectedComponentCount?.toLocaleString() ?? 'Not checked'} +
+
Face winding
+
+ {prepared.report.invertedWinding === null + ? 'Not checked' + : prepared.report.invertedWinding + ? 'Inverted' + : 'Outward'} +
+ {prepared.report.minimumFeatureThicknessMm !== undefined && ( + <> +
Minimum known feature
+
+ {prepared.report.minimumFeatureThicknessMm === null + ? 'Not measured' + : `${formatMillimeters(prepared.report.minimumFeatureThicknessMm)} mm`} +
+ + )} +
+ )} + +
    + {prepared.report.diagnostics.map((diagnostic, index) => ( +
  • · {diagnostic.message}
  • + ))} +
+ + +
+ )} +
+ ) +} diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index c9557bf9e3..5afc8d4c76 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -3,6 +3,8 @@ import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app import { buildDoorPreviewMesh } from '@pascal-app/viewer' import * as THREE from 'three' import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export' // The reference module reads the storage origin lazily on first use, so @@ -40,6 +42,35 @@ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { return new THREE.Mesh(geometry, material) } +function sceneWithVisibleAndHiddenBoxes(): { + root: THREE.Group + nodes: Record +} { + const root = new THREE.Group() + const nodes: Record = {} + + for (const [id, visible, x] of [ + ['item_visible', true, 0], + ['item_hidden', false, 2], + ] as const) { + const group = new THREE.Group() + const mesh = meshWithNodeMaterial(nodeMaterial()) + mesh.position.x = x + group.add(mesh) + root.add(group) + sceneRegistry.nodes.set(id, group) + nodes[id] = { + object: 'node', + id, + type: 'item', + parentId: null, + visible, + } as unknown as AnyNode + } + + return { root, nodes } +} + describe('prepareSceneForExport', () => { test('converts NodeMaterials to classic glTF-standard materials', () => { const root = new THREE.Group() @@ -173,6 +204,93 @@ describe('prepareSceneForExport', () => { expect(meshes).toHaveLength(1) }) + test('excludes hidden scene nodes and descendants by default', () => { + const root = new THREE.Group() + const levelGroup = new THREE.Group() + const itemGroup = new THREE.Group() + itemGroup.add(meshWithNodeMaterial(nodeMaterial())) + levelGroup.add(itemGroup) + root.add(levelGroup) + + const levelId = 'level_hidden' + const itemId = 'item_visible_child' + sceneRegistry.nodes.set(levelId, levelGroup) + sceneRegistry.nodes.set(itemId, itemGroup) + const nodes: Record = { + [levelId]: { + object: 'node', + id: levelId, + type: 'level', + parentId: null, + visible: false, + } as unknown as AnyNode, + [itemId]: { + object: 'node', + id: itemId, + type: 'item', + parentId: levelId, + visible: true, + } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(scene.getObjectByName(levelId)).toBeUndefined() + expect(scene.getObjectByName(itemId)).toBeUndefined() + expect(animations).toHaveLength(0) + }) + + test('can include hidden scene nodes when visible-only export is disabled', () => { + const root = new THREE.Group() + const itemGroup = new THREE.Group() + itemGroup.add(meshWithNodeMaterial(nodeMaterial())) + root.add(itemGroup) + + const itemId = 'item_hidden' + sceneRegistry.nodes.set(itemId, itemGroup) + const nodes: Record = { + [itemId]: { + object: 'node', + id: itemId, + type: 'item', + parentId: null, + visible: false, + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes, { onlyVisible: false }) + + expect(scene.getObjectByName(itemId)?.userData).toMatchObject({ + pascalId: itemId, + kind: 'item', + }) + }) + + test('traditional binary STL excludes hidden nodes by default and can include them', () => { + const { root, nodes } = sceneWithVisibleAndHiddenBoxes() + + const visibleScene = prepareSceneForExport(root, nodes).scene + const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene + const visibleStl = new STLExporter().parse(visibleScene, { binary: true }) + const completeStl = new STLExporter().parse(completeScene, { binary: true }) + + expect(visibleStl.getUint32(80, true)).toBe(12) + expect(completeStl.getUint32(80, true)).toBe(24) + }) + + test('traditional OBJ excludes hidden nodes by default and can include them', () => { + const { root, nodes } = sceneWithVisibleAndHiddenBoxes() + + const visibleScene = prepareSceneForExport(root, nodes).scene + const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene + const visibleObj = new OBJExporter().parse(visibleScene) + const completeObj = new OBJExporter().parse(completeScene) + const vertexCount = (obj: string) => obj.match(/^v /gm)?.length ?? 0 + + expect(vertexCount(visibleObj)).toBe(24) + expect(vertexCount(completeObj)).toBe(48) + }) + test('neutralises an invisible hitbox root but keeps its visible children', () => { // Door/window roots are selection hitboxes: a box geometry with an invisible // material (object stays visible). Left intact it would plug the wall opening. diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index ed0e406314..72461f3e5c 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -48,6 +48,7 @@ export type GlbExport = { export type GlbExportOptions = { textures?: 'embed' | 'reference' + onlyVisible?: boolean } /** Resolve after the next couple of animation frames, giving React/R3F time to @@ -125,10 +126,7 @@ export async function exportSceneToGlb( const restoreLevels = snapLevelsToTruePositions() let prepared: ReturnType try { - prepared = - textureMode === 'reference' - ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' }) - : prepareSceneForExport(sceneGroup, nodes) + prepared = prepareSceneForExport(sceneGroup, nodes, options) } finally { restoreLevels() emitter.emit('thumbnail:after-capture', undefined) @@ -152,7 +150,7 @@ export async function exportSceneToGlb( (error) => { reject(error) }, - { binary: true, animations }, + { binary: true, animations, onlyVisible: options.onlyVisible ?? true }, ) }) } @@ -197,6 +195,10 @@ export function prepareSceneForExport( } } + if (options.onlyVisible ?? true) { + pruneHiddenSceneNodes(cloneByOriginal, nodes) + } + // Object3Ds that carry node identity — never strip these even when they sit on // a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes // are stripped, but its identity node stays to carry the polygon that /viewer @@ -211,13 +213,57 @@ export function prepareSceneForExport( sanitizeMaterialGroups(scene, identityNodes) convertMaterials(scene, options.textures ?? 'embed') - const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) + const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal) + const { clips, clipNamesByNode } = bakeAnimationClips(retainedCloneByOriginal, nodes) - stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode) + stampIdentity(scene, retainedCloneByOriginal, nodes, clipNamesByNode) return { scene, animations: clips } } +function pruneHiddenSceneNodes( + cloneByOriginal: Map, + nodes: Record, +) { + const visibility = new Map() + + const isVisible = (id: string, path: Set): boolean => { + const cached = visibility.get(id) + if (cached !== undefined) return cached + + const node = nodes[id] + if (!node) return true + if (node.visible === false) { + visibility.set(id, false) + return false + } + if (!node.parentId || path.has(id)) { + visibility.set(id, true) + return true + } + + path.add(id) + const visible = isVisible(node.parentId, path) + path.delete(id) + visibility.set(id, visible) + return visible + } + + for (const [id, original] of sceneRegistry.nodes) { + if (isVisible(id, new Set())) continue + cloneByOriginal.get(original)?.removeFromParent() + } +} + +function retainedClones( + root: THREE.Object3D, + cloneByOriginal: Map, +): Map { + const retained = new Set() + root.traverse((object) => retained.add(object)) + return new Map(Array.from(cloneByOriginal.entries()).filter(([, clone]) => retained.has(clone))) +} + /** * Pair each original Object3D with its clone. `clone(true)` builds children in * source order, so parallel pre-order traversals line up 1:1 — this is how we diff --git a/packages/editor/src/lib/level-print-export.test.ts b/packages/editor/src/lib/level-print-export.test.ts new file mode 100644 index 0000000000..767a9263cd --- /dev/null +++ b/packages/editor/src/lib/level-print-export.test.ts @@ -0,0 +1,484 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, RoofSegmentNode, registerNode, sceneRegistry } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { exportSceneLevelsForPrint } from './level-print-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { compileSemanticPrintShell } from './print-shell-compiler' + +function registerFixtureKind(category: 'structure' | 'furnish'): string { + const kind = `print-level-${category}-${crypto.randomUUID()}` + registerNode({ + kind, + schemaVersion: 1, + category, + defaults: () => ({}), + capabilities: {}, + } as never) + return kind +} + +function binaryStlBounds(buffer: Uint8Array): { + triangles: number + min: THREE.Vector3 + max: THREE.Vector3 + size: THREE.Vector3 +} { + const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength) + const triangles = view.getUint32(80, true) + const bounds = new THREE.Box3() + const point = new THREE.Vector3() + let offset = 84 + + for (let triangle = 0; triangle < triangles; triangle += 1) { + offset += 12 + for (let vertex = 0; vertex < 3; vertex += 1) { + point.set( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ) + bounds.expandByPoint(point) + offset += 12 + } + offset += 2 + } + + return { + triangles, + min: bounds.min.clone(), + max: bounds.max.clone(), + size: bounds.getSize(new THREE.Vector3()), + } +} + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +function twoLevelFixture() { + const root = new THREE.Group() + const building = new THREE.Group() + const ground = new THREE.Group() + const upper = new THREE.Group() + const groundStructure = new THREE.Group() + const upperStructure = new THREE.Group() + const groundSolid = new THREE.Mesh(new THREE.BoxGeometry(10, 3, 8)) + const upperSolid = new THREE.Mesh(new THREE.BoxGeometry(8, 2, 6)) + groundSolid.position.y = 1.5 + upperSolid.position.y = 1 + groundStructure.add(groundSolid) + upperStructure.add(upperSolid) + ground.add(groundStructure) + upper.add(upperStructure) + upper.position.y = 3 + root.add(building) + building.add(ground, upper) + + const structureKind = registerFixtureKind('structure') + sceneRegistry.nodes.set('building_main', building) + sceneRegistry.nodes.set('level_ground', ground) + sceneRegistry.nodes.set('level_upper', upper) + sceneRegistry.nodes.set('structure_ground', groundStructure) + sceneRegistry.nodes.set('structure_upper', upperStructure) + + const nodes: Record = { + building_main: { + object: 'node', + id: 'building_main', + type: 'building', + parentId: null, + children: ['level_ground', 'level_upper'], + } as unknown as AnyNode, + level_ground: { + object: 'node', + id: 'level_ground', + type: 'level', + name: 'Ground', + level: 0, + height: 3, + parentId: 'building_main', + children: ['structure_ground'], + visible: true, + } as unknown as AnyNode, + level_upper: { + object: 'node', + id: 'level_upper', + type: 'level', + name: 'Upper', + level: 1, + height: 2, + parentId: 'building_main', + children: ['structure_upper'], + visible: true, + } as unknown as AnyNode, + structure_ground: { + object: 'node', + id: 'structure_ground', + type: structureKind, + parentId: 'level_ground', + visible: true, + } as unknown as AnyNode, + structure_upper: { + object: 'node', + id: 'structure_upper', + type: structureKind, + parentId: 'level_upper', + visible: true, + } as unknown as AnyNode, + } + + return { root, building, ground, upper, groundStructure, upperStructure, nodes } +} + +describe('per-level print STL export', () => { + afterEach(() => { + sceneRegistry.nodes.clear() + }) + + test('packages one bed-normalized, scale-correct STL per visible level', async () => { + const fixture = twoLevelFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) + const ground = binaryStlBounds(files['01_ground.stl']!) + const upper = binaryStlBounds(files['02_upper.stl']!) + + expect(Object.keys(files)).toEqual(['01_ground.stl', '02_upper.stl']) + expect(bundle.report.status).toBe('pass') + expect(bundle.report.partCount).toBe(2) + expect(bundle.report.parts.map((part) => part.kind)).toEqual(['level', 'level']) + expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([0, 3]) + expect(ground.triangles).toBe(12) + expect(ground.min.z).toBeCloseTo(0, 6) + expect(ground.size.x).toBeCloseTo(100, 4) + expect(ground.size.y).toBeCloseTo(80, 4) + expect(ground.size.z).toBeCloseTo(30, 4) + expect(upper.triangles).toBe(12) + expect(upper.min.z).toBeCloseTo(0, 6) + expect(upper.size.x).toBeCloseTo(80, 4) + expect(upper.size.y).toBeCloseTo(60, 4) + expect(upper.size.z).toBeCloseTo(20, 4) + }) + + test('blocks geometry that crosses or floats above its stored level base', async () => { + const fixture = twoLevelFixture() + fixture.groundStructure.position.y = -0.25 + fixture.upperStructure.position.y = 0.5 + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const [ground, upper] = bundle.report.parts + + expect(bundle.report.status).toBe('blocked') + expect(ground?.sourceBaseMeters).toBe(0) + expect(ground?.report.bounds?.min.z).toBeCloseTo(-2.5, 5) + expect(ground?.report.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'level_geometry_below_base', + nodeIds: ['level_ground'], + }), + ) + expect(upper?.sourceBaseMeters).toBe(3) + expect(upper?.report.bounds?.min.z).toBeCloseTo(5, 5) + expect(upper?.report.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'level_geometry_detached_from_base', + nodeIds: ['level_upper'], + }), + ) + }) + + test('orders a basement first and honors additive stored base elevation', async () => { + const fixture = twoLevelFixture() + fixture.nodes.level_ground = { + ...fixture.nodes.level_ground!, + name: 'Basement', + level: -1, + baseElevation: -0.4, + } as AnyNode + fixture.nodes.level_upper = { + ...fixture.nodes.level_upper!, + name: 'Ground', + level: 0, + } as AnyNode + fixture.ground.position.y = -0.4 + fixture.upper.position.y = 2.6 + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) + + expect(Object.keys(files)).toEqual(['01_basement.stl', '02_ground.stl']) + expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground', 'level_upper']) + expect(bundle.report.parts.map((part) => part.sourceBaseMeters)).toEqual([-0.4, 2.6]) + expect(binaryStlBounds(files['01_basement.stl']!).min.z).toBeCloseTo(0, 6) + expect(binaryStlBounds(files['02_ground.stl']!).min.z).toBeCloseTo(0, 6) + }) + + test('omits and blocks an unsplit stair that spans two levels', async () => { + const fixture = twoLevelFixture() + const stair = new THREE.Group() + stair.add(new THREE.Mesh(new THREE.BoxGeometry(1, 3, 2))) + fixture.ground.add(stair) + sceneRegistry.nodes.set('stair_main', stair) + fixture.nodes.stair_main = { + object: 'node', + id: 'stair_main', + type: 'stair', + parentId: 'level_ground', + fromLevelId: 'level_ground', + toLevelId: 'level_upper', + children: [], + visible: true, + } as unknown as AnyNode + + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + + expect(bundle.report.status).toBe('blocked') + expect(bundle.report.excludedNodeIds).toEqual(['stair_main']) + expect(bundle.report.diagnostics.map((diagnostic) => diagnostic.code)).toContain( + 'unsplit_spanning_node', + ) + expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) + }) + + test('does not create a part for a semantically hidden level', async () => { + const fixture = twoLevelFixture() + fixture.nodes.level_upper = { + ...fixture.nodes.level_upper!, + visible: false, + } as AnyNode + + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 100 }) + const files = unzipSync(bundle.data) + + expect(Object.keys(files)).toEqual(['01_ground.stl']) + expect(bundle.report.parts.map((part) => part.levelId)).toEqual(['level_ground']) + }) + + test('applies structure scope before partitioning level files', async () => { + const fixture = twoLevelFixture() + const furniture = new THREE.Group() + furniture.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) + fixture.ground.add(furniture) + sceneRegistry.nodes.set('chair_ground', furniture) + fixture.nodes.chair_ground = { + object: 'node', + id: 'chair_ground', + type: registerFixtureKind('furnish'), + parentId: 'level_ground', + visible: true, + } as unknown as AnyNode + + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') + const bundle = await exportSceneLevelsForPrint(structure, fixture.nodes, { scale: 100 }) + + expect(bundle.report.parts.map((part) => part.report.triangleCount)).toEqual([12, 12]) + expect(bundle.report.status).toBe('pass') + }) + + test('uses the asynchronous shell compiler before exporting a level part', async () => { + const root = new THREE.Group() + const building = new THREE.Group() + building.userData = { pascalId: 'building_roof-print' } + const level = new THREE.Group() + level.userData = { pascalId: 'level_roof-print' } + const roof = RoofSegmentNode.parse({ + id: 'rseg_level-print', + parentId: 'level_roof-print', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + root.add(building) + building.add(level) + level.add(roofRoot) + + const nodes: Record = { + 'building_roof-print': { + object: 'node', + id: 'building_roof-print', + type: 'building', + parentId: null, + children: ['level_roof-print'], + } as unknown as AnyNode, + 'level_roof-print': { + object: 'node', + id: 'level_roof-print', + type: 'level', + name: 'Roof', + level: 0, + parentId: 'building_roof-print', + children: [roof.id], + visible: true, + } as unknown as AnyNode, + [roof.id]: roof, + } + + let compileCalls = 0 + const raw = await exportSceneLevelsForPrint(root, nodes, { scale: 100 }) + const compiled = await exportSceneLevelsForPrint(root, nodes, { + scale: 100, + compileShells: true, + compileShell: async (source, compilerNodes) => { + compileCalls += 1 + return compileSemanticPrintShell(source, compilerNodes) + }, + }) + const part = compiled.report.parts[0]! + + expect(raw.report.parts[0]?.report.status).toBe('blocked') + expect(compileCalls).toBe(1) + expect(compiled.report.status).toBe('pass') + expect(part.report.status).toBe('pass') + expect(part.report.bounds?.width).toBeCloseTo(46.6962, 3) + expect(part.report.bounds?.depth).toBeCloseTo(37.1962, 3) + expect(part.report.bounds?.height).toBeCloseTo(15.3923, 3) + expect(part.report.boundaryEdgeCount).toBe(0) + expect(part.report.nonManifoldEdgeCount).toBe(0) + expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining(['baseline_compiler', 'compiler_limits']), + ) + expect(part.report.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain( + 'compiler_pending', + ) + }) + + test('produces deterministic archive bytes for the same level parts', async () => { + const fixture = twoLevelFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const first = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 }) + const second = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { scale: 50 }) + + expect(first.data).toEqual(second.data) + }) + + test('prepends an optional physical-size plinth derived from the lowest level bounds', async () => { + const fixture = twoLevelFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { + scale: 100, + plinth: { marginMm: 2, thicknessMm: 3 }, + }) + const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, { + scale: 100, + plinth: { marginMm: 2, thicknessMm: 3 }, + }) + const files = unzipSync(bundle.data) + const plinth = binaryStlBounds(files['00_plinth.stl']!) + + expect(Object.keys(files)).toEqual(['00_plinth.stl', '01_ground.stl', '02_upper.stl']) + expect(bundle.report.parts.map((part) => part.kind)).toEqual(['plinth', 'level', 'level']) + expect(bundle.report.parts[0]?.levelId).toBe('level_ground') + expect(plinth.triangles).toBe(12) + expect(plinth.size.x).toBeCloseTo(104, 4) + expect(plinth.size.y).toBeCloseTo(84, 4) + expect(plinth.size.z).toBeCloseTo(3, 4) + expect(bundle.data).toEqual(repeated.data) + }) + + test('packages named parts in one non-overlapping millimeter-unit 3MF plate mesh', async () => { + const fixture = twoLevelFixture() + fixture.nodes.level_ground = { + ...fixture.nodes.level_ground!, + name: 'Ground & Entry', + } as AnyNode + const prepared = prepareSceneForExport(fixture.root, fixture.nodes) + const options = { + scale: 100, + format: '3mf' as const, + plinth: { marginMm: 2, thicknessMm: 3 }, + } + + const bundle = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options) + const repeated = await exportSceneLevelsForPrint(prepared.scene, fixture.nodes, options) + const files = unzipSync(bundle.data) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse( + xml, + ).model + const object = asArray>(model.resources.object)[0]! + const items = asArray>(model.build.item) + const metadata = asArray>(model.metadata) + const partManifest = JSON.parse( + metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!, + ) as Array<{ + name: string + vertexStart: number + vertexCount: number + triangleStart: number + triangleCount: number + }> + const mesh = object.mesh as { + vertices: { vertex: Record | Record[] } + triangles: { triangle: Record | Record[] } + } + const vertices = asArray(mesh.vertices.vertex) + const triangles = asArray(mesh.triangles.triangle) + + expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model']) + expect(model.unit).toBe('millimeter') + expect(object.name).toBe('Pascal level parts') + expect(partManifest.map((part) => part.name)).toEqual([ + '00 Plinth', + '01 Ground & Entry', + '02 Upper', + ]) + expect(items.map((item) => item.objectid)).toEqual(['1']) + expect(bundle.report.format).toBe('3mf') + expect(bundle.report.parts.map((part) => part.filename)).toEqual([null, null, null]) + expect(bundle.report.parts.map((part) => part.objectName)).toEqual([ + '00 Plinth', + '01 Ground & Entry', + '02 Upper', + ]) + + const expectedSizes = [ + [104, 84, 3], + [100, 80, 30], + [80, 60, 20], + ] + let previousMaxX = Number.NEGATIVE_INFINITY + for (const [index, part] of partManifest.entries()) { + const bounds = new THREE.Box3() + for (const vertex of vertices.slice(part.vertexStart, part.vertexStart + part.vertexCount)) { + bounds.expandByPoint( + new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z)), + ) + } + const size = bounds.getSize(new THREE.Vector3()) + + expect(part.triangleCount).toBe(12) + expect( + triangles.slice(part.triangleStart, part.triangleStart + part.triangleCount), + ).toHaveLength(12) + expect(size.x).toBeCloseTo(expectedSizes[index]![0]!, 5) + expect(size.y).toBeCloseTo(expectedSizes[index]![1]!, 5) + expect(size.z).toBeCloseTo(expectedSizes[index]![2]!, 5) + expect(bounds.min.z).toBeCloseTo(0, 9) + if (index > 0) expect(bounds.min.x - previousMaxX).toBeCloseTo(5, 5) + previousMaxX = bounds.max.x + } + expect(items[0]?.transform).toBeUndefined() + expect(bundle.data).toEqual(repeated.data) + }) +}) diff --git a/packages/editor/src/lib/level-print-export.ts b/packages/editor/src/lib/level-print-export.ts new file mode 100644 index 0000000000..75a9fb7dc8 --- /dev/null +++ b/packages/editor/src/lib/level-print-export.ts @@ -0,0 +1,526 @@ +import { + type AnyNode, + getLevelDisplayName, + getLevelElevations, + type LevelNode, +} from '@pascal-app/core' +import { disposeObject3DResources } from '@pascal-app/viewer' +import { type Zippable, zipSync } from 'fflate' +import * as THREE from 'three' +import { createPrint3mf, type Print3mfPart } from './print-3mf' +import { + encodePreparedPrintSceneToStl, + extractPreparedPrintMesh, + mergePrintExportDiagnostics, + type PrintArtifactFormat, + type PrintExportBounds, + type PrintExportDiagnostic, + type PrintExportReport, + type PrintMeshData, + prepareSceneForPrint, +} from './print-export' +import { + applyPrintFeatureThickness, + applySemanticPrintFeatureThickness, + isPrintFeatureThicknessDiagnostic, +} from './print-feature-thickness' +import { compileSemanticPrintShell } from './print-shell-compiler' +import type { PrintShellCompileResult } from './print-shell-compiler-baseline' + +const ZIP_MTIME = new Date(2000, 0, 1, 0, 0, 0) +const MILLIMETERS_PER_METER = 1000 +const LEVEL_BASE_TOLERANCE_MM = 0.01 + +export type PrintBaseMode = 'none' | 'plinth' + +export type PrintPlinthOptions = { + marginMm: number + thicknessMm: number +} + +export type PrintLevelPartReport = { + kind: 'level' | 'plinth' + levelId: string + label: string + objectName: string + filename: string | null + sourceBaseMeters: number | null + report: PrintExportReport +} + +export type PrintLevelBundleReport = { + kind: 'print-level-export-report' + version: 2 + format: PrintArtifactFormat + scale: number + units: 'millimeter' + orientation: 'z-up' + status: 'pass' | 'warning' | 'blocked' + partCount: number + parts: PrintLevelPartReport[] + excludedNodeIds: string[] + diagnostics: PrintExportDiagnostic[] +} + +export type PrintLevelPackage = { + data: Uint8Array + report: PrintLevelBundleReport +} + +export type PrintLevelExportOptions = { + scale: number + format?: PrintArtifactFormat + plinth?: PrintPlinthOptions + minimumFeatureMm?: number + compileShells?: boolean + compileShell?: ( + source: THREE.Object3D, + nodes: Record, + ) => Promise +} + +function exportedIdentityIds(root: THREE.Object3D): Set { + const ids = new Set() + root.traverse((object) => { + const id = object.userData.pascalId + if (typeof id === 'string') ids.add(id) + }) + return ids +} + +function owningLevelId( + id: string, + nodes: Record, + memo: Map, + path = new Set(), +): string | null { + if (memo.has(id)) return memo.get(id) ?? null + const node = nodes[id] + if (!node || path.has(id)) return null + if (node.type === 'level') { + memo.set(id, id) + return id + } + if (!node.parentId) { + memo.set(id, null) + return null + } + + path.add(id) + const levelId = owningLevelId(node.parentId, nodes, memo, path) + path.delete(id) + memo.set(id, levelId) + return levelId +} + +function levelAncestors(levelId: string, nodes: Record): Set { + const ancestors = new Set() + const visited = new Set() + let parentId = nodes[levelId]?.parentId ?? null + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + ancestors.add(parentId) + parentId = nodes[parentId]?.parentId ?? null + } + return ancestors +} + +function isSpanningNode(node: AnyNode, ownerLevelId: string | null): boolean { + if (node.type === 'elevator') return true + if (node.type !== 'stair') return false + + const fromLevelId = node.fromLevelId ?? ownerLevelId + const toLevelId = node.toLevelId + return Boolean(fromLevelId && toLevelId && fromLevelId !== toLevelId) +} + +function hasExcludedAncestor( + id: string, + excludedIds: ReadonlySet, + nodes: Record, +): boolean { + const visited = new Set() + let parentId = nodes[id]?.parentId ?? null + while (parentId && !visited.has(parentId)) { + if (excludedIds.has(parentId)) return true + visited.add(parentId) + parentId = nodes[parentId]?.parentId ?? null + } + return false +} + +function pruneSceneToLevel( + source: THREE.Object3D, + levelId: string, + nodes: Record, + excludedIds: ReadonlySet, + ownerByNodeId: Map, +): THREE.Object3D { + const scene = source.clone(true) + const ancestors = levelAncestors(levelId, nodes) + const removals: THREE.Object3D[] = [] + + scene.traverse((object) => { + const id = object.userData.pascalId + if (typeof id !== 'string') return + const belongsToLevel = + ownerByNodeId.get(id) === levelId && + !excludedIds.has(id) && + !hasExcludedAncestor(id, excludedIds, nodes) + if (!belongsToLevel && !ancestors.has(id)) removals.push(object) + }) + + for (const object of removals) object.removeFromParent() + scene.name = `print-level-${levelId}` + return scene +} + +function safeFilenamePart(value: string): string { + return ( + value + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'level' + ) +} + +function bundleStatus( + diagnostics: PrintExportDiagnostic[], + parts: PrintLevelPartReport[], +): PrintLevelBundleReport['status'] { + if ( + diagnostics.some((diagnostic) => diagnostic.severity === 'error') || + parts.some((part) => part.report.status === 'blocked') + ) { + return 'blocked' + } + if ( + diagnostics.some((diagnostic) => diagnostic.severity === 'warning') || + parts.some((part) => part.report.status === 'warning') + ) { + return 'warning' + } + return 'pass' +} + +type PreparedLevelArtifact = { + filename: string | null + objectName: string + bytes: Uint8Array | null + mesh: PrintMeshData | null + bounds: PrintExportBounds | null +} + +function levelBaseDiagnostics( + level: LevelNode, + label: string, + sourceBaseMeters: number | null, + report: PrintExportReport, +): PrintExportDiagnostic[] { + if (sourceBaseMeters === null) { + return [ + { + severity: 'error', + code: 'missing_level_base', + message: `${label} has no finite stored level base and cannot be normalized reliably.`, + nodeIds: [level.id], + }, + ] + } + + const minZ = report.bounds?.min.z + if (minZ === undefined || Math.abs(minZ) <= LEVEL_BASE_TOLERANCE_MM) return [] + if (minZ < 0) { + return [ + { + severity: 'error', + code: 'level_geometry_below_base', + message: `${label} extends ${Math.abs(minZ).toFixed(3)} mm below its stored level base. Correct the level ownership or supporting slab before printing.`, + nodeIds: [level.id], + }, + ] + } + + return [ + { + severity: 'error', + code: 'level_geometry_detached_from_base', + message: `${label} begins ${minZ.toFixed(3)} mm above its stored level base, leaving the printable part detached from the bed. Add or assign a floor solid before printing.`, + nodeIds: [level.id], + }, + ] +} + +export async function exportSceneLevelsForPrint( + source: THREE.Object3D, + nodes: Record, + options: PrintLevelExportOptions, +): Promise { + const format = options.format ?? 'stl' + const exportedIds = exportedIdentityIds(source) + const levelElevations = getLevelElevations(nodes) + const ownerByNodeId = new Map() + for (const id of Object.keys(nodes)) owningLevelId(id, nodes, ownerByNodeId) + + const levels = Object.values(nodes) + .filter((node): node is LevelNode => node.type === 'level' && exportedIds.has(node.id)) + .sort( + (a, b) => + (a.parentId ?? '').localeCompare(b.parentId ?? '') || + a.level - b.level || + a.id.localeCompare(b.id), + ) + + const excludedIds = new Set() + const diagnostics: PrintExportDiagnostic[] = [] + for (const id of exportedIds) { + const node = nodes[id] + if (!node || !isSpanningNode(node, ownerByNodeId.get(id) ?? null)) continue + excludedIds.add(id) + diagnostics.push({ + severity: 'error', + code: 'unsplit_spanning_node', + message: `${node.type} ${id} spans levels and was omitted. Hide it or define a deterministic split before downloading level parts.`, + }) + } + + if (levels.length === 0) { + diagnostics.push({ + severity: 'error', + code: 'no_visible_levels', + message: 'No visible level nodes remain in the print scope.', + }) + } + + const levelArtifacts: PreparedLevelArtifact[] = [] + const levelParts: PrintLevelPartReport[] = [] + for (const [index, level] of levels.entries()) { + const label = getLevelDisplayName(level) + const prefix = String(index + 1).padStart(2, '0') + const objectName = `${prefix} ${label}` + const filename = format === 'stl' ? `${prefix}_${safeFilenamePart(label)}.stl` : null + const levelScene = pruneSceneToLevel(source, level.id, nodes, excludedIds, ownerByNodeId) + const sourceBase = levelElevations.get(level.id)?.baseY + const sourceBaseMeters = + typeof sourceBase === 'number' && Number.isFinite(sourceBase) ? sourceBase : null + const compiled = options.compileShells + ? options.compileShell + ? await options.compileShell(levelScene, nodes) + : compileSemanticPrintShell(levelScene, nodes) + : null + try { + const printSource = compiled ? (compiled.scene ?? new THREE.Group()) : levelScene + const prepared = prepareSceneForPrint(printSource, { + scale: options.scale, + compiled: compiled?.status === 'compiled', + indexedTopology: compiled?.backend === 'manifold-3d', + format, + ...(sourceBaseMeters === null ? {} : { sourceBedElevationMeters: sourceBaseMeters }), + }) + let report = compiled + ? mergePrintExportDiagnostics( + prepared.report, + compiled.diagnostics, + new Set(['compiler_pending']), + ) + : prepared.report + if (compiled) { + report = applySemanticPrintFeatureThickness( + report, + nodes, + compiled.sourceNodeIds, + options.minimumFeatureMm, + ) + } + const baseDiagnostics = levelBaseDiagnostics(level, label, sourceBaseMeters, report) + report = mergePrintExportDiagnostics(report, baseDiagnostics) + if (compiled) { + diagnostics.push( + ...compiled.diagnostics.filter((diagnostic) => diagnostic.severity !== 'info'), + ) + } + diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic)) + diagnostics.push(...baseDiagnostics) + levelArtifacts.push({ + filename, + objectName, + bytes: + format === 'stl' ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene)) : null, + mesh: + format === '3mf' && report.bounds && report.invalidTriangleCount === 0 + ? extractPreparedPrintMesh(prepared.scene) + : null, + bounds: report.bounds, + }) + levelParts.push({ + kind: 'level', + levelId: level.id, + label, + objectName, + filename, + sourceBaseMeters, + report, + }) + } finally { + if (compiled?.scene) disposeObject3DResources(compiled.scene) + } + } + + let plinthArtifact: PreparedLevelArtifact | null = null + let plinthPart: PrintLevelPartReport | null = null + if (options.plinth) { + const { marginMm, thicknessMm } = options.plinth + if ( + !Number.isFinite(marginMm) || + marginMm < 0 || + !Number.isFinite(thicknessMm) || + thicknessMm <= 0 + ) { + diagnostics.push({ + severity: 'error', + code: 'invalid_plinth_dimensions', + message: 'Plinth margin must be non-negative and thickness must be positive.', + }) + } else { + const buildingIds = new Set(levels.map((level) => level.parentId ?? 'unparented-building')) + const lowestLevel = levels[0] + const lowestPart = levelParts[0] + const bounds = lowestPart?.report.bounds + if (buildingIds.size > 1) { + diagnostics.push({ + severity: 'error', + code: 'multiple_building_plinth', + message: 'A plinth currently requires the print scope to contain exactly one building.', + }) + } else if (!lowestLevel || !lowestPart || !bounds) { + diagnostics.push({ + severity: 'error', + code: 'plinth_missing_footprint', + message: 'The lowest visible level has no structural bounds for plinth generation.', + }) + } else { + const widthMeters = ((bounds.width + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER + const depthMeters = ((bounds.depth + marginMm * 2) * options.scale) / MILLIMETERS_PER_METER + const thicknessMeters = (thicknessMm * options.scale) / MILLIMETERS_PER_METER + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(widthMeters, thicknessMeters, depthMeters), + ) + try { + const prepared = prepareSceneForPrint(mesh, { ...options, format }) + const report = applyPrintFeatureThickness( + prepared.report, + { + features: [{ nodeId: lowestLevel.id, thicknessMm }], + unmeasuredNodeIds: [], + }, + options.minimumFeatureMm, + ) + const filename = format === 'stl' ? '00_plinth.stl' : null + const objectName = '00 Plinth' + plinthArtifact = { + filename, + objectName, + bytes: + format === 'stl' + ? new Uint8Array(encodePreparedPrintSceneToStl(prepared.scene)) + : null, + mesh: + format === '3mf' && + prepared.report.bounds && + prepared.report.invalidTriangleCount === 0 + ? extractPreparedPrintMesh(prepared.scene) + : null, + bounds: report.bounds, + } + plinthPart = { + kind: 'plinth', + levelId: lowestLevel.id, + label: 'Plinth', + objectName, + filename, + sourceBaseMeters: null, + report, + } + diagnostics.push(...report.diagnostics.filter(isPrintFeatureThicknessDiagnostic)) + diagnostics.push({ + severity: 'info', + code: 'rectangular_plinth_experimental', + message: + 'The plinth is a separate rectangular part derived from the lowest level bounds; footprint shaping and connectors are not implemented yet.', + }) + } finally { + disposeObject3DResources(mesh) + } + } + } + } + + diagnostics.push({ + severity: 'info', + code: 'level_parts_experimental', + message: options.compileShells + ? options.compileShell + ? 'Level parts use stored level bases and worker-backed Manifold semantic shell compilation; known wall, slab, roof, and plinth dimensions are measured, while mesh-observed thin features and self-intersections remain pending.' + : 'Level parts use stored level bases and the experimental synchronous semantic shell compiler; known wall, slab, roof, and plinth dimensions are measured, while worker execution, mesh-observed thin features, and self-intersections remain pending.' + : 'Level parts use stored level bases and semantic separation but are not boolean-unioned printable shells yet.', + }) + + const files: Zippable = {} + const parts: PrintLevelPartReport[] = [] + const packageParts: Print3mfPart[] = [] + if (plinthArtifact && plinthPart) { + if (plinthArtifact.filename && plinthArtifact.bytes) { + files[plinthArtifact.filename] = [plinthArtifact.bytes, { level: 0, mtime: ZIP_MTIME }] + } + if (plinthArtifact.mesh && plinthArtifact.bounds) { + packageParts.push({ + name: plinthArtifact.objectName, + mesh: plinthArtifact.mesh, + bounds: plinthArtifact.bounds, + }) + } + parts.push(plinthPart) + } + for (const [index, artifact] of levelArtifacts.entries()) { + if (artifact.filename && artifact.bytes) { + files[artifact.filename] = [artifact.bytes, { level: 0, mtime: ZIP_MTIME }] + } + if (artifact.mesh && artifact.bounds) { + packageParts.push({ + name: artifact.objectName, + mesh: artifact.mesh, + bounds: artifact.bounds, + }) + } + const part = levelParts[index] + if (part) parts.push(part) + } + + return { + data: + format === '3mf' + ? createPrint3mf(packageParts, 'Pascal level parts') + : zipSync(files, { level: 0 }), + report: { + kind: 'print-level-export-report', + version: 2, + format, + scale: options.scale, + units: 'millimeter', + orientation: 'z-up', + status: bundleStatus(diagnostics, parts), + partCount: parts.length, + parts, + excludedNodeIds: Array.from(excludedIds).sort(), + diagnostics, + }, + } +} + +export function isPrintLevelBundleReport(value: unknown): value is PrintLevelBundleReport { + if (!value || typeof value !== 'object') return false + const report = value as Partial + return report.kind === 'print-level-export-report' && report.version === 2 +} diff --git a/packages/editor/src/lib/model-export.ts b/packages/editor/src/lib/model-export.ts new file mode 100644 index 0000000000..efdf19ffde --- /dev/null +++ b/packages/editor/src/lib/model-export.ts @@ -0,0 +1,24 @@ +export type ModelExportFormat = 'glb' | 'stl' | 'obj' | 'print-stl' | 'print-3mf' + +export type ModelExportOptions = { + onlyVisible?: boolean + download?: boolean + printScale?: number + printScope?: 'whole' | 'levels' + printContent?: 'structure' | 'everything' + printBase?: 'none' | 'plinth' + printMinimumFeatureMm?: number + printPlinthMarginMm?: number + printPlinthThicknessMm?: number +} + +export type ModelExportArtifact = { + blob: Blob + filename: string + metadata?: unknown +} + +export type ModelExport = ( + format?: ModelExportFormat, + options?: ModelExportOptions, +) => Promise diff --git a/packages/editor/src/lib/print-3mf.test.ts b/packages/editor/src/lib/print-3mf.test.ts new file mode 100644 index 0000000000..d263fa7b29 --- /dev/null +++ b/packages/editor/src/lib/print-3mf.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'bun:test' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' +import * as THREE from 'three' +import { exportSceneToPrint3mf } from './print-3mf' + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +describe('print 3MF export', () => { + test('writes a deterministic standards package with explicit millimeter units', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 4, 6)) + mesh.position.set(5, 2, -7) + + const first = exportSceneToPrint3mf(mesh, { scale: 100 }) + const second = exportSceneToPrint3mf(mesh, { scale: 100 }) + const files = unzipSync(first.buffer) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse( + xml, + ).model + const object = asArray>(model.resources.object)[0]! + const item = asArray>(model.build.item)[0]! + const metadata = asArray>(model.metadata) + const partManifest = JSON.parse( + metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!, + ) + const objectMesh = object.mesh as { + vertices: { vertex: Record | Record[] } + triangles: { triangle: Record | Record[] } + } + const vertices = asArray(objectMesh.vertices.vertex) + const triangles = asArray(objectMesh.triangles.triangle) + const bounds = new THREE.Box3() + for (const vertex of vertices) { + bounds.expandByPoint(new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z))) + } + + expect(Object.keys(files)).toEqual(['[Content_Types].xml', '_rels/.rels', '3D/3dmodel.model']) + expect(strFromU8(files['_rels/.rels']!)).toContain('Target="/3D/3dmodel.model"') + expect(model.unit).toBe('millimeter') + expect(object.name).toBe('Pascal print model') + expect(vertices).toHaveLength(8) + expect(triangles).toHaveLength(12) + expect(item.objectid).toBe('1') + expect(item.transform).toBeUndefined() + expect(partManifest).toEqual([ + { + name: 'Pascal print model', + vertexStart: 0, + vertexCount: 8, + triangleStart: 0, + triangleCount: 12, + }, + ]) + expect(bounds.min.toArray()).toEqual([0, 0, 0]) + expect(bounds.max.toArray()).toEqual([100, 60, 40]) + expect(first.report.format).toBe('3mf') + expect(first.report.bounds?.width).toBeCloseTo(100, 6) + expect(first.report.bounds?.depth).toBeCloseTo(60, 6) + expect(first.report.bounds?.height).toBeCloseTo(40, 6) + expect(first.buffer).toEqual(second.buffer) + + for (const triangle of triangles) { + expect(Number(triangle.v1)).toBeLessThan(vertices.length) + expect(Number(triangle.v2)).toBeLessThan(vertices.length) + expect(Number(triangle.v3)).toBeLessThan(vertices.length) + } + }) + + test('returns a blocking report instead of serializing non-finite coordinates', () => { + const geometry = new THREE.BoxGeometry(1, 1, 1) + geometry.getAttribute('position').setX(0, Number.NaN) + + const output = exportSceneToPrint3mf(new THREE.Mesh(geometry), { scale: 100 }) + const model = strFromU8(unzipSync(output.buffer)['3D/3dmodel.model']!) + + expect(output.report.status).toBe('blocked') + expect(output.report.invalidTriangleCount).toBeGreaterThan(0) + expect(output.report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'non_finite_geometry', severity: 'error' }), + ) + expect(model).not.toContain(' + + + + +` + +const ROOT_RELATIONSHIPS = ` + + + +` + +export type Print3mfPart = { + name: string + mesh: PrintMeshData + bounds: PrintExportBounds +} + +export type Print3mfExport = { + buffer: Uint8Array + report: PrintExportReport +} + +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function decimal(value: number): string { + if (!Number.isFinite(value)) throw new RangeError('3MF coordinates must be finite.') + const rounded = Math.abs(value) < 5e-10 ? 0 : value + return rounded.toFixed(9).replace(/\.?0+$/, '') +} + +type PlacedPrint3mfPart = { + part: Print3mfPart + translateX: number + translateY: number + vertexStart: number + triangleStart: number +} + +function appendMeshObject(lines: string[], name: string, parts: PlacedPrint3mfPart[]) { + lines.push(` `) + lines.push(' ') + lines.push(' ') + for (const { part, translateX, translateY } of parts) { + for (let offset = 0; offset < part.mesh.positions.length; offset += 3) { + lines.push( + ` `, + ) + } + } + lines.push(' ') + lines.push(' ') + for (const { part, vertexStart } of parts) { + for (let offset = 0; offset < part.mesh.indices.length; offset += 3) { + lines.push( + ` `, + ) + } + } + lines.push(' ') + lines.push(' ') + lines.push(' ') +} + +export function createPrint3mf( + parts: Print3mfPart[], + title = 'Pascal print export', +): Uint8Array { + const placements: PlacedPrint3mfPart[] = [] + let cursorX = 0 + let vertexStart = 0 + let triangleStart = 0 + for (const part of parts) { + placements.push({ + part, + translateX: cursorX - part.bounds.min.x, + translateY: -part.bounds.min.y, + vertexStart, + triangleStart, + }) + cursorX += part.bounds.width + PART_GAP_MM + vertexStart += part.mesh.positions.length / 3 + triangleStart += part.mesh.indices.length / 3 + } + const partManifest = placements.map(({ part, vertexStart, triangleStart }) => ({ + name: part.name, + vertexStart, + vertexCount: part.mesh.positions.length / 3, + triangleStart, + triangleCount: part.mesh.indices.length / 3, + })) + const lines = [ + '', + '', + ` ${escapeXml(title)}`, + ' Pascal', + ` ${escapeXml(JSON.stringify(partManifest))}`, + ' ', + ] + + // CHITUBOX 1.3.0 recenters independent 3MF objects, so package the plate as one mesh. + if (placements.length > 0) { + appendMeshObject(lines, parts.length === 1 ? parts[0]!.name : title, placements) + } + lines.push(' ') + lines.push(' ') + + if (parts.length > 0) lines.push(' ') + lines.push(' ') + lines.push('') + lines.push('') + + const files: Zippable = { + '[Content_Types].xml': [strToU8(CONTENT_TYPES), { level: 0, mtime: ZIP_MTIME }], + '_rels/.rels': [strToU8(ROOT_RELATIONSHIPS), { level: 0, mtime: ZIP_MTIME }], + '3D/3dmodel.model': [strToU8(lines.join('\n')), { level: 0, mtime: ZIP_MTIME }], + } + return zipSync(files, { level: 0 }) +} + +export function exportSceneToPrint3mf( + source: THREE.Object3D, + options: PrintExportOptions, +): Print3mfExport { + const prepared = prepareSceneForPrint(source, { ...options, format: '3mf' }) + const parts = + prepared.report.bounds && prepared.report.invalidTriangleCount === 0 + ? [ + { + name: 'Pascal print model', + mesh: extractPreparedPrintMesh(prepared.scene), + bounds: prepared.report.bounds, + }, + ] + : [] + return { + buffer: createPrint3mf(parts), + report: prepared.report, + } +} diff --git a/packages/editor/src/lib/print-content-scope.test.ts b/packages/editor/src/lib/print-content-scope.test.ts new file mode 100644 index 0000000000..c35a022f4e --- /dev/null +++ b/packages/editor/src/lib/print-content-scope.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, registerNode, sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { exportSceneToPrintStl } from './print-export' + +function registerFixtureKind(category: 'site' | 'structure' | 'furnish'): string { + const kind = `print-${category}-${crypto.randomUUID()}` + registerNode({ + kind, + schemaVersion: 1, + category, + defaults: () => ({}), + capabilities: {}, + } as never) + return kind +} + +function printContentFixture() { + const root = new THREE.Group() + const building = new THREE.Group() + const level = new THREE.Group() + const wall = new THREE.Group() + const furniture = new THREE.Group() + wall.add(new THREE.Mesh(new THREE.BoxGeometry(4, 3, 2))) + furniture.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) + root.add(building) + building.add(level) + level.add(wall, furniture) + + const buildingKind = registerFixtureKind('site') + const levelKind = registerFixtureKind('site') + const wallKind = registerFixtureKind('structure') + const furnitureKind = registerFixtureKind('furnish') + + sceneRegistry.nodes.set('building_main', building) + sceneRegistry.nodes.set('level_ground', level) + sceneRegistry.nodes.set('wall_main', wall) + sceneRegistry.nodes.set('chair_main', furniture) + + const nodes: Record = { + building_main: { + object: 'node', + id: 'building_main', + type: buildingKind, + parentId: null, + children: ['level_ground'], + } as unknown as AnyNode, + level_ground: { + object: 'node', + id: 'level_ground', + type: levelKind, + parentId: 'building_main', + children: ['wall_main', 'chair_main'], + } as unknown as AnyNode, + wall_main: { + object: 'node', + id: 'wall_main', + type: wallKind, + parentId: 'level_ground', + } as unknown as AnyNode, + chair_main: { + object: 'node', + id: 'chair_main', + type: furnitureKind, + parentId: 'level_ground', + } as unknown as AnyNode, + } + + return { root, nodes } +} + +describe('print content scope', () => { + afterEach(() => { + sceneRegistry.nodes.clear() + }) + + test('keeps registered structure and its transform ancestors while removing visible furniture', () => { + const fixture = printContentFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + + const structure = filterPreparedSceneForPrintContent(prepared.scene, fixture.nodes, 'structure') + const print = exportSceneToPrintStl(structure, { scale: 100 }) + + expect(structure.getObjectByName('building_main')).toBeDefined() + expect(structure.getObjectByName('level_ground')).toBeDefined() + expect(structure.getObjectByName('wall_main')).toBeDefined() + expect(structure.getObjectByName('chair_main')).toBeUndefined() + expect(print.report.triangleCount).toBe(12) + }) + + test('preserves all prepared semantic geometry in everything scope', () => { + const fixture = printContentFixture() + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + + const everything = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'everything', + ) + const print = exportSceneToPrintStl(everything, { scale: 100 }) + + expect(everything.getObjectByName('chair_main')).toBeDefined() + expect(print.report.triangleCount).toBe(24) + }) +}) diff --git a/packages/editor/src/lib/print-content-scope.ts b/packages/editor/src/lib/print-content-scope.ts new file mode 100644 index 0000000000..2863a677a0 --- /dev/null +++ b/packages/editor/src/lib/print-content-scope.ts @@ -0,0 +1,90 @@ +import { type AnyNode, nodeRegistry } from '@pascal-app/core' +import * as THREE from 'three' + +export type PrintContentScope = 'structure' | 'everything' + +const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry() +EMPTY_POSITION_GEOMETRY.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(0), 3), +) + +function identityId(object: THREE.Object3D): string | null { + const id = object.userData.pascalId + return typeof id === 'string' ? id : null +} + +function structureIds(nodes: Record): Set { + return new Set( + Object.values(nodes) + .filter((node) => nodeRegistry.get(node.type)?.category === 'structure') + .map((node) => node.id), + ) +} + +function retainedIds( + includedIds: ReadonlySet, + nodes: Record, +): Set { + const retained = new Set(includedIds) + for (const id of includedIds) { + const visited = new Set([id]) + let parentId = nodes[id]?.parentId ?? null + while (parentId && !visited.has(parentId)) { + retained.add(parentId) + visited.add(parentId) + parentId = nodes[parentId]?.parentId ?? null + } + } + return retained +} + +function neutralizeRenderable(object: THREE.Object3D) { + const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean } + if (renderable.isMesh || renderable.isLine || renderable.isPoints) { + renderable.geometry = EMPTY_POSITION_GEOMETRY + } +} + +/** + * Keep registered structural nodes plus the minimum semantic/Three ancestry + * needed to preserve their world transforms. Unknown, furnishing, analysis, + * utility, and site-owned geometry is removed unless it is only a transform + * container leading to retained structure. + */ +export function filterPreparedSceneForPrintContent( + source: THREE.Object3D, + nodes: Record, + scope: PrintContentScope, +): THREE.Object3D { + const scene = source.clone(true) + if (scope === 'everything') return scene + + const includedIds = structureIds(nodes) + const retained = retainedIds(includedIds, nodes) + + const prune = (object: THREE.Object3D, inheritedStructure: boolean, isRoot = false): boolean => { + const id = identityId(object) + let carriesStructure = inheritedStructure + + if (id) { + if (includedIds.has(id)) carriesStructure = true + else if (retained.has(id)) carriesStructure = false + else return false + } + + for (const child of [...object.children]) { + if (!prune(child, carriesStructure)) child.removeFromParent() + } + + if (carriesStructure || isRoot) return true + if (object.children.length === 0) return false + + neutralizeRenderable(object) + return true + } + + prune(scene, false, true) + scene.name = 'print-content-structure' + return scene +} diff --git a/packages/editor/src/lib/print-export.test.ts b/packages/editor/src/lib/print-export.test.ts new file mode 100644 index 0000000000..9c61d22df9 --- /dev/null +++ b/packages/editor/src/lib/print-export.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { type AnyNode, sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { + exportSceneToPrintStl, + mergePrintExportDiagnostics, + prepareSceneForPrint, +} from './print-export' + +function binaryStlBounds(buffer: ArrayBuffer): { triangles: number; bounds: THREE.Box3 } { + const view = new DataView(buffer) + const triangles = view.getUint32(80, true) + const bounds = new THREE.Box3() + const point = new THREE.Vector3() + let offset = 84 + + for (let triangle = 0; triangle < triangles; triangle += 1) { + offset += 12 + for (let vertex = 0; vertex < 3; vertex += 1) { + point.set( + view.getFloat32(offset, true), + view.getFloat32(offset + 4, true), + view.getFloat32(offset + 8, true), + ) + bounds.expandByPoint(point) + offset += 12 + } + offset += 2 + } + + return { triangles, bounds } +} + +function reverseWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry { + const index = geometry.getIndex() + if (!index) throw new Error('Expected indexed geometry') + const reversed: number[] = [] + for (let offset = 0; offset + 2 < index.count; offset += 3) { + reversed.push(index.getX(offset), index.getX(offset + 2), index.getX(offset + 1)) + } + geometry.setIndex(reversed) + return geometry +} + +describe('print STL export', () => { + afterEach(() => { + sceneRegistry.nodes.clear() + }) + + test('writes millimeter-scaled Z-up geometry centered on the print bed', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(10, 4, 6)) + mesh.position.set(5, 2, -7) + + const { buffer, report } = exportSceneToPrintStl(mesh, { scale: 100 }) + const parsed = binaryStlBounds(buffer) + const size = parsed.bounds.getSize(new THREE.Vector3()) + + expect(parsed.triangles).toBe(12) + expect(size.x).toBeCloseTo(100, 4) + expect(size.y).toBeCloseTo(60, 4) + expect(size.z).toBeCloseTo(40, 4) + expect(parsed.bounds.min.x).toBeCloseTo(-50, 4) + expect(parsed.bounds.max.x).toBeCloseTo(50, 4) + expect(parsed.bounds.min.y).toBeCloseTo(-30, 4) + expect(parsed.bounds.max.y).toBeCloseTo(30, 4) + expect(parsed.bounds.min.z).toBeCloseTo(0, 4) + expect(parsed.bounds.max.z).toBeCloseTo(40, 4) + + expect(report.status).toBe('pass') + expect(report.bounds?.width).toBeCloseTo(100, 4) + expect(report.bounds?.depth).toBeCloseTo(60, 4) + expect(report.bounds?.height).toBeCloseTo(40, 4) + expect(report.boundaryEdgeCount).toBe(0) + expect(report.nonManifoldEdgeCount).toBe(0) + expect(report.connectedComponentCount).toBe(1) + expect(report.solidComponentCount).toBe(1) + expect(report.invertedWinding).toBe(false) + expect(report.volumeMm3).toBeCloseTo(240_000, 4) + }) + + test('blocks open, zero-volume surface geometry before download', () => { + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 3)) + + const { report } = prepareSceneForPrint(mesh, { scale: 50 }) + + expect(report.status).toBe('blocked') + expect(report.boundaryEdgeCount).toBe(4) + expect(report.volumeMm3).toBeCloseTo(0) + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining(['open_boundaries', 'zero_volume', 'compiler_pending']), + ) + expect( + report.diagnostics + .filter( + (diagnostic) => + diagnostic.code === 'open_boundaries' || diagnostic.code === 'zero_volume', + ) + .map((diagnostic) => diagnostic.severity), + ).toEqual(['error', 'error']) + }) + + test('blocks degenerate triangles', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 2, 0, 0], 3), + ) + + const { report } = prepareSceneForPrint(new THREE.Mesh(geometry), { scale: 100 }) + + expect(report.status).toBe('blocked') + expect(report.degenerateTriangleCount).toBe(1) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'degenerate_triangles', severity: 'error' }), + ) + }) + + test('blocks an edge shared by more than two triangles', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute( + [0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + 3, + ), + ) + + const { report } = prepareSceneForPrint(new THREE.Mesh(geometry), { scale: 100 }) + + expect(report.status).toBe('blocked') + expect(report.nonManifoldEdgeCount).toBe(1) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'non_manifold_edges', severity: 'error' }), + ) + }) + + test('does not conflate distinct closed edges inside the boundary matching tolerance', () => { + const source = new THREE.Group() + const first = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + const second = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + second.position.x = 1.000002 + source.add(first, second) + + const { report } = prepareSceneForPrint(source, { scale: 100 }) + + expect(report.status).toBe('warning') + expect(report.boundaryEdgeCount).toBe(0) + expect(report.nonManifoldEdgeCount).toBe(0) + expect(report.connectedComponentCount).toBe(2) + expect(report.solidComponentCount).toBe(2) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'disconnected_solids', severity: 'warning' }), + ) + }) + + test('blocks disconnected solids in a compiled printable part', () => { + const source = new THREE.Group() + const first = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + const second = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + second.position.x = 2 + source.add(first, second) + + const { report } = prepareSceneForPrint(source, { scale: 100, compiled: true }) + + expect(report.status).toBe('blocked') + expect(report.connectedComponentCount).toBe(2) + expect(report.solidComponentCount).toBe(2) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'disconnected_solids', severity: 'error' }), + ) + }) + + test('allows an inward shell to represent a sealed cavity without calling it a second solid', () => { + const source = new THREE.Group() + source.add( + new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2)), + new THREE.Mesh(reverseWinding(new THREE.BoxGeometry(1, 1, 1))), + ) + + const { report } = prepareSceneForPrint(source, { scale: 100, compiled: true }) + + expect(report.status).toBe('warning') + expect(report.connectedComponentCount).toBe(2) + expect(report.solidComponentCount).toBe(1) + expect(report.invertedWinding).toBe(false) + expect(report.volumeMm3).toBeCloseTo(7_000, 4) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'inward_surface_components', severity: 'warning' }), + ) + }) + + test('blocks a globally inside-out closed surface', () => { + const mesh = new THREE.Mesh(reverseWinding(new THREE.BoxGeometry(1, 1, 1))) + + const { report } = prepareSceneForPrint(mesh, { scale: 100, compiled: true }) + + expect(report.status).toBe('blocked') + expect(report.connectedComponentCount).toBe(1) + expect(report.solidComponentCount).toBe(0) + expect(report.invertedWinding).toBe(true) + expect(report.diagnostics).toContainEqual( + expect.objectContaining({ code: 'inverted_winding', severity: 'error' }), + ) + }) + + test('uses authoritative indexed incidence for a compiled mesh', () => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 1], 3), + ) + geometry.setIndex([0, 1, 2, 1, 0, 3, 0, 1, 4]) + + const { report } = prepareSceneForPrint(new THREE.Mesh(geometry), { + scale: 100, + indexedTopology: true, + }) + + expect(report.status).toBe('blocked') + expect(report.nonManifoldEdgeCount).toBe(1) + }) + + test('merges located compiler diagnostics into a compiled preflight report', () => { + const { report } = prepareSceneForPrint(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)), { + scale: 100, + compiled: true, + }) + const merged = mergePrintExportDiagnostics(report, [ + { + severity: 'error', + code: 'unsupported_roof_print_trim', + message: 'The roof trim has no manifold fixture.', + nodeIds: ['rseg_print-trimmed'], + }, + ]) + + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toContain('compiler_limits') + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain( + 'compiler_pending', + ) + expect(merged.status).toBe('blocked') + expect(merged.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_roof_print_trim', + nodeIds: ['rseg_print-trimmed'], + }), + ) + }) + + test('omits semantically hidden meshes from the parsed print artifact', () => { + const root = new THREE.Group() + const visibleGroup = new THREE.Group() + const hiddenGroup = new THREE.Group() + visibleGroup.add(new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2))) + hiddenGroup.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) + root.add(visibleGroup, hiddenGroup) + + const visibleId = 'visible-structure' + const hiddenId = 'hidden-furniture' + sceneRegistry.nodes.set(visibleId, visibleGroup) + sceneRegistry.nodes.set(hiddenId, hiddenGroup) + const nodes = { + [visibleId]: { + object: 'node', + id: visibleId, + type: 'wall', + parentId: null, + visible: true, + } as unknown as AnyNode, + [hiddenId]: { + object: 'node', + id: hiddenId, + type: 'item', + parentId: null, + visible: false, + } as unknown as AnyNode, + } + + const prepared = prepareSceneForExport(root, nodes) + const print = exportSceneToPrintStl(prepared.scene, { scale: 100 }) + + expect(binaryStlBounds(print.buffer).triangles).toBe(12) + expect(print.report.triangleCount).toBe(12) + }) + + test('rejects an invalid architectural scale', () => { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1)) + + expect(() => prepareSceneForPrint(mesh, { scale: 0 })).toThrow( + 'Print scale must be a positive finite denominator', + ) + }) +}) diff --git a/packages/editor/src/lib/print-export.ts b/packages/editor/src/lib/print-export.ts new file mode 100644 index 0000000000..9b24014357 --- /dev/null +++ b/packages/editor/src/lib/print-export.ts @@ -0,0 +1,735 @@ +import * as THREE from 'three' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js' +import { HalfEdgeMap } from 'three-bvh-csg' + +const MILLIMETERS_PER_METER = 1000 +const EDGE_CONNECTIVITY_EPSILON_METERS = 1e-5 +const EDGE_INCIDENCE_EPSILON_METERS = 1e-7 +const MAX_EDGE_CHECK_TRIANGLES = 500_000 +const DEGENERATE_CROSS_LENGTH_SQ = 1e-12 + +const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry() +EMPTY_POSITION_GEOMETRY.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(0), 3), +) + +export type PrintExportDiagnostic = { + severity: 'error' | 'warning' | 'info' + code: string + message: string + nodeIds?: string[] +} + +export type PrintArtifactFormat = 'stl' | '3mf' + +export type PrintExportOptions = { + scale: number + compiled?: boolean + indexedTopology?: boolean + format?: PrintArtifactFormat + /** Original Y-up world elevation that becomes print Z=0. Omit to use geometry minimum. */ + sourceBedElevationMeters?: number +} + +export type PrintExportBounds = { + min: { x: number; y: number; z: number } + max: { x: number; y: number; z: number } + width: number + depth: number + height: number +} + +export type PrintExportReport = { + kind: 'print-export-report' + version: 2 + format: PrintArtifactFormat + scale: number + units: 'millimeter' + orientation: 'z-up' + status: 'pass' | 'warning' | 'blocked' + bounds: PrintExportBounds | null + triangleCount: number + invalidTriangleCount: number + degenerateTriangleCount: number + boundaryEdgeCount: number | null + nonManifoldEdgeCount: number | null + connectedComponentCount: number | null + solidComponentCount: number | null + invertedWinding: boolean | null + volumeMm3: number + minimumFeatureThicknessMm?: number | null + diagnostics: PrintExportDiagnostic[] +} + +export type PrintStlExport = { + buffer: ArrayBuffer + report: PrintExportReport +} + +export type PrintMeshData = { + positions: Float64Array + indices: Uint32Array +} + +type BoundsMeasurement = { + min: THREE.Vector3 + max: THREE.Vector3 +} | null + +type EdgeTopologyMeasurement = { + boundaryEdgeCount: number | null + nonManifoldEdgeCount: number | null + connectedComponentCount: number | null + componentIndexByTriangle: Int32Array | null + edgeCheckComplete: boolean +} + +function ensureMeshPositions(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh && !mesh.geometry?.getAttribute('position')) { + mesh.geometry = EMPTY_POSITION_GEOMETRY + } + }) +} + +function isFiniteVector(vector: THREE.Vector3): boolean { + return Number.isFinite(vector.x) && Number.isFinite(vector.y) && Number.isFinite(vector.z) +} + +function forEachTriangle( + root: THREE.Object3D, + visit: (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3) => void, +) { + root.updateMatrixWorld(true) + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + + const position = mesh.geometry.getAttribute('position') + if (!position) return + const index = mesh.geometry.index + const skinnedMesh = mesh as THREE.SkinnedMesh + + const readVertex = (vertexIndex: number, target: THREE.Vector3) => { + target.fromBufferAttribute(position, vertexIndex) + if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, target) + target.applyMatrix4(mesh.matrixWorld) + } + + const visitIndices = (indexA: number, indexB: number, indexC: number) => { + readVertex(indexA, a) + readVertex(indexB, b) + readVertex(indexC, c) + visit(a, b, c) + } + + if (index) { + for (let offset = 0; offset + 2 < index.count; offset += 3) { + visitIndices(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2)) + } + return + } + + for (let offset = 0; offset + 2 < position.count; offset += 3) { + visitIndices(offset, offset + 1, offset + 2) + } + }) +} + +function measureBounds(root: THREE.Object3D): BoundsMeasurement { + const min = new THREE.Vector3( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + const max = new THREE.Vector3( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ) + let hasFiniteTriangle = false + + forEachTriangle(root, (a, b, c) => { + if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return + min.min(a).min(b).min(c) + max.max(a).max(b).max(c) + hasFiniteTriangle = true + }) + + return hasFiniteTriangle ? { min, max } : null +} + +function pointKey(point: THREE.Vector3): string { + return `${Math.round(point.x / EDGE_INCIDENCE_EPSILON_METERS)},${Math.round( + point.y / EDGE_INCIDENCE_EPSILON_METERS, + )},${Math.round(point.z / EDGE_INCIDENCE_EPSILON_METERS)}` +} + +function edgeKey(a: THREE.Vector3, b: THREE.Vector3): string { + const keyA = pointKey(a) + const keyB = pointKey(b) + return keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}` +} + +function indexedNonManifoldEdgeCount(root: THREE.Object3D): number | null { + let hasGeometry = false + let nonManifoldEdgeCount = 0 + for (const object of root.children) object.updateMatrixWorld(true) + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const position = mesh.geometry.getAttribute('position') + if (!position || position.count === 0) return + const index = mesh.geometry.getIndex() + if (!index) { + nonManifoldEdgeCount = Number.NaN + return + } + hasGeometry = true + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index.count; offset += 3) { + const a = index.getX(offset) + const b = index.getX(offset + 1) + const c = index.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + for (const uses of edges.values()) { + if (uses > 2) nonManifoldEdgeCount += 1 + } + }) + + return hasGeometry && Number.isFinite(nonManifoldEdgeCount) ? nonManifoldEdgeCount : null +} + +function analyzeEdgeTopology( + root: THREE.Object3D, + useIndexedIncidence: boolean, +): EdgeTopologyMeasurement { + const edges = new Map() + const halfEdgePositions: number[] = [] + const topologyTriangleIndices: number[] = [] + const componentParents: number[] = [] + const componentRanks: number[] = [] + let edgeCheckComplete = true + let triangleCount = 0 + + const findComponent = (triangleIndex: number): number => { + let root = triangleIndex + while (componentParents[root] !== root) root = componentParents[root]! + let current = triangleIndex + while (componentParents[current] !== root) { + const parent = componentParents[current]! + componentParents[current] = root + current = parent + } + return root + } + + const unionComponents = (first: number, second: number) => { + let firstRoot = findComponent(first) + let secondRoot = findComponent(second) + if (firstRoot === secondRoot) return + const firstRank = componentRanks[firstRoot] ?? 0 + const secondRank = componentRanks[secondRoot] ?? 0 + if (firstRank < secondRank) [firstRoot, secondRoot] = [secondRoot, firstRoot] + componentParents[secondRoot] = firstRoot + if (firstRank === secondRank) componentRanks[firstRoot] = firstRank + 1 + } + + const addConnectedEdge = (a: THREE.Vector3, b: THREE.Vector3, triangleIndex: number) => { + const key = edgeKey(a, b) + const encoded = edges.get(key) + if (encoded === undefined) { + edges.set(key, triangleIndex * 4 + 1) + return + } + const firstTriangle = Math.floor(encoded / 4) + const uses = encoded % 4 + unionComponents(firstTriangle, triangleIndex) + edges.set(key, firstTriangle * 4 + Math.min(uses + 1, 3)) + } + + forEachTriangle(root, (a, b, c) => { + const triangleIndex = triangleCount + triangleCount += 1 + if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) return + if (edgeCheckComplete && triangleCount > MAX_EDGE_CHECK_TRIANGLES) { + edges.clear() + halfEdgePositions.length = 0 + topologyTriangleIndices.length = 0 + componentParents.length = 0 + componentRanks.length = 0 + edgeCheckComplete = false + } + if (!edgeCheckComplete) return + + componentParents[triangleIndex] = triangleIndex + componentRanks[triangleIndex] = 0 + addConnectedEdge(a, b, triangleIndex) + addConnectedEdge(b, c, triangleIndex) + addConnectedEdge(c, a, triangleIndex) + topologyTriangleIndices.push(triangleIndex) + halfEdgePositions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z) + }) + + if (!edgeCheckComplete) { + return { + boundaryEdgeCount: null, + nonManifoldEdgeCount: null, + connectedComponentCount: null, + componentIndexByTriangle: null, + edgeCheckComplete, + } + } + + const connectivityGeometry = new THREE.BufferGeometry() + connectivityGeometry.setAttribute( + 'position', + new THREE.BufferAttribute(new Float64Array(halfEdgePositions), 3), + ) + const halfEdges = new HalfEdgeMap() as HalfEdgeMap & { + data: Int32Array + disjointConnections: Map | null + matchDisjointEdges: boolean + degenerateEpsilon: number + unmatchedEdges: number + } + halfEdges.matchDisjointEdges = true + halfEdges.degenerateEpsilon = EDGE_CONNECTIVITY_EPSILON_METERS + halfEdges.updateFrom(connectivityGeometry) + const boundaryEdgeCount = halfEdges.unmatchedEdges + for (let localEdgeIndex = 0; localEdgeIndex < halfEdges.data.length; localEdgeIndex += 1) { + const triangleIndex = topologyTriangleIndices[Math.floor(localEdgeIndex / 3)] + if (triangleIndex === undefined) continue + const siblingEdgeIndex = halfEdges.data[localEdgeIndex] ?? -1 + if (siblingEdgeIndex >= 0) { + const siblingTriangleIndex = topologyTriangleIndices[Math.floor(siblingEdgeIndex / 3)] + if (siblingTriangleIndex !== undefined) unionComponents(triangleIndex, siblingTriangleIndex) + } + for (const disjointEdgeIndex of halfEdges.disjointConnections?.get(localEdgeIndex) ?? []) { + const disjointTriangleIndex = topologyTriangleIndices[Math.floor(disjointEdgeIndex / 3)] + if (disjointTriangleIndex !== undefined) unionComponents(triangleIndex, disjointTriangleIndex) + } + } + connectivityGeometry.dispose() + + const componentIndexByTriangle = new Int32Array(triangleCount) + componentIndexByTriangle.fill(-1) + const componentIndexByRoot = new Map() + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + if (componentParents[triangleIndex] === undefined) continue + const root = findComponent(triangleIndex) + let componentIndex = componentIndexByRoot.get(root) + if (componentIndex === undefined) { + componentIndex = componentIndexByRoot.size + componentIndexByRoot.set(root, componentIndex) + } + componentIndexByTriangle[triangleIndex] = componentIndex + } + + let nonManifoldEdgeCount = useIndexedIncidence ? indexedNonManifoldEdgeCount(root) : null + if (nonManifoldEdgeCount === null) { + nonManifoldEdgeCount = 0 + for (const encoded of edges.values()) { + if (encoded % 4 > 2) nonManifoldEdgeCount += 1 + } + } + + return { + boundaryEdgeCount, + nonManifoldEdgeCount, + connectedComponentCount: componentIndexByRoot.size, + componentIndexByTriangle, + edgeCheckComplete, + } +} + +function analyzePrintScene( + root: THREE.Object3D, + scale: number, + edgeTopology: EdgeTopologyMeasurement, + compiled: boolean, + format: PrintArtifactFormat, +): PrintExportReport { + const min = new THREE.Vector3( + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + const max = new THREE.Vector3( + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ) + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const areaCross = new THREE.Vector3() + const volumeCross = new THREE.Vector3() + let triangleCount = 0 + let invalidTriangleCount = 0 + let degenerateTriangleCount = 0 + let signedVolumeMm3 = 0 + let hasFiniteTriangle = false + const componentSignedVolumesMm3 = + edgeTopology.connectedComponentCount === null + ? null + : Array.from({ length: edgeTopology.connectedComponentCount }, () => 0) + + forEachTriangle(root, (a, b, c) => { + const triangleIndex = triangleCount + triangleCount += 1 + if (!isFiniteVector(a) || !isFiniteVector(b) || !isFiniteVector(c)) { + invalidTriangleCount += 1 + return + } + + min.min(a).min(b).min(c) + max.max(a).max(b).max(c) + hasFiniteTriangle = true + + ab.subVectors(b, a) + ac.subVectors(c, a) + areaCross.crossVectors(ab, ac) + if (areaCross.lengthSq() <= DEGENERATE_CROSS_LENGTH_SQ) { + degenerateTriangleCount += 1 + } + + volumeCross.crossVectors(b, c) + const triangleVolumeMm3 = a.dot(volumeCross) / 6 + signedVolumeMm3 += triangleVolumeMm3 + const componentIndex = edgeTopology.componentIndexByTriangle?.[triangleIndex] ?? -1 + if (componentSignedVolumesMm3 && componentIndex >= 0) { + componentSignedVolumesMm3[componentIndex] = + (componentSignedVolumesMm3[componentIndex] ?? 0) + triangleVolumeMm3 + } + }) + + const { boundaryEdgeCount, nonManifoldEdgeCount, connectedComponentCount, edgeCheckComplete } = + edgeTopology + const hasClosedTopology = + edgeCheckComplete && + boundaryEdgeCount === 0 && + nonManifoldEdgeCount === 0 && + invalidTriangleCount === 0 && + degenerateTriangleCount === 0 + const solidComponentCount = + hasClosedTopology && componentSignedVolumesMm3 + ? componentSignedVolumesMm3.filter((volume) => volume > 1e-6).length + : null + const inwardComponentCount = + hasClosedTopology && componentSignedVolumesMm3 + ? componentSignedVolumesMm3.filter((volume) => volume < -1e-6).length + : null + const invertedWinding = hasClosedTopology && triangleCount > 0 ? signedVolumeMm3 < -1e-6 : null + + const bounds = hasFiniteTriangle + ? { + min: { x: min.x, y: min.y, z: min.z }, + max: { x: max.x, y: max.y, z: max.z }, + width: max.x - min.x, + depth: max.y - min.y, + height: max.z - min.z, + } + : null + + const diagnostics: PrintExportDiagnostic[] = [] + if (triangleCount === 0) { + diagnostics.push({ + severity: 'error', + code: 'no_triangles', + message: 'No printable triangles remain after applying the export scope.', + }) + } + if (invalidTriangleCount > 0) { + diagnostics.push({ + severity: 'error', + code: 'non_finite_geometry', + message: `${invalidTriangleCount.toLocaleString()} triangle${ + invalidTriangleCount === 1 ? '' : 's' + } contain non-finite coordinates.`, + }) + } + if (degenerateTriangleCount > 0) { + diagnostics.push({ + severity: 'error', + code: 'degenerate_triangles', + message: `${degenerateTriangleCount.toLocaleString()} zero-area or near-zero-area triangle${ + degenerateTriangleCount === 1 ? '' : 's' + } prevent a print-ready artifact.`, + }) + } + if (boundaryEdgeCount && boundaryEdgeCount > 0) { + diagnostics.push({ + severity: 'error', + code: 'open_boundaries', + message: `${boundaryEdgeCount.toLocaleString()} boundary edge${ + boundaryEdgeCount === 1 ? '' : 's' + } leave the exported surface open.`, + }) + } + if (nonManifoldEdgeCount && nonManifoldEdgeCount > 0) { + diagnostics.push({ + severity: 'error', + code: 'non_manifold_edges', + message: `${nonManifoldEdgeCount.toLocaleString()} edge${ + nonManifoldEdgeCount === 1 ? '' : 's' + } are shared by more than two triangles and must be repaired.`, + }) + } + if (!edgeCheckComplete) { + diagnostics.push({ + severity: 'warning', + code: 'edge_check_skipped', + message: `Edge and connected-component checks were skipped above ${MAX_EDGE_CHECK_TRIANGLES.toLocaleString()} triangles.`, + }) + } + if (solidComponentCount !== null && solidComponentCount > 1) { + diagnostics.push({ + severity: compiled ? 'error' : 'warning', + code: 'disconnected_solids', + message: `${solidComponentCount.toLocaleString()} disconnected outward solid components remain in this ${compiled ? 'compiled part' : 'export'}. ${ + compiled + ? 'Each printable level must be one physically connected solid.' + : 'Use structure compilation or split them into separate printable parts.' + }`, + }) + } else if ( + connectedComponentCount !== null && + connectedComponentCount > 1 && + inwardComponentCount !== null && + inwardComponentCount > 0 + ) { + diagnostics.push({ + severity: 'warning', + code: 'inward_surface_components', + message: `${connectedComponentCount.toLocaleString()} connected surface shells include ${inwardComponentCount.toLocaleString()} inward-oriented shell${ + inwardComponentCount === 1 ? '' : 's' + }. These may be sealed cavities; inspect the sliced layers before printing.`, + }) + } + if (invertedWinding) { + diagnostics.push({ + severity: 'error', + code: 'inverted_winding', + message: 'The closed surface has globally inverted face winding and must be reoriented.', + }) + } + if (triangleCount > 0 && Math.abs(signedVolumeMm3) <= 1e-6) { + diagnostics.push({ + severity: 'error', + code: 'zero_volume', + message: 'The exported surfaces enclose no measurable signed volume.', + }) + } + diagnostics.push( + compiled + ? { + severity: 'info', + code: 'compiler_limits', + message: + 'The shell was boolean-unioned, but self-intersections and minimum wall thickness are not checked yet.', + } + : { + severity: 'info', + code: 'compiler_pending', + message: + 'Boolean union, shell intersections, and minimum wall thickness are not checked yet.', + }, + ) + + const status = diagnostics.some((diagnostic) => diagnostic.severity === 'error') + ? 'blocked' + : diagnostics.some((diagnostic) => diagnostic.severity === 'warning') + ? 'warning' + : 'pass' + + return { + kind: 'print-export-report', + version: 2, + format, + scale, + units: 'millimeter', + orientation: 'z-up', + status, + bounds, + triangleCount, + invalidTriangleCount, + degenerateTriangleCount, + boundaryEdgeCount, + nonManifoldEdgeCount, + connectedComponentCount, + solidComponentCount, + invertedWinding, + volumeMm3: Math.abs(signedVolumeMm3), + diagnostics, + } +} + +export function prepareSceneForPrint( + source: THREE.Object3D, + options: PrintExportOptions, +): { scene: THREE.Object3D; report: PrintExportReport } { + if (!Number.isFinite(options.scale) || options.scale <= 0) { + throw new RangeError('Print scale must be a positive finite denominator') + } + if ( + options.sourceBedElevationMeters !== undefined && + !Number.isFinite(options.sourceBedElevationMeters) + ) { + throw new RangeError('Print bed elevation must be finite') + } + + ensureMeshPositions(source) + + const physicalScale = MILLIMETERS_PER_METER / options.scale + // Connectivity is invariant under print scale and orientation. Checking it + // in model-space meters avoids scale-dependent ray tolerances and π/2 drift. + const edgeTopology = analyzeEdgeTopology(source, options.indexedTopology ?? false) + + const scene = new THREE.Group() + scene.name = 'print-export' + scene.add(source) + scene.rotation.x = Math.PI / 2 + scene.scale.setScalar(physicalScale) + scene.updateMatrixWorld(true) + + const initialBounds = measureBounds(scene) + if (initialBounds) { + const bedElevation = + options.sourceBedElevationMeters === undefined + ? initialBounds.min.z + : options.sourceBedElevationMeters * physicalScale + scene.position.set( + -(initialBounds.min.x + initialBounds.max.x) / 2, + -(initialBounds.min.y + initialBounds.max.y) / 2, + -bedElevation, + ) + scene.updateMatrixWorld(true) + } + + return { + scene, + report: analyzePrintScene( + scene, + options.scale, + edgeTopology, + options.compiled ?? false, + options.format ?? 'stl', + ), + } +} + +export function extractPreparedPrintMesh(root: THREE.Object3D): PrintMeshData { + root.updateMatrixWorld(true) + + const positions: number[] = [] + const indices: number[] = [] + const point = new THREE.Vector3() + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + + const position = mesh.geometry.getAttribute('position') + if (!position) return + const index = mesh.geometry.getIndex() + const skinnedMesh = mesh as THREE.SkinnedMesh + const outputIndexByPosition = new Map() + + const outputIndexFor = (vertexIndex: number): number => { + point.fromBufferAttribute(position, vertexIndex) + if (skinnedMesh.isSkinnedMesh) skinnedMesh.applyBoneTransform(vertexIndex, point) + point.applyMatrix4(mesh.matrixWorld) + if (!isFiniteVector(point)) { + throw new RangeError( + 'Print geometry contains non-finite coordinates and cannot be encoded.', + ) + } + + const x = Object.is(point.x, -0) ? 0 : point.x + const y = Object.is(point.y, -0) ? 0 : point.y + const z = Object.is(point.z, -0) ? 0 : point.z + const key = `${x},${y},${z}` + const existing = outputIndexByPosition.get(key) + if (existing !== undefined) return existing + + const next = positions.length / 3 + positions.push(x, y, z) + outputIndexByPosition.set(key, next) + return next + } + + const appendTriangle = (a: number, b: number, c: number) => { + indices.push(outputIndexFor(a), outputIndexFor(b), outputIndexFor(c)) + } + + if (index) { + for (let offset = 0; offset + 2 < index.count; offset += 3) { + appendTriangle(index.getX(offset), index.getX(offset + 1), index.getX(offset + 2)) + } + return + } + + for (let offset = 0; offset + 2 < position.count; offset += 3) { + appendTriangle(offset, offset + 1, offset + 2) + } + }) + + return { + positions: new Float64Array(positions), + indices: new Uint32Array(indices), + } +} + +export function encodePreparedPrintSceneToStl(scene: THREE.Object3D): ArrayBuffer { + const exporter = new STLExporter() + const output = exporter.parse(scene, { binary: true }) as ArrayBuffer | DataView + return output instanceof DataView + ? (output.buffer.slice(output.byteOffset, output.byteOffset + output.byteLength) as ArrayBuffer) + : output +} + +export function exportSceneToPrintStl( + source: THREE.Object3D, + options: PrintExportOptions, +): PrintStlExport { + const { scene, report } = prepareSceneForPrint(source, options) + return { buffer: encodePreparedPrintSceneToStl(scene), report } +} + +export function mergePrintExportDiagnostics( + report: PrintExportReport, + diagnostics: PrintExportDiagnostic[], + omitCodes: ReadonlySet = new Set(), +): PrintExportReport { + const merged = [ + ...report.diagnostics.filter((diagnostic) => !omitCodes.has(diagnostic.code)), + ...diagnostics, + ] + const status = merged.some((diagnostic) => diagnostic.severity === 'error') + ? 'blocked' + : merged.some((diagnostic) => diagnostic.severity === 'warning') + ? 'warning' + : 'pass' + return { ...report, status, diagnostics: merged } +} + +export function isPrintExportReport(value: unknown): value is PrintExportReport { + if (!value || typeof value !== 'object') return false + const report = value as Partial + return report.kind === 'print-export-report' && report.version === 2 +} diff --git a/packages/editor/src/lib/print-feature-thickness.test.ts b/packages/editor/src/lib/print-feature-thickness.test.ts new file mode 100644 index 0000000000..af544e02b0 --- /dev/null +++ b/packages/editor/src/lib/print-feature-thickness.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, RoofSegmentNode, SlabNode, WallNode } from '@pascal-app/core' +import type { PrintExportReport } from './print-export' +import { + applySemanticPrintFeatureThickness, + measureSemanticPrintFeatureThickness, +} from './print-feature-thickness' + +const nodes = { + wall_test: WallNode.parse({ + id: 'wall_test', + parentId: 'level_test', + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }), + slab_test: SlabNode.parse({ + id: 'slab_test', + parentId: 'level_test', + polygon: [ + [0, 0], + [4, 0], + [4, 3], + ], + thickness: 0.25, + }), + rseg_test: RoofSegmentNode.parse({ + id: 'rseg_test', + parentId: 'level_test', + wallThickness: 0.15, + deckThickness: 0.12, + shingleThickness: 0.03, + }), +} satisfies Record + +const report: PrintExportReport = { + kind: 'print-export-report', + version: 2, + format: '3mf', + scale: 100, + units: 'millimeter', + orientation: 'z-up', + status: 'pass', + bounds: null, + triangleCount: 12, + invalidTriangleCount: 0, + degenerateTriangleCount: 0, + boundaryEdgeCount: 0, + nonManifoldEdgeCount: 0, + connectedComponentCount: 1, + solidComponentCount: 1, + invertedWinding: false, + volumeMm3: 100, + diagnostics: [ + { + severity: 'info', + code: 'compiler_limits', + message: 'Old compiler limit message.', + }, + ], +} + +describe('print feature thickness', () => { + test('measures semantic wall, slab, and roof dimensions at print scale', () => { + const measurement = measureSemanticPrintFeatureThickness( + nodes, + ['slab_test', 'rseg_test', 'wall_test'], + 100, + ) + + expect(measurement).toEqual({ + features: [ + { nodeId: 'rseg_test', thicknessMm: 1.5 }, + { nodeId: 'slab_test', thicknessMm: 2.5 }, + { nodeId: 'wall_test', thicknessMm: 2 }, + ], + unmeasuredNodeIds: [], + }) + }) + + test('blocks located semantic features below a custom target', () => { + const measured = applySemanticPrintFeatureThickness(report, nodes, Object.keys(nodes), 1.8) + + expect(measured.status).toBe('blocked') + expect(measured.minimumFeatureThicknessMm).toBeCloseTo(1.5) + expect(measured.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'feature_below_target', + nodeIds: ['rseg_test'], + }), + ) + expect(measured.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'minimum_feature_thickness', + nodeIds: ['rseg_test'], + }), + ) + }) + + test('includes the canonical Dutch top-rake slab in roof measurement', () => { + const dutch = RoofSegmentNode.parse({ + id: 'rseg_dutch-test', + parentId: 'level_test', + roofType: 'dutch', + wallThickness: 0.2, + deckThickness: 0.12, + shingleThickness: 0.03, + dutchTopRakeThickness: 0.05, + }) + + expect( + measureSemanticPrintFeatureThickness({ [dutch.id]: dutch }, [dutch.id], 100).features, + ).toEqual([{ nodeId: dutch.id, thicknessMm: 0.5 }]) + }) + + test('does not certify a custom target when source-node coverage is incomplete', () => { + const measured = applySemanticPrintFeatureThickness( + report, + nodes, + ['wall_test', 'column_unmeasured'], + 1.5, + ) + + expect(measured.status).toBe('blocked') + expect(measured.minimumFeatureThicknessMm).toBe(2) + expect(measured.diagnostics).toContainEqual( + expect.objectContaining({ + severity: 'error', + code: 'feature_thickness_incomplete', + nodeIds: ['column_unmeasured'], + }), + ) + }) + + test('does not certify a custom target from an empty measurement set', () => { + const measured = applySemanticPrintFeatureThickness(report, nodes, [], 1.5) + + expect(measured.status).toBe('blocked') + expect(measured.minimumFeatureThicknessMm).toBeNull() + expect(measured.diagnostics).toContainEqual( + expect.objectContaining({ + severity: 'error', + code: 'feature_thickness_incomplete', + }), + ) + }) +}) diff --git a/packages/editor/src/lib/print-feature-thickness.ts b/packages/editor/src/lib/print-feature-thickness.ts new file mode 100644 index 0000000000..0593add343 --- /dev/null +++ b/packages/editor/src/lib/print-feature-thickness.ts @@ -0,0 +1,180 @@ +import { type AnyNode, getWallThickness } from '@pascal-app/core' +import { + mergePrintExportDiagnostics, + type PrintExportDiagnostic, + type PrintExportReport, +} from './print-export' + +const MILLIMETERS_PER_METER = 1000 + +export type PrintFeatureThickness = { + nodeId: string + thicknessMm: number +} + +export type PrintFeatureThicknessMeasurement = { + features: PrintFeatureThickness[] + unmeasuredNodeIds: string[] +} + +const FEATURE_DIAGNOSTIC_CODES = new Set([ + 'minimum_feature_thickness', + 'feature_below_target', + 'feature_thickness_incomplete', +]) + +function semanticThicknessMeters(node: AnyNode): number | null { + switch (node.type) { + case 'wall': + return getWallThickness(node) + case 'slab': + return node.thickness + case 'roof-segment': { + const structuralThicknesses = [node.wallThickness, node.deckThickness + node.shingleThickness] + if (node.roofType === 'dutch') structuralThicknesses.push(node.dutchTopRakeThickness) + return Math.min(...structuralThicknesses) + } + default: + return null + } +} + +function formatThickness(value: number): string { + return value + .toFixed(3) + .replace(/\.000$/, '') + .replace(/(\.\d*[1-9])0+$/, '$1') +} + +export function measureSemanticPrintFeatureThickness( + nodes: Record, + sourceNodeIds: Iterable, + scale: number, +): PrintFeatureThicknessMeasurement { + if (!Number.isFinite(scale) || scale <= 0) { + throw new RangeError('Print scale must be a positive finite denominator') + } + + const features: PrintFeatureThickness[] = [] + const unmeasuredNodeIds: string[] = [] + for (const nodeId of Array.from(new Set(sourceNodeIds)).sort()) { + const node = nodes[nodeId] + const thicknessMeters = node ? semanticThicknessMeters(node) : null + if (thicknessMeters === null || !Number.isFinite(thicknessMeters) || thicknessMeters <= 0) { + unmeasuredNodeIds.push(nodeId) + continue + } + features.push({ + nodeId, + thicknessMm: (thicknessMeters * MILLIMETERS_PER_METER) / scale, + }) + } + + return { features, unmeasuredNodeIds } +} + +export function applyPrintFeatureThickness( + report: PrintExportReport, + measurement: PrintFeatureThicknessMeasurement, + minimumFeatureMm?: number, +): PrintExportReport { + if ( + minimumFeatureMm !== undefined && + (!Number.isFinite(minimumFeatureMm) || minimumFeatureMm <= 0) + ) { + throw new RangeError('Minimum print feature target must be positive and finite') + } + + const minimum = measurement.features.reduce( + (current, feature) => + !current || feature.thicknessMm < current.thicknessMm ? feature : current, + null, + ) + const minimumNodeIds = minimum + ? measurement.features + .filter((feature) => Math.abs(feature.thicknessMm - minimum.thicknessMm) <= 1e-9) + .map((feature) => feature.nodeId) + .sort() + : [] + const diagnostics: PrintExportDiagnostic[] = [ + { + severity: 'info', + code: 'compiler_limits', + message: + 'Known semantic or generated feature dimensions were measured; mesh-observed thin features and self-intersections are not checked.', + }, + ] + + if (minimum) { + diagnostics.push({ + severity: 'info', + code: 'minimum_feature_thickness', + message: `Minimum known feature thickness is ${formatThickness(minimum.thicknessMm)} mm.`, + nodeIds: minimumNodeIds, + }) + } + + if (minimumFeatureMm !== undefined) { + const belowTargetNodeIds = measurement.features + .filter((feature) => feature.thicknessMm < minimumFeatureMm) + .map((feature) => feature.nodeId) + .sort() + if (belowTargetNodeIds.length > 0) { + diagnostics.push({ + severity: 'error', + code: 'feature_below_target', + message: `${belowTargetNodeIds.length.toLocaleString()} source node${ + belowTargetNodeIds.length === 1 ? '' : 's' + } fall below the custom ${formatThickness(minimumFeatureMm)} mm feature target; the minimum is ${formatThickness(minimum?.thicknessMm ?? 0)} mm.`, + nodeIds: belowTargetNodeIds, + }) + } + } + + if (measurement.unmeasuredNodeIds.length > 0) { + diagnostics.push({ + severity: minimumFeatureMm === undefined ? 'warning' : 'error', + code: 'feature_thickness_incomplete', + message: + minimumFeatureMm === undefined + ? `Known feature thickness was not measured for ${measurement.unmeasuredNodeIds.length.toLocaleString()} source node${measurement.unmeasuredNodeIds.length === 1 ? '' : 's'}; inspect those features in a slicer.` + : `The custom feature target cannot be verified for ${measurement.unmeasuredNodeIds.length.toLocaleString()} source node${measurement.unmeasuredNodeIds.length === 1 ? '' : 's'} without a supported semantic thickness measurement.`, + nodeIds: measurement.unmeasuredNodeIds, + }) + } else if (measurement.features.length === 0) { + diagnostics.push({ + severity: minimumFeatureMm === undefined ? 'warning' : 'error', + code: 'feature_thickness_incomplete', + message: + minimumFeatureMm === undefined + ? 'No known source feature thickness was available; inspect the part in a slicer.' + : 'The custom feature target cannot be verified because no known source feature thickness was available.', + }) + } + + return { + ...mergePrintExportDiagnostics( + report, + diagnostics, + new Set(['compiler_limits', 'compiler_pending']), + ), + minimumFeatureThicknessMm: minimum?.thicknessMm ?? null, + } +} + +export function applySemanticPrintFeatureThickness( + report: PrintExportReport, + nodes: Record, + sourceNodeIds: Iterable, + minimumFeatureMm?: number, +): PrintExportReport { + return applyPrintFeatureThickness( + report, + measureSemanticPrintFeatureThickness(nodes, sourceNodeIds, report.scale), + minimumFeatureMm, + ) +} + +export function isPrintFeatureThicknessDiagnostic(diagnostic: PrintExportDiagnostic): boolean { + return FEATURE_DIAGNOSTIC_CODES.has(diagnostic.code) +} diff --git a/packages/editor/src/lib/print-golden-house.test-fixture.ts b/packages/editor/src/lib/print-golden-house.test-fixture.ts new file mode 100644 index 0000000000..ec530f1c52 --- /dev/null +++ b/packages/editor/src/lib/print-golden-house.test-fixture.ts @@ -0,0 +1,352 @@ +import { + type AnyNode, + BuildingNode, + DoorNode, + getLevelElevations, + getWallThickness, + LevelNode, + nodeRegistry, + RoofSegmentNode, + registerNode, + SlabNode, + type SlabPolygonContext, + sceneRegistry, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { generateRoofSegmentGeometry, generateSlabGeometry } from '@pascal-app/viewer' +import * as THREE from 'three' + +const EMPTY_SLAB_CONTEXT: SlabPolygonContext = { walls: [], siblingSlabs: [] } +const PRINT_GOLDEN_FURNITURE_KIND = 'print-golden-furniture' + +export const PRINT_GOLDEN_HOUSE_IDS = { + building: 'building_print-golden-house', + groundLevel: 'level_print-golden-ground', + upperLevel: 'level_print-golden-upper', + groundWalls: [ + 'wall_print-golden-ground-front', + 'wall_print-golden-ground-right', + 'wall_print-golden-ground-back', + 'wall_print-golden-ground-left', + ], + upperWalls: [ + 'wall_print-golden-upper-front', + 'wall_print-golden-upper-right', + 'wall_print-golden-upper-back', + 'wall_print-golden-upper-left', + ], + door: 'door_print-golden-ground-front', + window: 'window_print-golden-upper-back', + groundSlab: 'slab_print-golden-ground', + upperSlab: 'slab_print-golden-upper', + roof: 'rseg_print-golden-upper', + visibleFurniture: 'furniture_print-golden-visible', + hiddenFurnitureParent: 'furniture_print-golden-hidden-parent', + hiddenFurnitureChild: 'furniture_print-golden-hidden-child', +} as const + +export type PrintGoldenHouseFixture = { + root: THREE.Group + nodes: Record + structuralNodeIds: string[] + groundStructuralNodeIds: string[] + upperStructuralNodeIds: string[] + dispose: () => void +} + +function wall( + id: string, + parentId: string, + start: [number, number], + end: [number, number], + children: string[] = [], +) { + return WallNode.parse({ + id, + parentId, + start, + end, + height: 2.5, + thickness: 0.2, + children, + }) +} + +function slab(id: string, parentId: string) { + return SlabNode.parse({ + id, + parentId, + elevation: 0.2, + thickness: 0.2, + polygon: [ + [-2.1, -1.6], + [2.1, -1.6], + [2.1, 1.6], + [-2.1, 1.6], + ], + }) +} + +function disposeObject(root: THREE.Object3D) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + mesh.geometry.dispose() + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + for (const material of materials) material.dispose() + }) +} + +export function createPrintGoldenHouseFixture(): PrintGoldenHouseFixture { + const ids = PRINT_GOLDEN_HOUSE_IDS + for (const kind of ['wall', 'door', 'window', 'slab', 'roof-segment']) { + if (nodeRegistry.has(kind)) continue + registerNode({ + kind, + schemaVersion: 1, + category: 'structure', + defaults: () => ({}), + capabilities: {}, + } as never) + } + const groundWalls = [ + wall(ids.groundWalls[0], ids.groundLevel, [-2, -1.5], [2, -1.5], [ids.door]), + wall(ids.groundWalls[1], ids.groundLevel, [2, -1.5], [2, 1.5]), + wall(ids.groundWalls[2], ids.groundLevel, [2, 1.5], [-2, 1.5]), + wall(ids.groundWalls[3], ids.groundLevel, [-2, 1.5], [-2, -1.5]), + ] + const upperWalls = [ + wall(ids.upperWalls[0], ids.upperLevel, [-2, -1.5], [2, -1.5]), + wall(ids.upperWalls[1], ids.upperLevel, [2, -1.5], [2, 1.5]), + wall(ids.upperWalls[2], ids.upperLevel, [2, 1.5], [-2, 1.5], [ids.window]), + wall(ids.upperWalls[3], ids.upperLevel, [-2, 1.5], [-2, -1.5]), + ] + const door = DoorNode.parse({ + id: ids.door, + parentId: groundWalls[0]!.id, + wallId: groundWalls[0]!.id, + position: [2, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: ids.window, + parentId: upperWalls[2]!.id, + wallId: upperWalls[2]!.id, + position: [2, 1.4, 0], + width: 1.2, + height: 1, + }) + const groundSlab = slab(ids.groundSlab, ids.groundLevel) + const upperSlab = slab(ids.upperSlab, ids.upperLevel) + const roof = RoofSegmentNode.parse({ + id: ids.roof, + parentId: ids.upperLevel, + roofType: 'gable', + position: [0, 2.5, 0], + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + + if (!nodeRegistry.has(PRINT_GOLDEN_FURNITURE_KIND)) { + registerNode({ + kind: PRINT_GOLDEN_FURNITURE_KIND, + schemaVersion: 1, + category: 'furnish', + defaults: () => ({}), + capabilities: {}, + } as never) + } + const visibleFurniture = { + object: 'node', + id: ids.visibleFurniture, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.groundLevel, + children: [], + visible: true, + } as unknown as AnyNode + const hiddenFurnitureParent = { + object: 'node', + id: ids.hiddenFurnitureParent, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.groundLevel, + children: [ids.hiddenFurnitureChild], + visible: false, + } as unknown as AnyNode + const hiddenFurnitureChild = { + object: 'node', + id: ids.hiddenFurnitureChild, + type: PRINT_GOLDEN_FURNITURE_KIND, + parentId: ids.hiddenFurnitureParent, + children: [], + visible: true, + } as unknown as AnyNode + + const groundLevel = LevelNode.parse({ + id: ids.groundLevel, + parentId: ids.building, + name: 'Ground', + level: 0, + height: 2.5, + children: [ + ...groundWalls.map((node) => node.id), + groundSlab.id, + visibleFurniture.id, + hiddenFurnitureParent.id, + ], + }) + const upperLevel = LevelNode.parse({ + id: ids.upperLevel, + parentId: ids.building, + name: 'Upper', + level: 1, + height: 2.5, + children: [...upperWalls.map((node) => node.id), upperSlab.id, roof.id], + }) + const building = BuildingNode.parse({ + id: ids.building, + children: [groundLevel.id, upperLevel.id], + }) + const nodes = Object.fromEntries( + [ + building, + groundLevel, + upperLevel, + ...groundWalls, + ...upperWalls, + door, + window, + groundSlab, + upperSlab, + roof, + visibleFurniture, + hiddenFurnitureParent, + hiddenFurnitureChild, + ].map((node) => [node.id, node]), + ) as Record + + const root = new THREE.Group() + root.name = 'print-golden-house' + const buildingRoot = new THREE.Group() + buildingRoot.userData = { pascalId: building.id } + const groundRoot = new THREE.Group() + groundRoot.userData = { pascalId: groundLevel.id } + const upperRoot = new THREE.Group() + upperRoot.userData = { pascalId: upperLevel.id } + const elevations = getLevelElevations(nodes) + groundRoot.position.y = elevations.get(groundLevel.id)?.baseY ?? 0 + upperRoot.position.y = elevations.get(upperLevel.id)?.baseY ?? 0 + root.add(buildingRoot) + buildingRoot.add(groundRoot, upperRoot) + + const registered = new Map() + const registerObject = (id: string, object: THREE.Object3D) => { + sceneRegistry.nodes.set(id, object) + registered.set(id, object) + } + registerObject(building.id, buildingRoot) + registerObject(groundLevel.id, groundRoot) + registerObject(upperLevel.id, upperRoot) + + const mountWalls = ( + levelRoot: THREE.Group, + walls: WallNode[], + openings: Array, + ) => { + for (const wallNode of walls) { + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wallNode.id } + wallRoot.position.set(wallNode.start[0], 0, wallNode.start[1]) + wallRoot.rotation.y = -Math.atan2( + wallNode.end[1] - wallNode.start[1], + wallNode.end[0] - wallNode.start[0], + ) + const wallOpenings = openings.filter((opening) => opening.wallId === wallNode.id) + const wallLength = Math.hypot( + wallNode.end[0] - wallNode.start[0], + wallNode.end[1] - wallNode.start[1], + ) + const wallHeight = wallNode.height ?? 2.5 + const displayWall = new THREE.Mesh( + new THREE.BoxGeometry(wallLength, wallHeight, getWallThickness(wallNode)), + ) + displayWall.position.set(wallLength / 2, wallHeight / 2, 0) + wallRoot.add(displayWall) + for (const opening of wallOpenings) { + const openingRoot = new THREE.Group() + openingRoot.userData = { pascalId: opening.id } + wallRoot.add(openingRoot) + registerObject(opening.id, openingRoot) + } + levelRoot.add(wallRoot) + registerObject(wallNode.id, wallRoot) + } + } + mountWalls(groundRoot, groundWalls, [door]) + mountWalls(upperRoot, upperWalls, [window]) + + const mountSlab = (levelRoot: THREE.Group, node: SlabNode) => { + const slabRoot = new THREE.Group() + slabRoot.userData = { pascalId: node.id } + slabRoot.add(new THREE.Mesh(generateSlabGeometry(node, EMPTY_SLAB_CONTEXT))) + levelRoot.add(slabRoot) + registerObject(node.id, slabRoot) + } + mountSlab(groundRoot, groundSlab) + mountSlab(upperRoot, upperSlab) + + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.position.set(...roof.position) + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + upperRoot.add(roofRoot) + registerObject(roof.id, roofRoot) + + const visibleFurnitureRoot = new THREE.Group() + visibleFurnitureRoot.userData = { pascalId: visibleFurniture.id } + visibleFurnitureRoot.position.set(1, 0.5, 0) + visibleFurnitureRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8))) + groundRoot.add(visibleFurnitureRoot) + registerObject(visibleFurniture.id, visibleFurnitureRoot) + + const hiddenFurnitureParentRoot = new THREE.Group() + hiddenFurnitureParentRoot.userData = { pascalId: hiddenFurnitureParent.id } + hiddenFurnitureParentRoot.position.set(-1, 0, 0) + const hiddenFurnitureChildRoot = new THREE.Group() + hiddenFurnitureChildRoot.userData = { pascalId: hiddenFurnitureChild.id } + hiddenFurnitureChildRoot.position.y = 0.5 + hiddenFurnitureChildRoot.add(new THREE.Mesh(new THREE.BoxGeometry(0.8, 1, 0.8))) + hiddenFurnitureParentRoot.add(hiddenFurnitureChildRoot) + groundRoot.add(hiddenFurnitureParentRoot) + registerObject(hiddenFurnitureParent.id, hiddenFurnitureParentRoot) + registerObject(hiddenFurnitureChild.id, hiddenFurnitureChildRoot) + + const groundStructuralNodeIds = [...groundWalls.map((node) => node.id), groundSlab.id].sort() + const upperStructuralNodeIds = [ + ...upperWalls.map((node) => node.id), + upperSlab.id, + roof.id, + ].sort() + + return { + root, + nodes, + structuralNodeIds: [...groundStructuralNodeIds, ...upperStructuralNodeIds].sort(), + groundStructuralNodeIds, + upperStructuralNodeIds, + dispose: () => { + for (const [id, object] of registered) { + if (sceneRegistry.nodes.get(id) === object) sceneRegistry.nodes.delete(id) + } + disposeObject(root) + root.clear() + }, + } +} diff --git a/packages/editor/src/lib/print-golden-house.test.ts b/packages/editor/src/lib/print-golden-house.test.ts new file mode 100644 index 0000000000..1594f11c52 --- /dev/null +++ b/packages/editor/src/lib/print-golden-house.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, test } from 'bun:test' +import { XMLParser } from 'fast-xml-parser' +import { strFromU8, unzipSync } from 'fflate' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { exportSceneLevelsForPrint } from './level-print-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { + createPrintGoldenHouseFixture, + PRINT_GOLDEN_HOUSE_IDS, +} from './print-golden-house.test-fixture' +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from './print-shell-compiler-manifold-worker' + +function identityIds(root: THREE.Object3D): string[] { + const ids: string[] = [] + root.traverse((object) => { + if (typeof object.userData.pascalId === 'string') ids.push(object.userData.pascalId) + }) + return ids.sort() +} + +function objectByIdentity(root: THREE.Object3D, id: string): THREE.Object3D { + let match: THREE.Object3D | null = null + root.traverse((object) => { + if (object.userData.pascalId === id) match = object + }) + if (!match) throw new Error(`Missing prepared object ${id}`) + return match +} + +function rayIntersectionCount( + root: THREE.Object3D, + origin: THREE.Vector3, + direction: THREE.Vector3, + far: number, +): number { + root.updateMatrixWorld(true) + const raycaster = new THREE.Raycaster(origin, direction.normalize(), 0, far) + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const originalMaterial = mesh.material + mesh.material = material + count += raycaster.intersectObject(mesh, false).length + mesh.material = originalMaterial + }) + material.dispose() + return count +} + +function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value] +} + +function packageObjectSizes(data: Uint8Array): Array<{ name: string; size: THREE.Vector3 }> { + const files = unzipSync(data) + const xml = strFromU8(files['3D/3dmodel.model']!) + const model = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }).parse(xml).model + const object = asArray>(model.resources.object)[0]! + const mesh = object.mesh as { vertices: { vertex: Record[] } } + const vertices = asArray(mesh.vertices.vertex) + const metadata = asArray>(model.metadata) + const partManifest = JSON.parse( + metadata.find((entry) => entry.name === 'Pascal.PartManifest')!['#text']!, + ) as Array<{ name: string; vertexStart: number; vertexCount: number }> + return partManifest.map((part) => { + const bounds = new THREE.Box3() + for (const vertex of vertices.slice(part.vertexStart, part.vertexStart + part.vertexCount)) { + bounds.expandByPoint(new THREE.Vector3(Number(vertex.x), Number(vertex.y), Number(vertex.z))) + } + return { name: part.name, size: bounds.getSize(new THREE.Vector3()) } + }) +} + +const compileGoldenShell = ( + source: THREE.Object3D, + nodes: Parameters[1], +) => compileSemanticPrintShellWithManifold(source, nodes, { runner: compileManifoldMeshData }) + +describe('print golden house', () => { + test('consolidates hidden-ancestor visibility and structure-only scope', () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const preparedIds = identityIds(prepared.scene) + expect(preparedIds).toContain(PRINT_GOLDEN_HOUSE_IDS.visibleFurniture) + expect(preparedIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.hiddenFurnitureParent) + expect(preparedIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.hiddenFurnitureChild) + + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const structureIds = identityIds(structure) + expect(structureIds).not.toContain(PRINT_GOLDEN_HOUSE_IDS.visibleFurniture) + expect(structureIds).toEqual( + expect.arrayContaining([ + PRINT_GOLDEN_HOUSE_IDS.groundLevel, + PRINT_GOLDEN_HOUSE_IDS.upperLevel, + ...fixture.structuralNodeIds, + ]), + ) + } finally { + fixture.dispose() + } + }) + + test('preserves door and window voids in the final Manifold level shells', async () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const ground = await compileGoldenShell( + objectByIdentity(structure, PRINT_GOLDEN_HOUSE_IDS.groundLevel), + fixture.nodes, + ) + const upper = await compileGoldenShell( + objectByIdentity(structure, PRINT_GOLDEN_HOUSE_IDS.upperLevel), + fixture.nodes, + ) + + expect(ground.status).toBe('compiled') + expect(upper.status).toBe('compiled') + expect(ground.sourceNodeIds).toEqual(fixture.groundStructuralNodeIds) + expect(upper.sourceNodeIds).toEqual(fixture.upperStructuralNodeIds) + expect( + rayIntersectionCount( + ground.scene!, + new THREE.Vector3(0, 1.05, -2), + new THREE.Vector3(0, 0, 1), + 0.8, + ), + ).toBe(0) + expect( + rayIntersectionCount( + ground.scene!, + new THREE.Vector3(1.5, 1.05, -2), + new THREE.Vector3(0, 0, 1), + 0.8, + ), + ).toBeGreaterThanOrEqual(2) + expect( + rayIntersectionCount( + upper.scene!, + new THREE.Vector3(0, 3.9, 1), + new THREE.Vector3(0, 0, 1), + 1, + ), + ).toBe(0) + expect( + rayIntersectionCount( + upper.scene!, + new THREE.Vector3(1.5, 3.9, 1), + new THREE.Vector3(0, 0, 1), + 1, + ), + ).toBeGreaterThanOrEqual(2) + } finally { + fixture.dispose() + } + }, 15_000) + + test('emits deterministic two-level 3MF parts and plinth from the same semantic house', async () => { + const fixture = createPrintGoldenHouseFixture() + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const options = { + scale: 100, + format: '3mf' as const, + plinth: { marginMm: 2, thicknessMm: 3 }, + compileShells: true, + compileShell: compileGoldenShell, + } + const first = await exportSceneLevelsForPrint(structure, fixture.nodes, options) + const second = await exportSceneLevelsForPrint(structure, fixture.nodes, { + ...options, + minimumFeatureMm: 1.8, + }) + + expect(first.report.status).toBe('pass') + expect(first.report.parts.map((part) => part.objectName)).toEqual([ + '00 Plinth', + '01 Ground', + '02 Upper', + ]) + expect(first.report.parts.map((part) => part.sourceBaseMeters)).toEqual([null, 0, 2.5]) + expect(first.report.parts.map((part) => part.report.minimumFeatureThicknessMm)).toEqual([ + 3, 2, 1.5, + ]) + for (const part of first.report.parts) { + expect(part.report.status).toBe('pass') + expect(part.report.degenerateTriangleCount).toBe(0) + expect(part.report.boundaryEdgeCount).toBe(0) + expect(part.report.nonManifoldEdgeCount).toBe(0) + expect(part.report.connectedComponentCount).toBe(1) + expect(part.report.solidComponentCount).toBe(1) + expect(part.report.invertedWinding).toBe(false) + expect(part.report.volumeMm3).toBeGreaterThan(0) + expect(part.report.bounds?.min.z).toBeCloseTo(0, 5) + } + expect(first.report.parts[1]?.report.diagnostics).toContainEqual( + expect.objectContaining({ nodeIds: fixture.groundStructuralNodeIds }), + ) + expect(first.report.parts[2]?.report.diagnostics).toContainEqual( + expect.objectContaining({ nodeIds: fixture.upperStructuralNodeIds }), + ) + expect(second.report.status).toBe('blocked') + expect(second.report.parts.map((part) => part.report.status)).toEqual([ + 'pass', + 'pass', + 'blocked', + ]) + expect(second.report.parts[2]?.report.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'feature_below_target', + nodeIds: [PRINT_GOLDEN_HOUSE_IDS.roof], + }), + ) + + const objects = packageObjectSizes(first.data) + expect(objects.map((object) => object.name)).toEqual(['00 Plinth', '01 Ground', '02 Upper']) + expect(objects[0]?.size.x).toBeCloseTo(46, 4) + expect(objects[0]?.size.y).toBeCloseTo(36, 4) + expect(objects[0]?.size.z).toBeCloseTo(3, 4) + expect(objects[1]?.size.x).toBeCloseTo(42, 4) + expect(objects[1]?.size.y).toBeCloseTo(32, 4) + expect(objects[1]?.size.z).toBeCloseTo(25, 4) + expect(objects[2]?.size.x).toBeCloseTo(46.6962, 3) + expect(objects[2]?.size.y).toBeCloseTo(37.1962, 3) + expect(objects[2]?.size.z).toBeCloseTo(40.3923, 3) + expect(first.data).toEqual(second.data) + } finally { + fixture.dispose() + } + }, 30_000) +}) diff --git a/packages/editor/src/lib/print-roof-solids.test.ts b/packages/editor/src/lib/print-roof-solids.test.ts new file mode 100644 index 0000000000..c84b3ff632 --- /dev/null +++ b/packages/editor/src/lib/print-roof-solids.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'bun:test' +import { DoorNode, RoofSegmentNode, type RoofType } from '@pascal-app/core' +import * as THREE from 'three' +import { buildPrintableRoofSegmentSolids } from './print-roof-solids' + +const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] + +function fixture(roofType: RoofType, overrides: Partial = {}): RoofSegmentNode { + return RoofSegmentNode.parse({ + id: `rseg_print-${roofType}`, + roofType, + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + ...overrides, + }) +} + +function onlyMesh(root: THREE.Group): THREE.Mesh { + expect(root.children).toHaveLength(1) + const mesh = root.children[0] + expect(mesh).toBeInstanceOf(THREE.Mesh) + return mesh as THREE.Mesh +} + +describe('buildPrintableRoofSegmentSolids', () => { + test('builds deterministic indexed print shells for every canonical roof type', () => { + for (const roofType of ROOF_TYPES) { + const first = buildPrintableRoofSegmentSolids(fixture(roofType)) + const second = buildPrintableRoofSegmentSolids(fixture(roofType)) + + expect(first.status).toBe('ready') + expect(second.status).toBe('ready') + expect(first.diagnostics).toEqual([]) + expect(second.diagnostics).toEqual([]) + expect(first.object).not.toBeNull() + expect(second.object).not.toBeNull() + + const firstMesh = onlyMesh(first.object!) + const secondMesh = onlyMesh(second.object!) + expect(firstMesh.geometry.index).not.toBeNull() + expect(Array.from(firstMesh.geometry.getAttribute('position').array)).toEqual( + Array.from(secondMesh.geometry.getAttribute('position').array), + ) + expect(Array.from(firstMesh.geometry.index!.array)).toEqual( + Array.from(secondMesh.geometry.index!.array), + ) + expect( + Array.from(firstMesh.geometry.getAttribute('position').array).every(Number.isFinite), + ).toBe(true) + + firstMesh.geometry.dispose() + secondMesh.geometry.dispose() + } + }) + + test('preserves segment provenance and local transform', () => { + const node = fixture('gable', { position: [1, 2, 3], rotation: Math.PI / 3 }) + const result = buildPrintableRoofSegmentSolids(node) + + expect(result.status).toBe('ready') + expect(result.object?.userData.pascalId).toBe(node.id) + expect(result.object?.position.toArray()).toEqual(node.position) + expect(result.object?.rotation.y).toBeCloseTo(node.rotation) + + onlyMesh(result.object!).geometry.dispose() + }) + + test('blocks trims and unsupported accessories until manifold fixtures exist', () => { + const unregisteredChild = DoorNode.parse({ + id: 'door_print-roof-cut', + wallId: 'wall_print-roof-host', + }) + const trimmed = buildPrintableRoofSegmentSolids( + fixture('gable', { trim: { left: 0.25 } as RoofSegmentNode['trim'] }), + ) + const unresolvedCut = buildPrintableRoofSegmentSolids( + fixture('gable', { children: ['door_missing-print-roof-cut'] }), + ) + const unregisteredCut = buildPrintableRoofSegmentSolids( + fixture('gable', { children: [unregisteredChild.id] }), + { [unregisteredChild.id]: unregisteredChild }, + ) + + expect(trimmed).toEqual( + expect.objectContaining({ + status: 'blocked', + object: null, + diagnostics: [expect.objectContaining({ code: 'unsupported_roof_print_trim' })], + }), + ) + expect(unresolvedCut).toEqual( + expect.objectContaining({ + status: 'blocked', + object: null, + diagnostics: [expect.objectContaining({ code: 'unsupported_roof_print_cut' })], + }), + ) + expect(unregisteredCut).toEqual( + expect.objectContaining({ + status: 'blocked', + object: null, + diagnostics: [expect.objectContaining({ code: 'unsupported_roof_print_cut' })], + }), + ) + }) + + test('blocks zero structural thickness', () => { + const result = buildPrintableRoofSegmentSolids(fixture('gable', { wallThickness: 0 })) + + expect(result).toEqual( + expect.objectContaining({ + status: 'blocked', + object: null, + diagnostics: [expect.objectContaining({ code: 'invalid_roof_print_dimensions' })], + }), + ) + }) +}) diff --git a/packages/editor/src/lib/print-roof-solids.ts b/packages/editor/src/lib/print-roof-solids.ts new file mode 100644 index 0000000000..74261d2cc0 --- /dev/null +++ b/packages/editor/src/lib/print-roof-solids.ts @@ -0,0 +1,623 @@ +import { + type AnyNode, + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, + getSegmentSlopeFrame, + nodeRegistry, + normalizeRoofSegmentTrim, + type RoofSegmentNode, + type RoofShapeFaceVertex, +} from '@pascal-app/core' +import * as THREE from 'three' +import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +// Manufacturing geometry belongs to the editor layer, not the read-only viewer runtime. +const FACE_EPSILON = 1e-7 +const SURFACE_NORMAL_EPSILON = 1e-6 + +type RoofFace = RoofShapeFaceVertex[] + +export type PrintRoofSolidDiagnostic = { + severity: 'error' + code: + | 'invalid_roof_print_dimensions' + | 'roof_print_topology_mismatch' + | 'unsupported_roof_print_trim' + | 'unsupported_roof_print_cut' + message: string + nodeIds: string[] +} + +export type PrintRoofSolidResult = + | { status: 'ready'; object: THREE.Group; diagnostics: [] } + | { status: 'blocked'; object: null; diagnostics: PrintRoofSolidDiagnostic[] } + +type BoundaryEdge = { + start: RoofShapeFaceVertex + end: RoofShapeFaceVertex +} + +type RoofModuleGeometryResult = + | { status: 'ready'; geometry: THREE.BufferGeometry } + | { status: 'blocked'; message: string } + +function faceNormal(face: RoofFace): THREE.Vector3 | null { + if (face.length < 3) return null + const origin = new THREE.Vector3(face[0]!.x, face[0]!.y, face[0]!.z) + const first = new THREE.Vector3() + const second = new THREE.Vector3() + const normal = new THREE.Vector3() + + for (let firstIndex = 1; firstIndex < face.length - 1; firstIndex += 1) { + first.set(face[firstIndex]!.x, face[firstIndex]!.y, face[firstIndex]!.z).sub(origin) + for (let secondIndex = firstIndex + 1; secondIndex < face.length; secondIndex += 1) { + second.set(face[secondIndex]!.x, face[secondIndex]!.y, face[secondIndex]!.z).sub(origin) + normal.crossVectors(first, second) + if (normal.lengthSq() > FACE_EPSILON * FACE_EPSILON) return normal.normalize() + } + } + + return null +} + +function pointKey(point: RoofShapeFaceVertex): string { + return `${Math.round(point.x / FACE_EPSILON)},${Math.round( + point.y / FACE_EPSILON, + )},${Math.round(point.z / FACE_EPSILON)}` +} + +function edgeKey(a: RoofShapeFaceVertex, b: RoofShapeFaceVertex): string { + const keyA = pointKey(a) + const keyB = pointKey(b) + return keyA < keyB ? `${keyA}|${keyB}` : `${keyB}|${keyA}` +} + +function projectFace(face: RoofFace, normal: THREE.Vector3): THREE.Vector2[] { + const absX = Math.abs(normal.x) + const absY = Math.abs(normal.y) + const absZ = Math.abs(normal.z) + + if (absX >= absY && absX >= absZ) { + return face.map((point) => new THREE.Vector2(point.z, point.y)) + } + if (absY >= absX && absY >= absZ) { + return face.map((point) => new THREE.Vector2(point.x, point.z)) + } + return face.map((point) => new THREE.Vector2(point.x, point.y)) +} + +function geometryFromFaces(faces: RoofFace[]): THREE.BufferGeometry | null { + const positions: number[] = [] + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const triangleSecondEdge = new THREE.Vector3() + const triangleNormal = new THREE.Vector3() + + for (const face of faces) { + const normal = faceNormal(face) + if (!normal) return null + const triangles = THREE.ShapeUtils.triangulateShape(projectFace(face, normal), []) + if (triangles.length === 0) return null + + for (const triangle of triangles) { + const [indexA, indexB, indexC] = triangle + if (indexA === undefined || indexB === undefined || indexC === undefined) return null + const pointA = face[indexA]! + let pointB = face[indexB]! + let pointC = face[indexC]! + a.set(pointA.x, pointA.y, pointA.z) + b.set(pointB.x, pointB.y, pointB.z) + c.set(pointC.x, pointC.y, pointC.z) + triangleNormal.subVectors(b, a).cross(triangleSecondEdge.subVectors(c, a)) + if (triangleNormal.dot(normal) < 0) { + ;[pointB, pointC] = [pointC, pointB] + } + positions.push( + pointA.x, + pointA.y, + pointA.z, + pointB.x, + pointB.y, + pointB.z, + pointC.x, + pointC.y, + pointC.z, + ) + } + } + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + const indexed = mergeVertices(geometry, FACE_EPSILON) + geometry.dispose() + indexed.computeVertexNormals() + return indexed +} + +function selectSurfaceFaces( + faces: RoofFace[], + include: (normal: THREE.Vector3) => boolean, + reverse = false, +): RoofFace[] | null { + const selected: RoofFace[] = [] + for (const face of faces) { + const normal = faceNormal(face) + if (!normal) return null + if (include(normal)) selected.push(reverse ? [...face].reverse() : face) + } + return selected.length > 0 ? selected : null +} + +function boundaryEdges(faces: RoofFace[]): BoundaryEdge[] | null { + const edgeUses = new Map() + for (const face of faces) { + for (let edgeIndex = 0; edgeIndex < face.length; edgeIndex += 1) { + const nextIndex = (edgeIndex + 1) % face.length + const edge = { start: face[edgeIndex]!, end: face[nextIndex]! } + const key = edgeKey(edge.start, edge.end) + const uses = edgeUses.get(key) ?? [] + uses.push(edge) + edgeUses.set(key, uses) + } + } + + const boundary: BoundaryEdge[] = [] + for (const uses of edgeUses.values()) { + if (uses.length === 1) boundary.push(uses[0]!) + else if (uses.length !== 2) return null + } + return boundary +} + +function pointDistanceSq(a: RoofShapeFaceVertex, b: RoofShapeFaceVertex): number { + const dx = a.x - b.x + const dy = a.y - b.y + const dz = a.z - b.z + return dx * dx + dy * dy + dz * dz +} + +function boundaryLoops(edges: BoundaryEdge[]): RoofFace[] | null { + const byStart = new Map() + for (const edge of edges) { + const key = pointKey(edge.start) + if (byStart.has(key)) return null + byStart.set(key, edge) + } + + const unused = new Set(byStart.keys()) + const loops: RoofFace[] = [] + while (unused.size > 0) { + const firstKey = unused.values().next().value + if (typeof firstKey !== 'string') return null + const loop: RoofFace = [] + let key = firstKey + + do { + const edge = byStart.get(key) + if (!edge || !unused.delete(key)) return null + loop.push(edge.start) + key = pointKey(edge.end) + } while (key !== firstKey) + + if (loop.length < 3) return null + loops.push(loop) + } + + return loops +} + +function loopCentroid(loop: RoofFace): RoofShapeFaceVertex { + const sum = loop.reduce( + (total, point) => ({ x: total.x + point.x, y: total.y + point.y, z: total.z + point.z }), + { x: 0, y: 0, z: 0 }, + ) + return { x: sum.x / loop.length, y: sum.y / loop.length, z: sum.z / loop.length } +} + +function rotated(values: T[], start: number): T[] { + return [...values.slice(start), ...values.slice(0, start)] +} + +function bridgeBoundaryLoop(first: RoofFace, second: RoofFace): RoofFace[] | null { + const reversedSecond = [...second].reverse() + let best: { cost: number; second: RoofFace; moves: ('first' | 'second')[] } | undefined + + for (let secondStart = 0; secondStart < reversedSecond.length; secondStart += 1) { + const candidate = rotated(reversedSecond, secondStart) + const width = candidate.length + 1 + const costs = new Array((first.length + 1) * width).fill(Number.POSITIVE_INFINITY) + const previous = new Array<'first' | 'second' | undefined>(costs.length) + const indexOf = (firstIndex: number, secondIndex: number) => firstIndex * width + secondIndex + costs[0] = pointDistanceSq(first[0]!, candidate[0]!) + + for (let firstIndex = 0; firstIndex <= first.length; firstIndex += 1) { + for (let secondIndex = 0; secondIndex <= candidate.length; secondIndex += 1) { + const index = indexOf(firstIndex, secondIndex) + const cost = costs[index]! + if (!Number.isFinite(cost)) continue + const firstPoint = first[firstIndex % first.length]! + const secondPoint = candidate[secondIndex % candidate.length]! + + if (firstIndex < first.length) { + const nextFirst = first[(firstIndex + 1) % first.length]! + const nextIndex = indexOf(firstIndex + 1, secondIndex) + const nextCost = cost + pointDistanceSq(nextFirst, secondPoint) + if (nextCost < costs[nextIndex]!) { + costs[nextIndex] = nextCost + previous[nextIndex] = 'first' + } + } + if (secondIndex < candidate.length) { + const nextSecond = candidate[(secondIndex + 1) % candidate.length]! + const nextIndex = indexOf(firstIndex, secondIndex + 1) + const nextCost = cost + pointDistanceSq(firstPoint, nextSecond) + if (nextCost < costs[nextIndex]!) { + costs[nextIndex] = nextCost + previous[nextIndex] = 'second' + } + } + } + } + + const endIndex = indexOf(first.length, candidate.length) + const moves: ('first' | 'second')[] = [] + let firstIndex = first.length + let secondIndex = candidate.length + while (firstIndex > 0 || secondIndex > 0) { + const move = previous[indexOf(firstIndex, secondIndex)] + if (!move) return null + moves.push(move) + if (move === 'first') firstIndex -= 1 + else secondIndex -= 1 + } + moves.reverse() + + if (!best || costs[endIndex]! < best.cost) { + best = { cost: costs[endIndex]!, second: candidate, moves } + } + } + + if (!best) return null + const bridges: RoofFace[] = [] + let firstIndex = 0 + let secondIndex = 0 + for (const move of best.moves) { + const firstPoint = first[firstIndex % first.length]! + const secondPoint = best.second[secondIndex % best.second.length]! + if (move === 'first') { + const nextFirst = first[(firstIndex + 1) % first.length]! + bridges.push([nextFirst, firstPoint, secondPoint]) + firstIndex += 1 + } else { + const nextSecond = best.second[(secondIndex + 1) % best.second.length]! + bridges.push([firstPoint, secondPoint, nextSecond]) + secondIndex += 1 + } + } + return bridges +} + +function bridgeBoundaries(first: BoundaryEdge[], second: BoundaryEdge[]): RoofFace[] | null { + const firstLoops = boundaryLoops(first) + const secondLoops = boundaryLoops(second) + if (!firstLoops || !secondLoops || firstLoops.length !== secondLoops.length) return null + + const unmatched = new Set(secondLoops.map((_, index) => index)) + const bridges: RoofFace[] = [] + for (const firstLoop of firstLoops) { + const firstCentroid = loopCentroid(firstLoop) + let bestIndex = -1 + let bestScore = Number.POSITIVE_INFINITY + for (const candidateIndex of unmatched) { + const score = pointDistanceSq(firstCentroid, loopCentroid(secondLoops[candidateIndex]!)) + if (score < bestScore) { + bestIndex = candidateIndex + bestScore = score + } + } + if (bestIndex < 0) return null + unmatched.delete(bestIndex) + const loopBridges = bridgeBoundaryLoop(firstLoop, secondLoops[bestIndex]!) + if (!loopBridges) return null + bridges.push(...loopBridges) + } + return bridges +} + +function splitWallBoundary(edges: BoundaryEdge[]): { + bottom: BoundaryEdge[] + top: BoundaryEdge[] +} | null { + const bottom: BoundaryEdge[] = [] + const top: BoundaryEdge[] = [] + for (const edge of edges) { + if (Math.abs(edge.start.y) <= FACE_EPSILON && Math.abs(edge.end.y) <= FACE_EPSILON) { + bottom.push(edge) + } else { + top.push(edge) + } + } + return bottom.length > 0 && top.length > 0 ? { bottom, top } : null +} + +function buildRoofModuleGeometry( + wallOuterFaces: RoofFace[], + wallInnerFaces: RoofFace[], + roofOuterFaces: RoofFace[], + roofInnerFaces: RoofFace[], +): RoofModuleGeometryResult { + const isWall = (normal: THREE.Vector3) => Math.abs(normal.y) <= SURFACE_NORMAL_EPSILON + const isRoof = (normal: THREE.Vector3) => normal.y > SURFACE_NORMAL_EPSILON + const wallOuter = selectSurfaceFaces(wallOuterFaces, isWall) + const wallInner = selectSurfaceFaces(wallInnerFaces, isWall, true) + const roofOuter = selectSurfaceFaces(roofOuterFaces, isRoof) + const roofInner = selectSurfaceFaces(roofInnerFaces, isRoof, true) + if (!wallOuter || !wallInner || !roofOuter || !roofInner) { + return { status: 'blocked', message: 'A canonical wall or roof surface is missing.' } + } + + const wallOuterBoundary = boundaryEdges(wallOuter) + const wallInnerBoundary = boundaryEdges(wallInner) + const roofOuterBoundary = boundaryEdges(roofOuter) + const roofInnerBoundary = boundaryEdges(roofInner) + if (!wallOuterBoundary || !wallInnerBoundary || !roofOuterBoundary || !roofInnerBoundary) { + return { status: 'blocked', message: 'A canonical surface has invalid edge incidence.' } + } + + const outerWallLoops = splitWallBoundary(wallOuterBoundary) + const innerWallLoops = splitWallBoundary(wallInnerBoundary) + if (!outerWallLoops || !innerWallLoops) { + return { status: 'blocked', message: 'A wall surface does not expose top and bottom loops.' } + } + + const bottomRing = bridgeBoundaries(outerWallLoops.bottom, innerWallLoops.bottom) + const outerRoofJoin = bridgeBoundaries(outerWallLoops.top, roofOuterBoundary) + const innerRoofJoin = bridgeBoundaries(innerWallLoops.top, roofInnerBoundary) + if (!bottomRing) { + return { + status: 'blocked', + message: `Outer and inner wall bottoms have incompatible edge counts (${outerWallLoops.bottom.length} and ${innerWallLoops.bottom.length}).`, + } + } + if (!outerRoofJoin) { + return { + status: 'blocked', + message: `Outer wall and roof boundaries have incompatible edge counts (${outerWallLoops.top.length} and ${roofOuterBoundary.length}).`, + } + } + if (!innerRoofJoin) { + return { + status: 'blocked', + message: `Inner wall and roof boundaries have incompatible edge counts (${innerWallLoops.top.length} and ${roofInnerBoundary.length}).`, + } + } + + const geometry = geometryFromFaces([ + ...wallOuter, + ...roofOuter, + ...wallInner, + ...roofInner, + ...bottomRing, + ...outerRoofJoin, + ...innerRoofJoin, + ]) + return geometry + ? { status: 'ready', geometry } + : { status: 'blocked', message: 'A canonical print face could not be triangulated.' } +} + +function getVolumeFaces( + node: RoofSegmentNode, + options: { widthExtension: number; verticalOffset: number; isVoid: boolean }, +): RoofFace[] { + const { activeRh, tanTheta } = getSegmentSlopeFrame(node) + const width = Math.max(0.01, node.width + options.widthExtension * 2) + const depth = Math.max(0.01, node.depth + options.widthExtension * 2) + const autoDrop = options.widthExtension * tanTheta + const wallHeight = Math.max(0.01, node.wallHeight - autoDrop + options.verticalOffset) + const roofHeight = + activeRh > 0 ? activeRh + autoDrop * (node.roofType === 'shed' ? 2 : 1) : activeRh + const shapeRatios = getRoofShapeRatios(node) + const dutchInset = + Math.min(node.width, node.depth) * node.dutchHipWidthRatio + + (options.isVoid ? node.deckThickness : 0) + + return getRoofModuleFaces({ + type: node.roofType, + w: width, + d: depth, + wh: wallHeight, + rh: roofHeight, + baseY: 0, + insets: { dutchI: dutchInset }, + baseW: node.width, + baseD: node.depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }) +} + +function getShingleOuterFaces(node: RoofSegmentNode): RoofFace[] { + const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) + const shapeRatios = getRoofShapeRatios(node) + const horizontalOverhang = node.overhang * cosTheta + const deckExtension = node.wallThickness / 2 + horizontalOverhang + const deckVerticalThickness = activeRh > 0 ? node.deckThickness / cosTheta : node.deckThickness + const deckDrop = deckExtension * tanTheta + const shingleHorizontalThickness = node.shingleThickness * sinTheta + const shingleVerticalThickness = node.shingleThickness * cosTheta + const baseWidth = Math.max(0.01, node.width + deckExtension * 2) + const baseDepth = Math.max(0.01, node.depth + deckExtension * 2) + const baseWallHeight = node.wallHeight - deckDrop + deckVerticalThickness + const baseRoofHeight = + activeRh > 0 ? activeRh + deckDrop * (node.roofType === 'shed' ? 2 : 1) : activeRh + let width = baseWidth + let depth = baseDepth + let translateZ = 0 + + if (['hip', 'mansard', 'dutch'].includes(node.roofType)) { + width += shingleHorizontalThickness * 2 + depth += shingleHorizontalThickness * 2 + } else if (['gable', 'gambrel'].includes(node.roofType)) { + depth += shingleHorizontalThickness * 2 + } else if (node.roofType === 'shed') { + depth += shingleHorizontalThickness + translateZ = shingleHorizontalThickness / 2 + } + + const wallHeight = baseWallHeight + shingleVerticalThickness + const roofHeight = + activeRh > 0 ? baseRoofHeight + shingleHorizontalThickness * tanTheta : baseRoofHeight + const insets = getRoofShapeInsets({ + roofType: node.roofType, + width: node.width, + depth: node.depth, + wh: wallHeight, + baseY: 0, + isVoid: false, + brushW: width, + brushD: depth, + tanTheta, + shingleThickness: node.shingleThickness, + dutchHipWidthRatio: node.dutchHipWidthRatio, + }) + + const faces = getRoofModuleFaces({ + type: node.roofType, + w: width, + d: depth, + wh: wallHeight, + rh: roofHeight, + baseY: 0, + insets, + baseW: node.width, + baseD: node.depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }) + + if (translateZ === 0) return faces + return faces.map((face) => face.map((point) => ({ ...point, z: point.z + translateZ }))) +} + +function blockingDiagnostics( + node: RoofSegmentNode, + nodes: Record | undefined, +): PrintRoofSolidDiagnostic[] { + const diagnostics: PrintRoofSolidDiagnostic[] = [] + const dimensions = [ + node.width, + node.depth, + node.wallHeight, + node.wallThickness, + node.deckThickness, + node.overhang, + node.shingleThickness, + ] + if ( + dimensions.some((value) => !Number.isFinite(value) || value < 0) || + node.width <= FACE_EPSILON || + node.depth <= FACE_EPSILON || + node.wallThickness <= FACE_EPSILON || + node.deckThickness + node.shingleThickness <= FACE_EPSILON + ) { + diagnostics.push({ + severity: 'error', + code: 'invalid_roof_print_dimensions', + message: `Roof segment ${node.id} needs positive footprint, wall, and roof-cover thickness for print compilation.`, + nodeIds: [node.id], + }) + } + + const trim = normalizeRoofSegmentTrim(node) + if (Object.values(trim).some((value) => value > FACE_EPSILON)) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_roof_print_trim', + message: `Roof segment ${node.id} has trim cuts that do not yet have a manifold print fixture.`, + nodeIds: [node.id], + }) + } + + const cutNodeIds: string[] = [] + for (const childId of node.children) { + const child = nodes?.[childId] + if (!child) { + cutNodeIds.push(childId) + continue + } + const definition = nodeRegistry.get(child.type) + const roofAccessory = definition?.capabilities.roofAccessory + if (!roofAccessory || roofAccessory.buildCut) { + cutNodeIds.push(child.id) + } + } + if (cutNodeIds.length > 0) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_roof_print_cut', + message: `Roof segment ${node.id} has unresolved, unregistered, or cutting accessories that do not yet have a manifold print fixture.`, + nodeIds: [node.id, ...cutNodeIds].sort(), + }) + } + + return diagnostics +} + +export function buildPrintableRoofSegmentSolids( + node: RoofSegmentNode, + nodes?: Record, +): PrintRoofSolidResult { + const diagnostics = blockingDiagnostics(node, nodes) + if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics } + + const { cosTheta } = getSegmentSlopeFrame(node) + const wallOuter = getVolumeFaces(node, { + widthExtension: node.wallThickness / 2, + verticalOffset: 0, + isVoid: false, + }) + const wallInner = getVolumeFaces(node, { + widthExtension: -node.wallThickness / 2, + verticalOffset: 0, + isVoid: false, + }) + const deckExtension = node.wallThickness / 2 + node.overhang * cosTheta + const roofInner = getVolumeFaces(node, { + widthExtension: deckExtension, + verticalOffset: 0, + isVoid: true, + }) + const roofOuter = getShingleOuterFaces(node) + const geometryResult = buildRoofModuleGeometry(wallOuter, wallInner, roofOuter, roofInner) + if (geometryResult.status === 'blocked') { + return { + status: 'blocked', + object: null, + diagnostics: [ + { + severity: 'error', + code: 'roof_print_topology_mismatch', + message: `Roof segment ${node.id} cannot form one closed print module. ${geometryResult.message}`, + nodeIds: [node.id], + }, + ], + } + } + + const root = new THREE.Group() + root.name = 'print-roof-segment-solids' + root.userData = { pascalId: node.id } + root.position.set(node.position[0], node.position[1], node.position[2]) + root.rotation.y = node.rotation + + const mesh = new THREE.Mesh(geometryResult.geometry) + mesh.name = 'print-roof-shell' + root.add(mesh) + + return { status: 'ready', object: root, diagnostics: [] } +} diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.test.ts b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts new file mode 100644 index 0000000000..ca4600b900 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-baseline.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, RoofSegmentNode, type RoofType, WallNode } from '@pascal-app/core' +import { generateRoofSegmentGeometry } from '@pascal-app/viewer' +import * as THREE from 'three' +import { prepareSceneForExport } from './glb-export' +import { filterPreparedSceneForPrintContent } from './print-content-scope' +import { exportSceneToPrintStl } from './print-export' +import { createPrintGoldenHouseFixture } from './print-golden-house.test-fixture' +import { compileSemanticPrintShell } from './print-shell-compiler' +import { compilePrintShellBaseline } from './print-shell-compiler-baseline' +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import { compileSemanticPrintShellWithManifold } from './print-shell-compiler-manifold-worker' + +const ROOF_TYPES: RoofType[] = ['gable', 'hip', 'shed', 'gambrel', 'mansard', 'flat', 'dutch'] + +function structuralBox(id: string, x: number): THREE.Group { + const group = new THREE.Group() + group.userData = { pascalId: id } + group.position.x = x + group.add(new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2))) + return group +} + +function rayIntersectionCount( + root: THREE.Object3D, + x: number, + y: number, + far = Number.POSITIVE_INFINITY, + startZ = -2, +): number { + root.updateMatrixWorld(true) + const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, startZ), new THREE.Vector3(0, 0, 1)) + raycaster.far = far + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + let count = 0 + + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const originalMaterial = mesh.material + mesh.material = material + count += raycaster.intersectObject(mesh, false).length + mesh.material = originalMaterial + }) + + material.dispose() + return count +} + +function indexedNonManifoldEdgeCount(root: THREE.Object3D): number { + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + const index = mesh.isMesh ? mesh.geometry.getIndex() : null + if (!index) return + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index.count; offset += 3) { + const a = index.getX(offset) + const b = index.getX(offset + 1) + const c = index.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + count += Array.from(edges.values()).filter((uses) => uses > 2).length + }) + return count +} + +describe('print shell compiler baseline', () => { + test('unions overlapping world-space structural meshes into a closed shell', () => { + const source = new THREE.Group() + source.add(structuralBox('wall_left', -0.5), structuralBox('wall_right', 0.5)) + + const compiled = compilePrintShellBaseline(source) + + expect(compiled.status).toBe('compiled') + expect(compiled.inputMeshCount).toBe(2) + expect(compiled.sourceNodeIds).toEqual(['wall_left', 'wall_right']) + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) + expect(print.report.status).toBe('pass') + expect(print.report.bounds?.width).toBeCloseTo(30, 4) + expect(print.report.bounds?.depth).toBeCloseTo(20, 4) + expect(print.report.bounds?.height).toBeCloseTo(20, 4) + expect(print.report.boundaryEdgeCount).toBe(0) + expect(print.report.nonManifoldEdgeCount).toBe(0) + expect(print.report.volumeMm3).toBeCloseTo(12_000, 1) + }) + + test('blocks a structural mesh without Pascal provenance', () => { + const source = new THREE.Group() + source.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))) + + const compiled = compilePrintShellBaseline(source) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ code: 'missing_node_provenance', severity: 'error' }), + ) + }) + + test('compares the semantic full-house baseline union with the Manifold candidate', async () => { + const fixture = createPrintGoldenHouseFixture() + + try { + const prepared = prepareSceneForExport(fixture.root, fixture.nodes, { onlyVisible: true }) + const structure = filterPreparedSceneForPrintContent( + prepared.scene, + fixture.nodes, + 'structure', + ) + const compiled = compileSemanticPrintShell(structure, fixture.nodes, { wallSolids: true }) + + expect(compiled.status).toBe('compiled') + expect(compiled.inputMeshCount).toBeGreaterThan(10) + expect(compiled.sourceNodeIds).toEqual(fixture.structuralNodeIds) + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) + expect(print.report.status).toBe('blocked') + expect(print.report.degenerateTriangleCount).toBeGreaterThan(0) + expect(print.report.boundaryEdgeCount).toBeGreaterThan(0) + expect(print.report.volumeMm3).toBeGreaterThan(0) + + const candidate = await compileSemanticPrintShellWithManifold(structure, fixture.nodes, { + runner: compileManifoldMeshData, + }) + expect(candidate.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual( + [], + ) + expect(candidate.backend).toBe('manifold-3d') + expect(candidate.scene).not.toBeNull() + expect(indexedNonManifoldEdgeCount(candidate.scene!)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 0, 1.05, 0.8)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 1.5, 1.05, 0.8)).toBeGreaterThanOrEqual(2) + expect(rayIntersectionCount(candidate.scene!, 0, 3.9, 1, 1)).toBe(0) + expect(rayIntersectionCount(candidate.scene!, 1.5, 3.9, 1, 1)).toBeGreaterThanOrEqual(2) + + const candidatePrint = exportSceneToPrintStl(candidate.scene!, { + scale: 100, + compiled: true, + indexedTopology: true, + }) + expect( + candidatePrint.report.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'), + ).toEqual([]) + expect(candidatePrint.report.degenerateTriangleCount).toBe(0) + expect(candidatePrint.report.boundaryEdgeCount).toBe(0) + expect(candidatePrint.report.nonManifoldEdgeCount).toBe(0) + expect(candidatePrint.report.volumeMm3).toBeGreaterThan(0) + } finally { + fixture.dispose() + } + }, 15_000) + + test('blocks a Manifold worker failure without exporting display geometry', async () => { + const source = structuralBox('wall_worker-failure', 0) + const compiled = await compileSemanticPrintShellWithManifold( + source, + {}, + { + runner: async () => { + throw new Error('Worker unavailable') + }, + }, + ) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.sourceNodeIds).toEqual(['wall_worker-failure']) + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'manifold_worker_failed', + message: 'Worker unavailable', + severity: 'error', + }), + ) + }) + + test('compiles a plane-bound wall independently of its flat 2D display geometry', async () => { + const levelId = 'level_print-shell-2d' + const wall = WallNode.parse({ + id: 'wall_print-shell-2d', + parentId: levelId, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wall.id } + const flatDisplay = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.2)) + flatDisplay.rotation.x = -Math.PI / 2 + wallRoot.add(flatDisplay) + const source = new THREE.Group() + source.add(wallRoot) + const nodes = { + [levelId]: { + object: 'node', + id: levelId, + type: 'level', + parentId: null, + children: [wall.id], + height: 2.5, + level: 0, + visible: true, + } as unknown as AnyNode, + [wall.id]: wall, + } + + const compiled = await compileSemanticPrintShellWithManifold(source, nodes, { + runner: compileManifoldMeshData, + }) + expect(compiled.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]) + expect(compiled.status).toBe('compiled') + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { + scale: 100, + compiled: true, + indexedTopology: true, + }) + expect(print.report.status).toBe('pass') + expect(print.report.bounds?.height).toBeCloseTo(25, 4) + expect(print.report.boundaryEdgeCount).toBe(0) + expect(print.report.nonManifoldEdgeCount).toBe(0) + }) + + test('blocks unsupported semantic wall forms without falling back to display geometry', () => { + const wall = WallNode.parse({ + id: 'wall_print-shell-curved', + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 0.2, + curveOffset: 0.5, + }) + const wallRoot = new THREE.Group() + wallRoot.userData = { pascalId: wall.id } + const displayMesh = new THREE.Mesh(new THREE.BoxGeometry(4, 2.5, 0.2)) + displayMesh.position.set(2, 1.25, 0) + wallRoot.add(displayMesh) + const source = new THREE.Group() + source.add(wallRoot) + + const compiled = compileSemanticPrintShell(source, { [wall.id]: wall }, { wallSolids: true }) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_wall_print_curve', + severity: 'error', + nodeIds: [wall.id], + }), + ) + }) + + test('blocks the generated display gable roof from print export', () => { + const roof = RoofSegmentNode.parse({ + id: 'rseg_print-shell-fixture', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + const source = new THREE.Group() + source.add(roofRoot) + + const first = compilePrintShellBaseline(source) + const second = compilePrintShellBaseline(source) + + expect(first.status).toBe('compiled') + expect(first.inputMeshCount).toBe(1) + expect(first.sourceNodeIds).toEqual([roof.id]) + expect(first.scene).not.toBeNull() + expect(second.status).toBe('compiled') + expect(second.scene).not.toBeNull() + + const firstPrint = exportSceneToPrintStl(first.scene!, { scale: 100 }) + const secondPrint = exportSceneToPrintStl(second.scene!, { scale: 100 }) + expect(firstPrint.report.status).toBe('blocked') + expect(firstPrint.report.degenerateTriangleCount).toBeGreaterThan(0) + expect(firstPrint.report.boundaryEdgeCount).toBeGreaterThan(0) + expect(firstPrint.report.nonManifoldEdgeCount).toBeGreaterThan(0) + expect(firstPrint.report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual( + expect.arrayContaining(['degenerate_triangles', 'open_boundaries', 'non_manifold_edges']), + ) + expect(firstPrint.report.volumeMm3).toBeGreaterThan(0) + expect(new Uint8Array(firstPrint.buffer)).toEqual(new Uint8Array(secondPrint.buffer)) + }) + + test('blocks unsupported semantic roof cuts without falling back to display geometry', () => { + const roof = RoofSegmentNode.parse({ + id: 'rseg_print-shell-trimmed', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + trim: { left: 0.25 }, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + const source = new THREE.Group() + source.add(roofRoot) + + const compiled = compileSemanticPrintShell(source, { [roof.id]: roof }) + + expect(compiled.status).toBe('blocked') + expect(compiled.scene).toBeNull() + expect(compiled.sourceNodeIds).toEqual([roof.id]) + expect(compiled.diagnostics).toContainEqual( + expect.objectContaining({ + code: 'unsupported_roof_print_trim', + severity: 'error', + nodeIds: [roof.id], + }), + ) + }) + + test('compiles canonical roof modules into deterministic manifold print shells', () => { + for (const roofType of ROOF_TYPES) { + const roof = RoofSegmentNode.parse({ + id: `rseg_print-shell-${roofType}`, + roofType, + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const roofRoot = new THREE.Group() + roofRoot.userData = { pascalId: roof.id } + roofRoot.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + const source = new THREE.Group() + source.add(roofRoot) + + const compiled = compileSemanticPrintShell(source, { [roof.id]: roof }) + expect(compiled.status).toBe('compiled') + expect(compiled.inputMeshCount).toBe(1) + expect(compiled.sourceNodeIds).toEqual([roof.id]) + expect(compiled.scene).not.toBeNull() + + const print = exportSceneToPrintStl(compiled.scene!, { scale: 100, compiled: true }) + expect(print.report.status).toBe('pass') + expect(print.report.degenerateTriangleCount).toBe(0) + expect(print.report.boundaryEdgeCount).toBe(0) + expect(print.report.nonManifoldEdgeCount).toBe(0) + expect(print.report.volumeMm3).toBeGreaterThan(0) + } + + const roof = RoofSegmentNode.parse({ + id: 'rseg_print-shell-deterministic', + roofType: 'gable', + width: 4, + depth: 3, + wallHeight: 0.5, + pitch: 30, + wallThickness: 0.15, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + }) + const firstSource = new THREE.Group() + const firstRoof = new THREE.Group() + firstRoof.userData = { pascalId: roof.id } + firstRoof.add(new THREE.Mesh(generateRoofSegmentGeometry(roof))) + firstSource.add(firstRoof) + const secondSource = firstSource.clone(true) + const firstCompiled = compileSemanticPrintShell(firstSource, { [roof.id]: roof }) + const secondCompiled = compileSemanticPrintShell(secondSource, { [roof.id]: roof }) + const firstPrint = exportSceneToPrintStl(firstCompiled.scene!, { + scale: 100, + compiled: true, + }) + const secondPrint = exportSceneToPrintStl(secondCompiled.scene!, { + scale: 100, + compiled: true, + }) + + expect(firstPrint.report.bounds?.width).toBeCloseTo(46.6962, 3) + expect(firstPrint.report.bounds?.depth).toBeCloseTo(37.1962, 3) + expect(firstPrint.report.bounds?.height).toBeCloseTo(15.3923, 3) + expect(firstPrint.report.volumeMm3).toBeCloseTo(4_066.52, 1) + expect(new Uint8Array(firstPrint.buffer)).toEqual(new Uint8Array(secondPrint.buffer)) + }) +}) diff --git a/packages/editor/src/lib/print-shell-compiler-baseline.ts b/packages/editor/src/lib/print-shell-compiler-baseline.ts new file mode 100644 index 0000000000..0c78f9ab90 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-baseline.ts @@ -0,0 +1,225 @@ +import { ADDITION, Brush, csgEvaluator, csgGeometry, prepareBrushForCSG } from '@pascal-app/viewer' +import * as THREE from 'three' +import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +export type PrintShellCompileDiagnostic = { + severity: 'error' | 'warning' | 'info' + code: string + message: string + nodeIds: string[] +} + +export type PrintShellCompileResult = { + backend: 'pascal-three-bvh-csg' | 'manifold-3d' + status: 'compiled' | 'blocked' + scene: THREE.Object3D | null + inputMeshCount: number + sourceNodeIds: string[] + diagnostics: PrintShellCompileDiagnostic[] +} + +export type PrintShellInput = { + inputMeshCount: number + sourceNodeIds: Set + geometries: THREE.BufferGeometry[] + geometryNodeIds: string[] + diagnostics: PrintShellCompileDiagnostic[] +} + +function nearestPascalId(object: THREE.Object3D): string | null { + let current: THREE.Object3D | null = object + while (current) { + const id = current.userData.pascalId + if (typeof id === 'string') return id + current = current.parent + } + return null +} + +function hasFinitePositions(geometry: THREE.BufferGeometry): boolean { + const position = geometry.getAttribute('position') + if (!position || position.count === 0 || position.itemSize !== 3) return false + for (let index = 0; index < position.count; index += 1) { + if ( + !Number.isFinite(position.getX(index)) || + !Number.isFinite(position.getY(index)) || + !Number.isFinite(position.getZ(index)) + ) { + return false + } + } + return true +} + +function worldGeometry(mesh: THREE.Mesh): THREE.BufferGeometry { + const geometry = mesh.geometry.clone() + geometry.applyMatrix4(mesh.matrixWorld) + for (const name of Object.keys(geometry.attributes)) { + if (name !== 'position') geometry.deleteAttribute(name) + } + geometry.morphAttributes = {} + geometry.clearGroups() + + const indexed = mergeVertices(geometry, 1e-5) + geometry.dispose() + indexed.computeVertexNormals() + const count = indexed.getIndex()?.count ?? indexed.getAttribute('position').count + if (count > 0) indexed.addGroup(0, count, 0) + return indexed +} + +export function collectPrintShellInput(source: THREE.Object3D): PrintShellInput { + source.updateMatrixWorld(true) + + const diagnostics: PrintShellCompileDiagnostic[] = [] + const sourceNodeIds = new Set() + const geometries: THREE.BufferGeometry[] = [] + const geometryNodeIds: string[] = [] + let inputMeshCount = 0 + + source.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count === 0) return + inputMeshCount += 1 + + const nodeId = nearestPascalId(mesh) + if (!nodeId) { + diagnostics.push({ + severity: 'error', + code: 'missing_node_provenance', + message: 'A structural mesh has no Pascal node identity.', + nodeIds: [], + }) + return + } + sourceNodeIds.add(nodeId) + + const specialized = mesh as THREE.SkinnedMesh & THREE.InstancedMesh + if (specialized.isSkinnedMesh || specialized.isInstancedMesh) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_dynamic_mesh', + message: `Node ${nodeId} uses skinned or instanced geometry that the baseline compiler cannot flatten safely.`, + nodeIds: [nodeId], + }) + return + } + if (!hasFinitePositions(mesh.geometry)) { + diagnostics.push({ + severity: 'error', + code: 'invalid_shell_input', + message: `Node ${nodeId} has empty or non-finite structural geometry.`, + nodeIds: [nodeId], + }) + return + } + + geometries.push(worldGeometry(mesh)) + geometryNodeIds.push(nodeId) + }) + + if (inputMeshCount === 0) { + diagnostics.push({ + severity: 'error', + code: 'no_shell_meshes', + message: 'No structural meshes are available for shell compilation.', + nodeIds: [], + }) + } + + return { inputMeshCount, sourceNodeIds, geometries, geometryNodeIds, diagnostics } +} + +function blockedResult( + inputMeshCount: number, + sourceNodeIds: Set, + diagnostics: PrintShellCompileDiagnostic[], +): PrintShellCompileResult { + return { + backend: 'pascal-three-bvh-csg', + status: 'blocked', + scene: null, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } +} + +/** + * Synchronous baseline used only by print fixtures while backend correctness + * is evaluated. It unions world-space static meshes and preserves source node + * IDs at result level; it does not yet run in a worker or provide face-level + * provenance. + */ +export function compilePrintShellBaseline(source: THREE.Object3D): PrintShellCompileResult { + const { diagnostics, geometries, inputMeshCount, sourceNodeIds } = collectPrintShellInput(source) + if (diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + for (const geometry of geometries) geometry.dispose() + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } + + const material = new THREE.MeshStandardMaterial() + const ownedGeometries = new Set(geometries) + const brushes = geometries.map((geometry) => { + const brush = new Brush(geometry, material) + prepareBrushForCSG(brush) + return brush + }) + + let current = brushes[0]! + try { + for (let index = 1; index < brushes.length; index += 1) { + const next = csgEvaluator.evaluate(current, brushes[index]!, ADDITION) as Brush + prepareBrushForCSG(next) + ownedGeometries.add(next.geometry) + current = next + } + + const geometry = csgGeometry(current).clone() + geometry.clearGroups() + const count = geometry.getIndex()?.count ?? geometry.getAttribute('position').count + if (count > 0) geometry.addGroup(0, count, 0) + geometry.computeVertexNormals() + + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) + mesh.name = 'print-shell-baseline' + mesh.userData = { + printCompiler: 'pascal-three-bvh-csg', + sourceNodeIds: Array.from(sourceNodeIds).sort(), + } + const scene = new THREE.Group() + scene.name = 'compiled-print-shell' + scene.add(mesh) + + diagnostics.push({ + severity: 'info', + code: 'baseline_compiler', + message: + 'Compiled with the synchronous Pascal Three/CSG baseline; worker scheduling and face-level provenance remain pending.', + nodeIds: Array.from(sourceNodeIds).sort(), + }) + + return { + backend: 'pascal-three-bvh-csg', + status: 'compiled', + scene, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } + } catch (error) { + diagnostics.push({ + severity: 'error', + code: 'shell_union_failed', + message: error instanceof Error ? error.message : 'The baseline shell union failed.', + nodeIds: Array.from(sourceNodeIds).sort(), + }) + return blockedResult(inputMeshCount, sourceNodeIds, diagnostics) + } finally { + for (const geometry of ownedGeometries) geometry.dispose() + material.dispose() + } +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-core.ts b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts new file mode 100644 index 0000000000..082dcb835d --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold-core.ts @@ -0,0 +1,330 @@ +import ManifoldModule, { type Manifold as ManifoldSolid, type ManifoldToplevel } from 'manifold-3d' +import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline' +import type { ManifoldCompileOutput, ManifoldMeshData } from './print-shell-compiler-protocol' + +let modulePromise: Promise | null = null +const MANIFOLD_OUTPUT_WELD_EPSILON_METERS = 2e-5 +const COLLINEAR_SEAM_CROSS_LENGTH_SQ = 1e-20 + +type Triangle = [number, number, number] + +async function getManifoldModule(wasmUrl?: string): Promise { + modulePromise ??= ManifoldModule(wasmUrl ? { locateFile: () => wasmUrl } : undefined).then( + (module) => { + module.setup() + return module + }, + ) + return modulePromise +} + +function manifoldMesh( + module: ManifoldToplevel, + mesh: ManifoldMeshData, +): InstanceType { + return new module.Mesh({ + numProp: 3, + vertProperties: mesh.positions, + triVerts: mesh.indices, + }) +} + +function distanceSquared(positions: Float32Array, left: number, right: number): number { + const dx = positions[left * 3]! - positions[right * 3]! + const dy = positions[left * 3 + 1]! - positions[right * 3 + 1]! + const dz = positions[left * 3 + 2]! - positions[right * 3 + 2]! + return dx * dx + dy * dy + dz * dz +} + +function triangleCrossLengthSquared(positions: Float32Array, [a, b, c]: Triangle): number { + const abX = positions[b * 3]! - positions[a * 3]! + const abY = positions[b * 3 + 1]! - positions[a * 3 + 1]! + const abZ = positions[b * 3 + 2]! - positions[a * 3 + 2]! + const acX = positions[c * 3]! - positions[a * 3]! + const acY = positions[c * 3 + 1]! - positions[a * 3 + 1]! + const acZ = positions[c * 3 + 2]! - positions[a * 3 + 2]! + const crossX = abY * acZ - abZ * acY + const crossY = abZ * acX - abX * acZ + const crossZ = abX * acY - abY * acX + return crossX * crossX + crossY * crossY + crossZ * crossZ +} + +function collinearSeam( + positions: Float32Array, + triangle: Triangle, +): { start: number; middle: number; end: number } | null { + if (triangleCrossLengthSquared(positions, triangle) > COLLINEAR_SEAM_CROSS_LENGTH_SQ) { + return null + } + + const [a, b, c] = triangle + const edges = [ + { start: a, middle: c, end: b, lengthSquared: distanceSquared(positions, a, b) }, + { start: b, middle: a, end: c, lengthSquared: distanceSquared(positions, b, c) }, + { start: c, middle: b, end: a, lengthSquared: distanceSquared(positions, c, a) }, + ].sort((left, right) => right.lengthSquared - left.lengthSquared) + const longest = edges[0]! + if (longest.lengthSquared === 0) return null + + const startOffset = longest.start * 3 + const middleOffset = longest.middle * 3 + const endOffset = longest.end * 3 + const edgeX = positions[endOffset]! - positions[startOffset]! + const edgeY = positions[endOffset + 1]! - positions[startOffset + 1]! + const edgeZ = positions[endOffset + 2]! - positions[startOffset + 2]! + const middleX = positions[middleOffset]! - positions[startOffset]! + const middleY = positions[middleOffset + 1]! - positions[startOffset + 1]! + const middleZ = positions[middleOffset + 2]! - positions[startOffset + 2]! + const projection = middleX * edgeX + middleY * edgeY + middleZ * edgeZ + if (projection <= 0 || projection >= longest.lengthSquared) return null + + return longest +} + +function stitchCollinearSeams(positions: Float32Array, input: Triangle[]): Triangle[] { + const triangles: Array = [...input] + + // Manifold can encode a T-junction as one zero-area triangle: one surface owns the long + // edge while the other owns its two segments. Split the neighboring face at the middle + // vertex before removing the collapsed face so indexed edge incidence remains closed. + for (let triangleIndex = 0; triangleIndex < triangles.length; triangleIndex += 1) { + const triangle = triangles[triangleIndex] + if (!triangle) continue + const seam = collinearSeam(positions, triangle) + if (!seam) continue + + const matches: Array<{ index: number; edgeOffset: number }> = [] + for (let candidateIndex = 0; candidateIndex < triangles.length; candidateIndex += 1) { + if (candidateIndex === triangleIndex) continue + const candidate = triangles[candidateIndex] + if ( + !candidate || + triangleCrossLengthSquared(positions, candidate) <= COLLINEAR_SEAM_CROSS_LENGTH_SQ + ) { + continue + } + for (let edgeOffset = 0; edgeOffset < 3; edgeOffset += 1) { + const from = candidate[edgeOffset]! + const to = candidate[(edgeOffset + 1) % 3]! + if ((from === seam.start && to === seam.end) || (from === seam.end && to === seam.start)) { + matches.push({ index: candidateIndex, edgeOffset }) + } + } + } + if (matches.length !== 1) continue + + const match = matches[0]! + const neighbor = triangles[match.index]! + const from = neighbor[match.edgeOffset]! + const to = neighbor[(match.edgeOffset + 1) % 3]! + const opposite = neighbor[(match.edgeOffset + 2) % 3]! + triangles[match.index] = [from, seam.middle, opposite] + triangles.push([seam.middle, to, opposite]) + triangles[triangleIndex] = null + } + + return triangles.filter((triangle): triangle is Triangle => triangle !== null) +} + +function manifoldOutput(solid: ManifoldSolid): { positions: Float32Array; indices: Uint32Array } { + const mesh = solid.getMesh() + const positions = new Float32Array(mesh.numVert * 3) + for (let index = 0; index < mesh.numVert; index += 1) { + const sourceOffset = index * mesh.numProp + positions[index * 3] = mesh.vertProperties[sourceOffset]! + positions[index * 3 + 1] = mesh.vertProperties[sourceOffset + 1]! + positions[index * 3 + 2] = mesh.vertProperties[sourceOffset + 2]! + } + + const parents = new Uint32Array(mesh.numVert) + for (let index = 0; index < parents.length; index += 1) parents[index] = index + const find = (index: number): number => { + let root = index + while (parents[root] !== root) root = parents[root]! + while (parents[index] !== index) { + const next = parents[index]! + parents[index] = root + index = next + } + return root + } + for (let index = 0; index < mesh.mergeFromVert.length; index += 1) { + parents[find(mesh.mergeFromVert[index]!)] = find(mesh.mergeToVert[index]!) + } + + // Float32 boolean output can leave seam vertices just over 10 microns apart. Dropping the + // resulting sliver triangle by area opens the shell; weld the vertices first so adjacent + // faces inherit one indexed edge, then remove only triangles collapsed by that topology. + const cellSize = MANIFOLD_OUTPUT_WELD_EPSILON_METERS + const cellRoots = new Map() + const cellCoordinate = (value: number) => Math.floor(value / cellSize) + const cellKey = (x: number, y: number, z: number) => `${x},${y},${z}` + const weldDistanceSquared = cellSize * cellSize + for (let index = 0; index < mesh.numVert; index += 1) { + const root = find(index) + if (root !== index) continue + const cellX = cellCoordinate(positions[root * 3]!) + const cellY = cellCoordinate(positions[root * 3 + 1]!) + const cellZ = cellCoordinate(positions[root * 3 + 2]!) + let weldedTo: number | null = null + for (let xOffset = -1; xOffset <= 1 && weldedTo === null; xOffset += 1) { + for (let yOffset = -1; yOffset <= 1 && weldedTo === null; yOffset += 1) { + for (let zOffset = -1; zOffset <= 1 && weldedTo === null; zOffset += 1) { + const candidates = cellRoots.get( + cellKey(cellX + xOffset, cellY + yOffset, cellZ + zOffset), + ) + for (const candidate of candidates ?? []) { + if (distanceSquared(positions, root, candidate) <= weldDistanceSquared) { + weldedTo = candidate + break + } + } + } + } + } + if (weldedTo === null) { + const key = cellKey(cellX, cellY, cellZ) + const roots = cellRoots.get(key) ?? [] + roots.push(root) + cellRoots.set(key, roots) + } else { + parents[root] = find(weldedTo) + } + } + + const triangles: Triangle[] = [] + for (let index = 0; index + 2 < mesh.triVerts.length; index += 3) { + const a = find(mesh.triVerts[index]!) + const b = find(mesh.triVerts[index + 1]!) + const c = find(mesh.triVerts[index + 2]!) + if (a === b || b === c || c === a) continue + triangles.push([a, b, c]) + } + const indices = stitchCollinearSeams(positions, triangles).flat() + + return { positions, indices: new Uint32Array(indices) } +} + +function elapsed(startedAt: number): number { + return Math.max(0, performance.now() - startedAt) +} + +export async function compileManifoldMeshData( + meshes: ManifoldMeshData[], + wasmUrl?: string, +): Promise { + const startedAt = performance.now() + const sourceNodeIds = Array.from(new Set(meshes.map((mesh) => mesh.nodeId))).sort() + const diagnostics: PrintShellCompileDiagnostic[] = [] + const solids: ManifoldSolid[] = [] + let union: ManifoldSolid | null = null + let result: ManifoldSolid | null = null + + if (meshes.length === 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'no_shell_meshes', + message: 'No structural meshes are available for Manifold compilation.', + nodeIds: [], + }, + ], + durationMs: elapsed(startedAt), + } + } + + try { + const module = await getManifoldModule(wasmUrl) + for (const mesh of meshes) { + try { + solids.push(new module.Manifold(manifoldMesh(module, mesh))) + } catch (error) { + diagnostics.push({ + severity: 'error', + code: 'manifold_input_failed', + message: `Node ${mesh.nodeId}: ${ + error instanceof Error ? error.message : 'Manifold rejected the shell input.' + }`, + nodeIds: [mesh.nodeId], + }) + } + } + if (diagnostics.length > 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics, + durationMs: elapsed(startedAt), + } + } + + union = module.Manifold.union(solids) + result = union.asOriginal() + const status = result.status() + if (status !== 'NoError') { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_union_failed', + message: `Manifold union failed with ${status}.`, + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } + + const output = manifoldOutput(result) + if (output.indices.length === 0) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_union_failed', + message: 'Manifold produced no printable triangles.', + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } + return { + status: 'compiled', + positions: output.positions, + indices: output.indices, + diagnostics: [], + durationMs: elapsed(startedAt), + } + } catch (error) { + return { + status: 'blocked', + positions: null, + indices: null, + diagnostics: [ + { + severity: 'error', + code: 'manifold_worker_failed', + message: error instanceof Error ? error.message : 'Manifold compilation failed.', + nodeIds: sourceNodeIds, + }, + ], + durationMs: elapsed(startedAt), + } + } finally { + for (const solid of solids) solid.delete() + union?.delete() + result?.delete() + } +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts new file mode 100644 index 0000000000..f4ddb4c855 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold-worker.ts @@ -0,0 +1,187 @@ +import type { AnyNode } from '@pascal-app/core' +import * as THREE from 'three' +import { + prepareSemanticPrintShellSource, + type SemanticPrintCompileOptions, +} from './print-shell-compiler' +import { + collectPrintShellInput, + type PrintShellCompileDiagnostic, + type PrintShellCompileResult, +} from './print-shell-compiler-baseline' +import { + geometryFromManifoldMeshData, + geometryToManifoldMeshData, +} from './print-shell-compiler-mesh-data' +import type { + ManifoldCompileOutput, + ManifoldMeshData, + ManifoldWorkerRequest, + ManifoldWorkerResponse, +} from './print-shell-compiler-protocol' + +const WORKER_TIMEOUT_MS = 60_000 + +export type ManifoldCompileRunner = (meshes: ManifoldMeshData[]) => Promise + +export type SemanticManifoldCompileOptions = SemanticPrintCompileOptions & { + runner?: ManifoldCompileRunner +} + +type PendingRequest = { + resolve: (output: ManifoldCompileOutput) => void + reject: (error: Error) => void + timeout: ReturnType +} + +let worker: Worker | null = null +let nextRequestId = 1 +const pendingRequests = new Map() + +function resetWorker(error: Error) { + worker?.terminate() + worker = null + for (const pending of pendingRequests.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + pendingRequests.clear() +} + +function getWorker(): Worker { + if (worker) return worker + if (typeof Worker === 'undefined') { + throw new Error('Web Workers are unavailable in this environment.') + } + worker = new Worker(new URL('./print-shell-compiler-manifold.worker.ts', import.meta.url), { + type: 'module', + }) + worker.addEventListener('message', (event: MessageEvent) => { + const pending = pendingRequests.get(event.data.id) + if (!pending) return + pendingRequests.delete(event.data.id) + clearTimeout(pending.timeout) + pending.resolve(event.data) + }) + worker.addEventListener('error', (event) => { + resetWorker(new Error(event.message || 'The Manifold worker failed.')) + }) + worker.addEventListener('messageerror', () => { + resetWorker(new Error('The Manifold worker returned an unreadable response.')) + }) + return worker +} + +export const runManifoldWorker: ManifoldCompileRunner = (meshes) => { + const activeWorker = getWorker() + const id = nextRequestId + nextRequestId += 1 + const request: ManifoldWorkerRequest = { id, meshes } + const transfer = meshes.flatMap((mesh) => [ + mesh.positions.buffer as ArrayBuffer, + mesh.indices.buffer as ArrayBuffer, + ]) + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + resetWorker(new Error(`The Manifold worker exceeded ${WORKER_TIMEOUT_MS / 1000} seconds.`)) + }, WORKER_TIMEOUT_MS) + pendingRequests.set(id, { resolve, reject, timeout }) + try { + activeWorker.postMessage(request, transfer) + } catch (error) { + resetWorker( + error instanceof Error ? error : new Error('Failed to start the Manifold worker.'), + ) + } + }) +} + +function blockedResult( + inputMeshCount: number, + sourceNodeIds: Iterable, + diagnostics: PrintShellCompileDiagnostic[], +): PrintShellCompileResult { + return { + backend: 'manifold-3d', + status: 'blocked', + scene: null, + inputMeshCount, + sourceNodeIds: Array.from(sourceNodeIds).sort(), + diagnostics, + } +} + +export async function compileSemanticPrintShellWithManifold( + source: THREE.Object3D, + nodes: Record, + options: SemanticManifoldCompileOptions = {}, +): Promise { + const { runner = runManifoldWorker, ...semanticOptions } = options + const prepared = prepareSemanticPrintShellSource(source, nodes, { + ...semanticOptions, + wallSolids: semanticOptions.wallSolids ?? true, + }) + if (prepared.status === 'blocked') { + return blockedResult(prepared.inputMeshCount, prepared.sourceNodeIds, prepared.diagnostics) + } + + const input = collectPrintShellInput(prepared.scene) + prepared.dispose() + if (input.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + for (const geometry of input.geometries) geometry.dispose() + return blockedResult(input.inputMeshCount, input.sourceNodeIds, input.diagnostics) + } + + const meshes = input.geometries.map((geometry, index) => + geometryToManifoldMeshData(geometry, input.geometryNodeIds[index]!), + ) + for (const geometry of input.geometries) geometry.dispose() + + let output: ManifoldCompileOutput + try { + output = await runner(meshes) + } catch (error) { + return blockedResult(input.inputMeshCount, input.sourceNodeIds, [ + ...input.diagnostics, + { + severity: 'error', + code: 'manifold_worker_failed', + message: error instanceof Error ? error.message : 'The Manifold worker failed.', + nodeIds: Array.from(input.sourceNodeIds).sort(), + }, + ]) + } + + const diagnostics = [...input.diagnostics, ...output.diagnostics] + if (output.status === 'blocked') { + return blockedResult(input.inputMeshCount, input.sourceNodeIds, diagnostics) + } + + const geometry = geometryFromManifoldMeshData(output.positions, output.indices) + const mesh = new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()) + mesh.name = 'print-shell-manifold' + mesh.userData = { + printCompiler: 'manifold-3d', + sourceNodeIds: Array.from(input.sourceNodeIds).sort(), + } + const scene = new THREE.Group() + scene.name = 'compiled-print-shell' + scene.add(mesh) + diagnostics.push({ + severity: 'info', + code: runner === runManifoldWorker ? 'manifold_worker_compiler' : 'manifold_compiler_candidate', + message: `Compiled with Manifold in ${output.durationMs.toFixed(1)} ms${ + runner === runManifoldWorker ? ' off the main thread' : ' through the in-process test runner' + }.`, + nodeIds: Array.from(input.sourceNodeIds).sort(), + }) + return { + backend: 'manifold-3d', + status: 'compiled', + scene, + inputMeshCount: input.inputMeshCount, + sourceNodeIds: Array.from(input.sourceNodeIds).sort(), + diagnostics, + } +} diff --git a/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts new file mode 100644 index 0000000000..49b2739691 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-manifold.worker.ts @@ -0,0 +1,20 @@ +import { compileManifoldMeshData } from './print-shell-compiler-manifold-core' +import type { ManifoldWorkerRequest, ManifoldWorkerResponse } from './print-shell-compiler-protocol' + +const workerScope = self as unknown as { + addEventListener: ( + type: 'message', + listener: (event: MessageEvent) => void, + ) => void + postMessage: (response: ManifoldWorkerResponse, transfer: Transferable[]) => void +} + +workerScope.addEventListener('message', async (event) => { + const output = await compileManifoldMeshData(event.data.meshes) + const response: ManifoldWorkerResponse = { id: event.data.id, ...output } + const transfer: Transferable[] = [] + if (response.status === 'compiled') { + transfer.push(response.positions.buffer as ArrayBuffer, response.indices.buffer as ArrayBuffer) + } + workerScope.postMessage(response, transfer) +}) diff --git a/packages/editor/src/lib/print-shell-compiler-mesh-data.ts b/packages/editor/src/lib/print-shell-compiler-mesh-data.ts new file mode 100644 index 0000000000..317edb3c1a --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-mesh-data.ts @@ -0,0 +1,32 @@ +import * as THREE from 'three' +import type { ManifoldMeshData } from './print-shell-compiler-protocol' + +export function geometryToManifoldMeshData( + geometry: THREE.BufferGeometry, + nodeId: string, +): ManifoldMeshData { + const position = geometry.getAttribute('position') + const positions = new Float32Array(position.count * 3) + for (let index = 0; index < position.count; index += 1) { + positions[index * 3] = position.getX(index) + positions[index * 3 + 1] = position.getY(index) + positions[index * 3 + 2] = position.getZ(index) + } + const geometryIndex = geometry.getIndex() + const indices = new Uint32Array(geometryIndex?.count ?? position.count) + for (let index = 0; index < indices.length; index += 1) { + indices[index] = geometryIndex?.getX(index) ?? index + } + return { nodeId, positions, indices } +} + +export function geometryFromManifoldMeshData( + positions: Float32Array, + indices: Uint32Array, +): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setIndex(new THREE.BufferAttribute(indices, 1)) + geometry.computeVertexNormals() + return geometry +} diff --git a/packages/editor/src/lib/print-shell-compiler-protocol.ts b/packages/editor/src/lib/print-shell-compiler-protocol.ts new file mode 100644 index 0000000000..8554f1e2e8 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler-protocol.ts @@ -0,0 +1,30 @@ +import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline' + +export type ManifoldMeshData = { + nodeId: string + positions: Float32Array + indices: Uint32Array +} + +export type ManifoldCompileOutput = + | { + status: 'compiled' + positions: Float32Array + indices: Uint32Array + diagnostics: PrintShellCompileDiagnostic[] + durationMs: number + } + | { + status: 'blocked' + positions: null + indices: null + diagnostics: PrintShellCompileDiagnostic[] + durationMs: number + } + +export type ManifoldWorkerRequest = { + id: number + meshes: ManifoldMeshData[] +} + +export type ManifoldWorkerResponse = ManifoldCompileOutput & { id: number } diff --git a/packages/editor/src/lib/print-shell-compiler.ts b/packages/editor/src/lib/print-shell-compiler.ts new file mode 100644 index 0000000000..f0165dcf21 --- /dev/null +++ b/packages/editor/src/lib/print-shell-compiler.ts @@ -0,0 +1,273 @@ +import { + type AnyNode, + getWallEffectiveHeightForNodes, + type RoofSegmentNode, + resolveLevelId, + spatialGridManager, + type WallNode, +} from '@pascal-app/core' +import { disposeObject3DResources } from '@pascal-app/viewer' +import * as THREE from 'three' +import { buildPrintableRoofSegmentSolids } from './print-roof-solids' +import { + compilePrintShellBaseline, + type PrintShellCompileDiagnostic, + type PrintShellCompileResult, +} from './print-shell-compiler-baseline' +import { buildPrintableWallSolids } from './print-wall-solids' + +export type SemanticPrintCompileOptions = { + wallSolids?: boolean +} + +export type SemanticPrintSourceResult = + | { + status: 'ready' + scene: THREE.Object3D + diagnostics: [] + dispose: () => void + } + | { + status: 'blocked' + scene: null + inputMeshCount: number + sourceNodeIds: string[] + diagnostics: PrintShellCompileDiagnostic[] + dispose: () => void + } + +function meshCount(root: THREE.Object3D): number { + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + const position = mesh.isMesh ? mesh.geometry?.getAttribute('position') : null + if (position && position.count > 0) count += 1 + }) + return count +} + +function replaceChild(parent: THREE.Object3D, target: THREE.Object3D, replacement: THREE.Object3D) { + const targetIndex = parent.children.indexOf(target) + parent.remove(target) + parent.add(replacement) + const appendedIndex = parent.children.indexOf(replacement) + parent.children.splice(appendedIndex, 1) + parent.children.splice(targetIndex, 0, replacement) +} + +function copyPreparedTransform( + source: THREE.Object3D, + target: THREE.Object3D, + printSource: 'canonical-roof' | 'canonical-wall', +) { + target.name = source.name + target.position.copy(source.position) + target.quaternion.copy(source.quaternion) + target.scale.copy(source.scale) + target.matrix.copy(source.matrix) + target.matrixAutoUpdate = source.matrixAutoUpdate + target.visible = source.visible + target.layers.mask = source.layers.mask + target.userData = { ...source.userData, printSource } +} + +function disposeGenerated(root: THREE.Object3D) { + disposeObject3DResources(root) +} + +function exportedIdentityIds(root: THREE.Object3D): Set { + const ids = new Set() + root.traverse((object) => { + if (typeof object.userData.pascalId === 'string') ids.add(object.userData.pascalId) + }) + return ids +} + +function ownedLocalYBounds(root: THREE.Object3D): { min: number; max: number } | null { + root.updateMatrixWorld(true) + const inverseRoot = root.matrixWorld.clone().invert() + const point = new THREE.Vector3() + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + + const visit = (object: THREE.Object3D) => { + if ( + object !== root && + typeof object.userData.pascalId === 'string' && + object.userData.pascalId !== root.userData.pascalId + ) { + return + } + const mesh = object as THREE.Mesh + const position = mesh.isMesh ? mesh.geometry.getAttribute('position') : null + if (position) { + const toRoot = inverseRoot.clone().multiply(object.matrixWorld) + for (let index = 0; index < position.count; index += 1) { + point.fromBufferAttribute(position, index).applyMatrix4(toRoot) + min = Math.min(min, point.y) + max = Math.max(max, point.y) + } + } + for (const child of object.children) visit(child) + } + visit(root) + return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : null +} + +function preparedWallHeight( + node: WallNode, + object: THREE.Object3D, + nodes: Record, +): + | { height: number; diagnostic: null } + | { height: null; diagnostic: PrintShellCompileDiagnostic } { + const levelId = resolveLevelId(node, nodes) + const support = spatialGridManager.getSlabSupportForWall( + levelId, + node.start, + node.end, + node.curveOffset ?? 0, + node.thickness, + node.supportSlabId ?? null, + undefined, + node.supportOffset, + ) + const hasDisplacedBase = + Math.abs(support.baseElevation - support.elevation) > 1e-5 || + support.baseSegments.some((segment) => Math.abs(segment.elevation - support.elevation) > 1e-5) + const bounds = ownedLocalYBounds(object) + if (hasDisplacedBase || (bounds && bounds.max > 1e-7 && Math.abs(bounds.min) > 1e-5)) { + return { + height: null, + diagnostic: { + severity: 'error', + code: 'unsupported_wall_print_base', + message: `Wall ${node.id} has a stepped or displaced local base that does not yet have a canonical printable solid.`, + nodeIds: [node.id], + }, + } + } + + const height = getWallEffectiveHeightForNodes(node, nodes) + if (!Number.isFinite(height) || height <= 1e-7) { + return { + height: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_dimensions', + message: `Wall ${node.id} has no finite semantic height for print compilation.`, + nodeIds: [node.id], + }, + } + } + return { height, diagnostic: null } +} + +export function prepareSemanticPrintShellSource( + source: THREE.Object3D, + nodes: Record, + options: SemanticPrintCompileOptions = {}, +): SemanticPrintSourceResult { + const scene = new THREE.Group() + scene.name = 'semantic-print-source' + scene.add(source.clone(true)) + + const includedNodeIds = exportedIdentityIds(scene) + const roofTargets: { node: RoofSegmentNode; object: THREE.Object3D }[] = [] + const wallTargets: { node: WallNode; object: THREE.Object3D }[] = [] + scene.traverse((object) => { + const id = object.userData.pascalId + const node = typeof id === 'string' ? nodes[id] : undefined + if (node?.type === 'roof-segment') roofTargets.push({ node, object }) + if (options.wallSolids && node?.type === 'wall') wallTargets.push({ node, object }) + }) + + const diagnostics: PrintShellCompileDiagnostic[] = [] + const replacements: { target: THREE.Object3D; replacement: THREE.Group }[] = [] + for (const { node, object } of roofTargets) { + const result = buildPrintableRoofSegmentSolids(node, nodes) + if (result.status === 'blocked') { + diagnostics.push(...result.diagnostics) + continue + } + copyPreparedTransform(object, result.object, 'canonical-roof') + replacements.push({ target: object, replacement: result.object }) + } + for (const { node, object } of wallTargets) { + const prepared = preparedWallHeight(node, object, nodes) + if (prepared.diagnostic) { + diagnostics.push(prepared.diagnostic) + continue + } + const result = buildPrintableWallSolids( + node, + { effectiveHeight: prepared.height, includedNodeIds }, + nodes, + ) + if (result.status === 'blocked') { + diagnostics.push(...result.diagnostics) + continue + } + copyPreparedTransform(object, result.object, 'canonical-wall') + replacements.push({ target: object, replacement: result.object }) + } + + if (diagnostics.length > 0) { + for (const { replacement } of replacements) disposeGenerated(replacement) + return { + status: 'blocked', + scene: null, + inputMeshCount: meshCount(scene), + sourceNodeIds: Array.from( + new Set(diagnostics.flatMap((diagnostic) => diagnostic.nodeIds)), + ).sort(), + diagnostics, + dispose: () => {}, + } + } + + for (const { target, replacement } of replacements) { + if (target.parent) replaceChild(target.parent, target, replacement) + } + + let disposed = false + return { + status: 'ready', + scene, + diagnostics: [], + dispose: () => { + if (disposed) return + disposed = true + for (const { replacement } of replacements) disposeGenerated(replacement) + }, + } +} + +/** + * Compiles a semantic structural source instead of trusting display aggregates. + * Roof segments are replaced as complete identity subtrees so their hosted + * display CSG and accessory meshes cannot leak into the manufacturing shell. + */ +export function compileSemanticPrintShell( + source: THREE.Object3D, + nodes: Record, + options: SemanticPrintCompileOptions = {}, +): PrintShellCompileResult { + const prepared = prepareSemanticPrintShellSource(source, nodes, options) + if (prepared.status === 'blocked') { + return { + backend: 'pascal-three-bvh-csg', + status: 'blocked', + scene: null, + inputMeshCount: prepared.inputMeshCount, + sourceNodeIds: prepared.sourceNodeIds, + diagnostics: prepared.diagnostics, + } + } + + try { + return compilePrintShellBaseline(prepared.scene) + } finally { + prepared.dispose() + } +} diff --git a/packages/editor/src/lib/print-wall-solids.test.ts b/packages/editor/src/lib/print-wall-solids.test.ts new file mode 100644 index 0000000000..af82679574 --- /dev/null +++ b/packages/editor/src/lib/print-wall-solids.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'bun:test' +import { DoorNode, WallNode, WindowNode } from '@pascal-app/core' +import * as THREE from 'three' +import { buildPrintableWallSolids } from './print-wall-solids' + +function rayIntersectionCount(root: THREE.Object3D, x: number, y: number): number { + root.updateMatrixWorld(true) + const raycaster = new THREE.Raycaster(new THREE.Vector3(x, y, -1), new THREE.Vector3(0, 0, 1)) + const material = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }) + let count = 0 + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const originalMaterial = mesh.material + mesh.material = material + count += raycaster.intersectObject(mesh, false).length + mesh.material = originalMaterial + }) + material.dispose() + return count +} + +function expectClosedBoxMeshes(root: THREE.Group) { + for (const object of root.children) { + const mesh = object as THREE.Mesh + expect(mesh.isMesh).toBe(true) + const index = mesh.geometry.getIndex() + expect(index).not.toBeNull() + const edges = new Map() + const add = (a: number, b: number) => { + const key = a < b ? `${a}|${b}` : `${b}|${a}` + edges.set(key, (edges.get(key) ?? 0) + 1) + } + for (let offset = 0; offset + 2 < index!.count; offset += 3) { + const a = index!.getX(offset) + const b = index!.getX(offset + 1) + const c = index!.getX(offset + 2) + add(a, b) + add(b, c) + add(c, a) + } + expect(Array.from(edges.values()).every((uses) => uses === 2)).toBe(true) + } +} + +function dispose(root: THREE.Group) { + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (mesh.isMesh) mesh.geometry.dispose() + }) +} + +describe('buildPrintableWallSolids', () => { + test('builds deterministic closed solids around rectangular door and window voids', () => { + const door = DoorNode.parse({ + id: 'door_print-wall', + wallId: 'wall_print-openings', + position: [1.5, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_print-wall', + wallId: 'wall_print-openings', + position: [4.5, 1.4, 0], + width: 1.2, + height: 1.0, + }) + const wall = WallNode.parse({ + id: 'wall_print-openings', + start: [0, 0], + end: [6, 0], + height: 2.5, + thickness: 0.2, + children: [door.id, window.id], + }) + const nodes = { [door.id]: door, [window.id]: window } + const first = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, nodes) + const second = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, nodes) + + expect(first.status).toBe('ready') + expect(second.status).toBe('ready') + expect(first.object).not.toBeNull() + expect(second.object).not.toBeNull() + expect(first.object!.userData.pascalId).toBe(wall.id) + expectClosedBoxMeshes(first.object!) + + const bounds = new THREE.Box3().setFromObject(first.object!) + expect(bounds.min.x).toBeCloseTo(0, 6) + expect(bounds.min.y).toBeCloseTo(0, 6) + expect(bounds.min.z).toBeCloseTo(-0.1, 6) + expect(bounds.max.x).toBeCloseTo(6, 6) + expect(bounds.max.y).toBeCloseTo(2.5, 6) + expect(bounds.max.z).toBeCloseTo(0.1, 6) + expect(rayIntersectionCount(first.object!, 1.5, 1)).toBe(0) + expect(rayIntersectionCount(first.object!, 4.5, 1.4)).toBe(0) + expect(rayIntersectionCount(first.object!, 3, 1)).toBeGreaterThanOrEqual(2) + + expect(first.object!.children.map((child) => child.position.toArray())).toEqual( + second.object!.children.map((child) => child.position.toArray()), + ) + expect( + first.object!.children.map((child) => + Array.from((child as THREE.Mesh).geometry.getAttribute('position').array), + ), + ).toEqual( + second.object!.children.map((child) => + Array.from((child as THREE.Mesh).geometry.getAttribute('position').array), + ), + ) + + const hiddenOpenings = buildPrintableWallSolids( + wall, + { effectiveHeight: 2.5, includedNodeIds: new Set() }, + nodes, + ) + expect(hiddenOpenings.status).toBe('ready') + expect(rayIntersectionCount(hiddenOpenings.object!, 1.5, 1)).toBeGreaterThanOrEqual(2) + + dispose(first.object!) + dispose(second.object!) + dispose(hiddenOpenings.object!) + }) + + test('preserves the authored wall-local transform', () => { + const wall = WallNode.parse({ + id: 'wall_print-transform', + start: [1, 2], + end: [1, 6], + thickness: 0.2, + }) + const result = buildPrintableWallSolids(wall, { effectiveHeight: 3 }) + + expect(result.status).toBe('ready') + expect(result.object?.position.toArray()).toEqual([1, 0, 2]) + expect(result.object?.rotation.y).toBeCloseTo(-Math.PI / 2) + dispose(result.object!) + }) + + test('blocks unsupported wall forms and invalid opening contracts', () => { + const shaped = DoorNode.parse({ + id: 'door_print-wall-arch', + wallId: 'wall_print-blocked', + position: [2, 1.05, 0], + openingShape: 'arch', + }) + const wall = WallNode.parse({ + id: 'wall_print-blocked', + start: [0, 0], + end: [4, 0], + children: [shaped.id], + }) + const curved = buildPrintableWallSolids( + { ...wall, curveOffset: 0.5 }, + { effectiveHeight: 2.5 }, + { [shaped.id]: shaped }, + ) + const terrain = buildPrintableWallSolids( + { ...wall, children: [], fillToTerrain: true }, + { effectiveHeight: 2.5 }, + ) + const shapedResult = buildPrintableWallSolids( + wall, + { effectiveHeight: 2.5 }, + { [shaped.id]: shaped }, + ) + const unresolved = buildPrintableWallSolids(wall, { effectiveHeight: 2.5 }, {}) + + expect(curved).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_curve' })], + }), + ) + expect(terrain).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_terrain' })], + }), + ) + expect(shapedResult).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unsupported_wall_print_opening_shape' })], + }), + ) + expect(unresolved).toEqual( + expect.objectContaining({ + status: 'blocked', + diagnostics: [expect.objectContaining({ code: 'unresolved_wall_print_child' })], + }), + ) + }) +}) diff --git a/packages/editor/src/lib/print-wall-solids.ts b/packages/editor/src/lib/print-wall-solids.ts new file mode 100644 index 0000000000..33fe9a36e6 --- /dev/null +++ b/packages/editor/src/lib/print-wall-solids.ts @@ -0,0 +1,328 @@ +import { + type AnyNode, + type DoorNode, + getWallThickness, + type WallNode, + type WindowNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +// Manufacturing geometry belongs to the editor layer, not the read-only viewer runtime. +const DIMENSION_EPSILON = 1e-7 +const SOLID_JOIN_OVERLAP = 1e-5 + +type PrintWallOpening = DoorNode | WindowNode + +type OpeningInterval = { + node: PrintWallOpening + left: number + right: number + bottom: number + top: number +} + +export type PrintWallSolidDiagnostic = { + severity: 'error' + code: + | 'invalid_wall_print_dimensions' + | 'unsupported_wall_print_curve' + | 'unsupported_wall_print_terrain' + | 'unsupported_wall_print_opening_shape' + | 'invalid_wall_print_opening' + | 'unresolved_wall_print_child' + message: string + nodeIds: string[] +} + +export type PrintWallSolidOptions = { + effectiveHeight: number + includedNodeIds?: ReadonlySet +} + +export type PrintWallSolidResult = + | { status: 'ready'; object: THREE.Group; diagnostics: [] } + | { status: 'blocked'; object: null; diagnostics: PrintWallSolidDiagnostic[] } + +function finite(values: number[]): boolean { + return values.every(Number.isFinite) +} + +function openingInterval( + wall: WallNode, + opening: PrintWallOpening, + length: number, + height: number, +): { interval: OpeningInterval | null; diagnostic: PrintWallSolidDiagnostic | null } { + if (opening.wallId && opening.wallId !== wall.id) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} is listed by wall ${wall.id} but references ${opening.wallId}.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + if (opening.openingShape !== 'rectangle') { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'unsupported_wall_print_opening_shape', + message: `Opening ${opening.id} uses a ${opening.openingShape} profile that does not yet have a printable wall fixture.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + const [centerX, centerY] = opening.position + const { width, height: openingHeight } = opening + if ( + !finite([centerX, centerY, width, openingHeight]) || + width <= DIMENSION_EPSILON || + openingHeight <= DIMENSION_EPSILON + ) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} has invalid printable dimensions.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + const interval = { + node: opening, + left: centerX - width / 2, + right: centerX + width / 2, + bottom: centerY - openingHeight / 2, + top: centerY + openingHeight / 2, + } + if ( + interval.left < -DIMENSION_EPSILON || + interval.right > length + DIMENSION_EPSILON || + interval.bottom < -DIMENSION_EPSILON || + interval.top > height + DIMENSION_EPSILON || + interval.left >= interval.right - DIMENSION_EPSILON || + interval.bottom >= interval.top - DIMENSION_EPSILON + ) { + return { + interval: null, + diagnostic: { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Opening ${opening.id} extends outside printable wall ${wall.id}.`, + nodeIds: [wall.id, opening.id].sort(), + }, + } + } + + interval.left = THREE.MathUtils.clamp(interval.left, 0, length) + interval.right = THREE.MathUtils.clamp(interval.right, 0, length) + interval.bottom = THREE.MathUtils.clamp(interval.bottom, 0, height) + interval.top = THREE.MathUtils.clamp(interval.top, 0, height) + return { interval, diagnostic: null } +} + +function collectOpenings( + wall: WallNode, + nodes: Record | undefined, + options: PrintWallSolidOptions, + length: number, +): { openings: OpeningInterval[]; diagnostics: PrintWallSolidDiagnostic[] } { + const openings: OpeningInterval[] = [] + const diagnostics: PrintWallSolidDiagnostic[] = [] + + for (const childId of wall.children) { + const child = nodes?.[childId] + if (!child) { + diagnostics.push({ + severity: 'error', + code: 'unresolved_wall_print_child', + message: `Wall ${wall.id} references unresolved child ${childId}.`, + nodeIds: [wall.id, childId].sort(), + }) + continue + } + if (options.includedNodeIds && !options.includedNodeIds.has(child.id)) continue + if (child.type !== 'door' && child.type !== 'window') continue + + const result = openingInterval(wall, child, length, options.effectiveHeight) + if (result.diagnostic) diagnostics.push(result.diagnostic) + if (result.interval) openings.push(result.interval) + } + + return { openings, diagnostics } +} + +function uniqueBreakpoints(values: number[]): number[] { + const sorted = [...values].sort((a, b) => a - b) + const result: number[] = [] + for (const value of sorted) { + if (result.length === 0 || value - result[result.length - 1]! > DIMENSION_EPSILON) { + result.push(value) + } + } + return result +} + +function mergedVerticalCuts(openings: OpeningInterval[], x: number): [number, number][] { + const intervals = openings + .filter((opening) => opening.left < x && opening.right > x) + .map((opening) => [opening.bottom, opening.top] as [number, number]) + .sort((a, b) => a[0] - b[0]) + const merged: [number, number][] = [] + + for (const interval of intervals) { + const previous = merged[merged.length - 1] + if (!previous || interval[0] > previous[1] + DIMENSION_EPSILON) { + merged.push([...interval]) + } else { + previous[1] = Math.max(previous[1], interval[1]) + } + } + return merged +} + +function addSolid( + root: THREE.Group, + wallId: string, + index: number, + left: number, + right: number, + bottom: number, + top: number, + thickness: number, + wallLength: number, +) { + if (right - left <= DIMENSION_EPSILON || top - bottom <= DIMENSION_EPSILON) return + const joinedLeft = Math.max(0, left - (left > DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0)) + const joinedRight = Math.min( + wallLength, + right + (right < wallLength - DIMENSION_EPSILON ? SOLID_JOIN_OVERLAP : 0), + ) + const box = new THREE.BoxGeometry(joinedRight - joinedLeft, top - bottom, thickness) + box.deleteAttribute('normal') + box.deleteAttribute('uv') + const geometry = mergeVertices(box, DIMENSION_EPSILON) + box.dispose() + geometry.computeVertexNormals() + const mesh = new THREE.Mesh(geometry) + mesh.name = `print-wall-solid-${index}` + mesh.position.set((joinedLeft + joinedRight) / 2, (bottom + top) / 2, 0) + mesh.userData = { pascalId: wallId } + root.add(mesh) +} + +export function buildPrintableWallSolids( + node: WallNode, + options: PrintWallSolidOptions, + nodes?: Record, +): PrintWallSolidResult { + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const length = Math.hypot(dx, dz) + const thickness = getWallThickness(node) + const diagnostics: PrintWallSolidDiagnostic[] = [] + + if ( + !finite([ + node.start[0], + node.start[1], + node.end[0], + node.end[1], + length, + thickness, + options.effectiveHeight, + ]) || + length <= DIMENSION_EPSILON || + thickness <= DIMENSION_EPSILON || + options.effectiveHeight <= DIMENSION_EPSILON + ) { + diagnostics.push({ + severity: 'error', + code: 'invalid_wall_print_dimensions', + message: `Wall ${node.id} has invalid printable length, thickness, or height.`, + nodeIds: [node.id], + }) + } + if (Math.abs(node.curveOffset ?? 0) > DIMENSION_EPSILON) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_wall_print_curve', + message: `Curved wall ${node.id} does not yet have a canonical printable solid.`, + nodeIds: [node.id], + }) + } + if (node.fillToTerrain) { + diagnostics.push({ + severity: 'error', + code: 'unsupported_wall_print_terrain', + message: `Terrain-filled wall ${node.id} requires a terrain-aware printable base fixture.`, + nodeIds: [node.id], + }) + } + if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics } + + const collected = collectOpenings(node, nodes, options, length) + diagnostics.push(...collected.diagnostics) + if (diagnostics.length > 0) return { status: 'blocked', object: null, diagnostics } + + const root = new THREE.Group() + root.name = 'print-wall-solids' + root.userData = { pascalId: node.id } + root.position.set(node.start[0], 0, node.start[1]) + root.rotation.y = -Math.atan2(dz, dx) + + const breakpoints = uniqueBreakpoints([ + 0, + length, + ...collected.openings.flatMap((opening) => [opening.left, opening.right]), + ]) + let solidIndex = 0 + for (let index = 0; index < breakpoints.length - 1; index += 1) { + const left = breakpoints[index]! + const right = breakpoints[index + 1]! + if (right - left <= DIMENSION_EPSILON) continue + const cuts = mergedVerticalCuts(collected.openings, (left + right) / 2) + let bottom = 0 + for (const [cutBottom, cutTop] of cuts) { + addSolid(root, node.id, solidIndex, left, right, bottom, cutBottom, thickness, length) + if (cutBottom - bottom > DIMENSION_EPSILON) solidIndex += 1 + bottom = Math.max(bottom, cutTop) + } + addSolid( + root, + node.id, + solidIndex, + left, + right, + bottom, + options.effectiveHeight, + thickness, + length, + ) + if (options.effectiveHeight - bottom > DIMENSION_EPSILON) solidIndex += 1 + } + + if (root.children.length === 0) { + return { + status: 'blocked', + object: null, + diagnostics: [ + { + severity: 'error', + code: 'invalid_wall_print_opening', + message: `Openings remove all printable material from wall ${node.id}.`, + nodeIds: [node.id, ...collected.openings.map((opening) => opening.node.id)].sort(), + }, + ], + } + } + + return { status: 'ready', object: root, diagnostics: [] } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 417a8b1941..52ba85e977 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -40,6 +40,7 @@ import { DEFAULT_CREATABLE_MEASUREMENT_KIND, normalizeCreatableMeasurementKind, } from '../lib/measurement-kind' +import type { ModelExport } from '../lib/model-export' import { cyclePaintScope as cyclePaintScopeValue, type PaintHoverInfo, @@ -459,6 +460,8 @@ type EditorState = { // Read by the mobile layout so the viewer container can shrink to preview edits. mobilePanelSheetHeight: number setMobilePanelSheetHeight: (px: number) => void + modelExport: ModelExport | null + setModelExport: (modelExport: ModelExport | null) => void } export type PersistedEditorUiState = Pick< @@ -1365,6 +1368,8 @@ const useEditor = create()( set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }), mobilePanelSheetHeight: 0, setMobilePanelSheetHeight: (px) => set({ mobilePanelSheetHeight: Math.max(0, px) }), + modelExport: null, + setModelExport: (modelExport) => set({ modelExport }), }), { name: 'pascal-editor-ui-preferences',