diff --git a/README.md b/README.md index 1d237bcf..10832baa 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b This is a Turborepo monorepo with three main packages: ``` -editor-v2/ +editor/ ├── apps/ │ └── editor/ # Next.js application ├── packages/ │ ├── core/ # Schema definitions, state management, systems -│ └── viewer/ # 3D rendering components +│ ├── viewer/ # 3D rendering components +│ └── ui/ # Shared UI components ``` ### Separation of Concerns diff --git a/apps/editor/README.md b/apps/editor/README.md index 2321446a..ae7e0f62 100644 --- a/apps/editor/README.md +++ b/apps/editor/README.md @@ -7,7 +7,7 @@ A 3D building editor built with React Three Fiber and WebGPU. This is a Turborepo monorepo with three main packages: ``` -editor-v2/ +editor/ ├── apps/ │ └── editor/ # Next.js application (this package) ├── packages/ diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index b23495c6..0ea0b355 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -172,7 +172,8 @@ export function BuildTab() { const ductContext = mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting') const pipeContext = - mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting') + mode === 'build' && + (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting' || activeTool === 'pipe-trap') const liquidLineContext = mode === 'build' && activeTool === 'liquid-line' const isMepItemActive = (item: MepItem) => @@ -445,6 +446,30 @@ export function BuildTab() { /> Add Fitting + ) : null} diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 225f7237..ddd2c821 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -119,11 +119,12 @@ const levelModeLabels: Record = { solo: 'Solo', } -const wallModeOrder = ['cutaway', 'up', 'down'] as const +const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const const wallModeConfig: Record = { up: { icon: '/icons/room.webp', label: 'Full height' }, cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, down: { icon: '/icons/walllow.webp', label: 'Low' }, + translucent: { icon: '/icons/wall.webp', label: 'Translucent' }, } const SHADING_OPTIONS = [ diff --git a/apps/editor/package.json b/apps/editor/package.json index 25dc7e8c..5af99975 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -8,7 +8,7 @@ "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", - "check-types": "next typegen && tsc --noEmit" + "check-types": "next typegen && tsgo --noEmit" }, "dependencies": { "@iconify/react": "^6.0.2", @@ -25,7 +25,7 @@ "clsx": "^2.1.1", "geist": "^1.7.0", "lucide-react": "^1.7.0", - "next": "16.2.6", + "next": "16.2.9", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/apps/editor/public/audios/sfx/resize_0.mp3 b/apps/editor/public/audios/sfx/resize_0.mp3 new file mode 100644 index 00000000..7743b81c Binary files /dev/null and b/apps/editor/public/audios/sfx/resize_0.mp3 differ diff --git a/apps/editor/public/audios/sfx/resize_1.mp3 b/apps/editor/public/audios/sfx/resize_1.mp3 new file mode 100644 index 00000000..5aae5ce5 Binary files /dev/null and b/apps/editor/public/audios/sfx/resize_1.mp3 differ diff --git a/apps/editor/public/audios/sfx/resize_2.mp3 b/apps/editor/public/audios/sfx/resize_2.mp3 new file mode 100644 index 00000000..aa86b4cb Binary files /dev/null and b/apps/editor/public/audios/sfx/resize_2.mp3 differ diff --git a/apps/ifc-converter/components/PreviewToolbar.tsx b/apps/ifc-converter/components/PreviewToolbar.tsx index 192119dd..6ae43f52 100644 --- a/apps/ifc-converter/components/PreviewToolbar.tsx +++ b/apps/ifc-converter/components/PreviewToolbar.tsx @@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid import { type ReactNode, useMemo } from 'react' const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const -const wallModes = ['up', 'cutaway', 'down'] as const +const wallModes = ['up', 'cutaway', 'down', 'translucent'] as const const levelLabel: Record<(typeof levelModes)[number], string> = { stacked: 'Stack', @@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = { up: 'Full', cutaway: 'Cutaway', down: 'Down', + translucent: 'Translucent', } function cycle(list: readonly T[], current: T): T { diff --git a/apps/ifc-converter/package.json b/apps/ifc-converter/package.json index 0854f1a1..0827c1c4 100644 --- a/apps/ifc-converter/package.json +++ b/apps/ifc-converter/package.json @@ -11,7 +11,7 @@ "build": "next build", "start": "next start", "lint": "biome lint", - "check-types": "next typegen && tsc --noEmit" + "check-types": "next typegen && tsgo --noEmit" }, "dependencies": { "@pascal-app/core": "*", @@ -23,7 +23,7 @@ "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", - "next": "16.2.6", + "next": "16.2.9", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/bun.lock b/bun.lock index be8566b4..b100cd22 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "editor", "devDependencies": { "@biomejs/biome": "^2.4.16", + "@typescript/native-preview": "7.0.0-dev.20260624.1", "dotenv-cli": "^11.0.0", "turbo": "^2.9.17", "typescript": "6.0.3", @@ -40,7 +41,7 @@ "clsx": "^2.1.1", "geist": "^1.7.0", "lucide-react": "^1.7.0", - "next": "16.2.6", + "next": "16.2.9", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -75,7 +76,7 @@ "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", - "next": "16.2.6", + "next": "16.2.9", "postcss": "^8.5.6", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -510,25 +511,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="], + "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.19", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@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=="], @@ -890,6 +891,22 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260624.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260624.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260624.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260624.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-ogwfNo1xuAutOF8RbTCo3Ut0q/65u2ucOeHizi6O14q+3vnelNS+u8qVC2QWXubMcwtuN5E9cbfPslvGC4kdwA=="], + + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260624.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g8CqDkYCHTCYdhBHXs5cMraBurOS+KrcMFxE0SsaKZoI6Tnp+le1aWvxUBbzNKJYyThHJqb/1mLopzEJxJCuKA=="], + + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260624.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-P00JVvSV90eioYDuINAKmOSA8yhFTWLq6RvS5lrCfUuDlcgr2kSOgZAfFHIksHBVz6ZXpAXpa0dHPmc5SJ3Ymw=="], + + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "arm" }, "sha512-eWHELvfQMkVRjafMd+3ATgM9p9yAergJaM4AOY8AekCNWnHFwUrp/ohh+ryyMUIqque5jjb/kuTiOiGj728I2Q=="], + + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cppM2yTZ/Gd1hOXy8NEJcUBxJ0O0zl9CU3OU1ZWZ/OHWWX/ukEzCCr94SUwJhjIWOylBCpIYkrvYoTwxNa94XQ=="], + + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260624.1", "", { "os": "linux", "cpu": "x64" }, "sha512-FaB8rS+rKYz4nDrEsHsF3b4cn7eCKCYroMJReA375OuQ6PHcmCNQ6QlVetA0dfFBxTTgejmoKyfw9xgAA5P4Yw=="], + + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260624.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-BgkqbCmSHDb5UxqWaFlFFJ/DHNT3lEUO4W8627ap6+QthJZuXk2imiHAX3PgYXC6en9fLLyR6jjcseAa4CCshg=="], + + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260624.1", "", { "os": "win32", "cpu": "x64" }, "sha512-WaZ+ue63NgB2j/lqjirfevh/TqcsCxSqnKhGGiRnlxHyYIBcoq+x7KngyEnyGIaywJE1PcFeXA+2EMSIPlSEiQ=="], + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], @@ -1472,7 +1489,7 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "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-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "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-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], diff --git a/package.json b/package.json index 25b3e9ed..d9d2c901 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.16", + "@typescript/native-preview": "7.0.0-dev.20260624.1", "dotenv-cli": "^11.0.0", "turbo": "^2.9.17", "typescript": "6.0.3", diff --git a/packages/core/package.json b/packages/core/package.json index 1ed4b006..56eced72 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,7 +58,7 @@ ], "scripts": { "build": "tsc --build", - "dev": "tsc --build --watch", + "dev": "tsgo --build --watch", "test": "bun test", "bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts", "prepublishOnly": "npm run build" diff --git a/packages/core/src/hooks/scene-registry/item-clip-registry.ts b/packages/core/src/hooks/scene-registry/item-clip-registry.ts new file mode 100644 index 00000000..3b8d63b0 --- /dev/null +++ b/packages/core/src/hooks/scene-registry/item-clip-registry.ts @@ -0,0 +1,19 @@ +import type * as THREE from 'three' + +export type ItemClipEntry = { + /** The catalog clip to re-emit (e.g. a fan's "On" spin). */ + clip: THREE.AnimationClip + /** Plays looping in the baked viewer (ambient motion) vs once. */ + loop: boolean +} + +/** + * Catalog-item animation clips the bake needs to re-emit. A catalog GLB ships + * its own clips (the live item renderer loads + plays them), but those clips + * are not part of the editor scene graph, so the GLB export can't see them on + * its own. The item renderer registers the resolved clip per node id while the + * scene is live; `glb-export` reads this and retargets the clip onto the baked + * item subtree. Door/window motion is synthesized separately and never goes + * here. Keyed by node id; cleared with the rest of the scene refs on unload. + */ +export const itemClipRegistry = new Map() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 67ffb151..fc35f98f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,6 +35,7 @@ export type { ZoneEvent, } from './events/bus' export { emitter, eventSuffixes } from './events/bus' +export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry' export { sceneRegistry, useRegistry, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 161b25b6..ca1ab1b9 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) { } export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null { - if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null + if (!materialRef?.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length) } diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 5824255c..a248b54d 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -182,6 +182,25 @@ export type LinearResizeHandle = { * the roof shell below it. Only consulted when `shape === 'tracker'`. */ trackerBaseY?: (node: N, sceneApi: SceneApi) => number + /** + * Stand the chevron blade up into the node's facing plane instead of + * leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a + * wall-mounted opening (door / window), the local XZ plane is horizontal, + * so the default blade is seen edge-on from the front — rotating it 90° + * about its pointing axis lays it in the wall face (local XY) so it reads + * face-on toward the camera. Chevron shape only; `axis: 'y'` handles are + * already stood up unconditionally so this is a no-op for them. + */ + faceNormal?: boolean + /** + * Gate this arrow behind a click-to-latch cube. When set, the arrow is + * hidden until the user clicks the {@link LatchHandle} cube declaring the + * same `group` name; clicking the cube again hides it. Lets a node keep a + * dense cluster (e.g. a dormer's window width/height arrows) collapsed + * behind a single grip until the user opts in. The latch state is local to + * the selection and resets when the node is deselected. + */ + latchGroup?: string } /** @@ -365,6 +384,25 @@ export type TranslateHandle = { portal?: HandlePortal } +/** + * Click-to-latch cube. Renders a small persistent cube at `placement` that + * toggles the visibility of every handle tagged with the matching + * {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows + * the group's arrows; clicking again hides them. The latch state is local to + * the current selection and resets on deselect. + * + * Mirrors the duct-fitting selection cube but driven by descriptor data so any + * node can collapse a dense arrow cluster behind one grip — e.g. a dormer's + * window width/height arrows latch behind a cube at the window center. + */ +export type LatchHandle = { + kind: 'latch' + /** The `latchGroup` name whose arrows this cube reveals / hides. */ + group: string + placement: HandlePlacement + portal?: HandlePortal +} + export type HandleDescriptor = | LinearResizeHandle | RadialResizeHandle @@ -372,6 +410,7 @@ export type HandleDescriptor = | EndpointMoveHandle | TapActionHandle | TranslateHandle + | LatchHandle /** * Static array, or a function for shape-dependent cases (column diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 47b43a90..62107fc2 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -9,6 +9,7 @@ export type { HandleList, HandlePlacement, HandlePortal, + LatchHandle, LinearResizeHandle, RadialResizeHandle, TapActionHandle, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4ea592f8..db3b10ef 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1665,6 +1665,23 @@ export type ParametricDescriptor = { * `updateNodes`. */ reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Deletion companion to `reconcile`: when a node of this kind is about + * to be removed, return patches for OTHER nodes that must follow to + * undo whatever the node imposed on its neighbours — e.g. an + * auto-inserted elbow re-extends the duct runs it trimmed back onto the + * corner it replaced. Called with the node and the live scene `nodes` + * map BEFORE the deletion lands; patches targeting nodes also being + * deleted are ignored. Applied in the same `set` as the delete so it's + * one undo step. Fires only on `deleteNodes` (user-intent deletes) — + * NOT on `applyNodeChanges`, whose deletes are internal re-routes that + * rewrite neighbours explicitly in the same batch and would fight a + * restore. + */ + onDelete?: ( + node: N, + nodes: Record, + ) => Array<{ id: AnyNodeId; data: Partial }> customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/material.test.ts b/packages/core/src/schema/material.test.ts new file mode 100644 index 00000000..a6e52146 --- /dev/null +++ b/packages/core/src/schema/material.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' +import { MaterialSchema } from './material' + +describe('MaterialSchema', () => { + describe('preset', () => { + test('valid preset passes through unchanged', () => { + const result = MaterialSchema.parse({ preset: 'brick' }) + expect(result.preset).toBe('brick') + }) + + test('every enum preset is accepted', () => { + const presets = [ + 'white', + 'brick', + 'concrete', + 'wood', + 'glass', + 'metal', + 'plaster', + 'tile', + 'marble', + 'custom', + ] as const + for (const preset of presets) { + expect(MaterialSchema.parse({ preset }).preset).toBe(preset) + } + }) + + test("unknown preset coerces to 'custom' instead of throwing (Sentry MONOREPO-EDITOR-DB)", () => { + const result = MaterialSchema.parse({ preset: 'stone' }) + expect(result.preset).toBe('custom') + }) + + test("non-string preset coerces to 'custom'", () => { + const result = MaterialSchema.parse({ preset: 42 }) + expect(result.preset).toBe('custom') + }) + + test('missing preset stays undefined', () => { + const result = MaterialSchema.parse({}) + expect(result.preset).toBeUndefined() + }) + + test('explicit undefined preset stays undefined', () => { + const result = MaterialSchema.parse({ preset: undefined }) + expect(result.preset).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index da82b68b..0c8fcae5 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -27,7 +27,8 @@ export type MaterialProperties = z.infer export const MaterialSchema = z.object({ id: z.string().optional(), - preset: MaterialPreset.optional(), + // Coerce unknown presets (legacy/AI-generated data) to 'custom' instead of throwing. + preset: MaterialPreset.catch('custom').optional(), properties: MaterialProperties.optional(), texture: z .object({ diff --git a/packages/core/src/schema/nodes/dormer.ts b/packages/core/src/schema/nodes/dormer.ts index d4b772c0..a5702caa 100644 --- a/packages/core/src/schema/nodes/dormer.ts +++ b/packages/core/src/schema/nodes/dormer.ts @@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({ windowCornerRadii: z .tuple([z.number(), z.number(), z.number(), z.number()]) .default(DEFAULT_CORNER_RADII), - windowSill: z.boolean().default(true), + windowSill: z.boolean().default(false), windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH), windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS), }).describe( diff --git a/packages/core/src/schema/nodes/duct-fitting.ts b/packages/core/src/schema/nodes/duct-fitting.ts index 277b02f7..1c22a6e0 100644 --- a/packages/core/src/schema/nodes/duct-fitting.ts +++ b/packages/core/src/schema/nodes/duct-fitting.ts @@ -45,7 +45,7 @@ export const DuctFittingNode = BaseNode.extend({ // matching the trunk the fitting sits in. Reducers ignore the shape. // When non-round, `diameter` carries the area-equivalent round size // (drives leg lengths + advertised ports). - shape: z.enum(['round', 'rect', 'oval']).default('round'), + shape: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval run-leg profile in inches (used when shape ≠ 'round'). width: z.number().min(4).max(60).default(14), height: z.number().min(3).max(40).default(8), @@ -53,13 +53,15 @@ export const DuctFittingNode = BaseNode.extend({ // rect / oval profile matching the duct drawn off the tap. When // non-round, `diameter2` carries the branch's area-equivalent round // size. A cross's two opposed branches share this one profile. - shape2: z.enum(['round', 'rect', 'oval']).default('round'), + shape2: z.enum(['round', 'rect', 'oval']).default('rect'), // Rect / oval branch profile in inches (used when shape2 ≠ 'round'). width2: z.number().min(4).max(60).default(14), height2: z.number().min(3).max(40).default(8), // Elbow turn angle in degrees. Residential sheet-metal elbows come in - // 90° and 45°; adjustable elbows cover the range between. - angle: z.number().min(15).max(90).default(90), + // 90° and 45°; adjustable elbows cover the range between. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Tee branch angle in degrees, measured off the +X (outlet) axis: 90° // is a square straight tee, <90° a lateral whose branch sweeps // downstream toward the outlet (flow merges), >90° leans the branch @@ -72,6 +74,7 @@ export const DuctFittingNode = BaseNode.extend({ diameter2: z.number().min(2).max(48).default(6), ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'), system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs. diff --git a/packages/core/src/schema/nodes/duct-segment.ts b/packages/core/src/schema/nodes/duct-segment.ts index 21af751f..348a909d 100644 --- a/packages/core/src/schema/nodes/duct-segment.ts +++ b/packages/core/src/schema/nodes/duct-segment.ts @@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({ // Which side of the air loop this segment belongs to. Drives visual tint // and (in later slices) System graph membership. system: z.enum(['supply', 'return']).default('supply'), + slots: z.record(z.string(), z.string()).optional(), }).describe( dedent` Duct segment - polyline of 3D points connected by duct sections. diff --git a/packages/core/src/schema/nodes/pipe-fitting.ts b/packages/core/src/schema/nodes/pipe-fitting.ts index 88e38e9a..81707ae0 100644 --- a/packages/core/src/schema/nodes/pipe-fitting.ts +++ b/packages/core/src/schema/nodes/pipe-fitting.ts @@ -24,8 +24,10 @@ export const PipeFittingNode = BaseNode.extend({ rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'), // Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long - // sweep" for drains); adjustable range matches the duct elbow. - angle: z.number().min(15).max(90).default(90), + // sweep" for drains); adjustable range matches the duct elbow. 0° is a + // straight coupling — what an elbow flattens to when its run is dragged + // into line with the fixed collar. + angle: z.number().min(0).max(90).default(90), // Run nominal size in inches. diameter: z.number().min(1.25).max(8).default(2), // Branch collar size (wye / sanitary-tee). diff --git a/packages/core/src/schema/nodes/pipe-trap.ts b/packages/core/src/schema/nodes/pipe-trap.ts index ca63f242..0eba603f 100644 --- a/packages/core/src/schema/nodes/pipe-trap.ts +++ b/packages/core/src/schema/nodes/pipe-trap.ts @@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({ // Yaw in radians (the arm direction in plan). rotation: z.number().default(0), // Trap size in inches — matches the fixture drain it serves. - diameter: z.number().min(1.25).max(4).default(1.5), + diameter: z.number().min(1.25).max(4).default(2), pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), // Developed length of the trap arm (trap weir → vent) in meters. The // draw tool measures it when the arm is drawn; editable in the diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index a5b8fc0f..85517d5c 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -44,6 +44,8 @@ export { } from './hosting' export { DEFAULT_LEVEL_HEIGHT, + getCeilingAt, + getCeilingHeightAt, getLevelHeight, } from './level-height' export { diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts index 46f4da0a..016711ac 100644 --- a/packages/core/src/services/level-height.ts +++ b/packages/core/src/services/level-height.ts @@ -1,3 +1,4 @@ +import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager' import type { CeilingNode, LevelNode, WallNode } from '../schema' import type { AnyNode, AnyNodeId } from '../schema/types' @@ -40,3 +41,46 @@ export function getLevelHeight( return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT } + +/** + * The ceiling covering level-local point `[x, z]`, or `null` when none + * sits over it. Points inside a ceiling's hole are treated as uncovered. + * When ceilings overlap, the lowest one wins — that's the surface a duct + * would actually hang from. + */ +export function getCeilingAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): CeilingNode | null { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return null + + let best: CeilingNode | null = null + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (child?.type !== 'ceiling') continue + const ceiling = child as CeilingNode + if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue + if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue + const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT + if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling + } + return best +} + +/** + * Underside elevation (meters above the level floor) of the ceiling + * covering level-local point `[x, z]`, or `null` when no ceiling sits + * over that point. See {@link getCeilingAt}. + */ +export function getCeilingHeightAt( + levelId: string, + nodes: Record, + x: number, + z: number, +): number | null { + const ceiling = getCeilingAt(levelId, nodes, x, z) + return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null +} diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts new file mode 100644 index 00000000..7d9cc1ea --- /dev/null +++ b/packages/core/src/services/port-connectivity.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity' + +type Point = [number, number, number] + +// Stub registrations mirroring the real kinds' port + role conventions +// without importing the nodes package (which pulls in CSG and can't load +// under the test runner). A run exposes start/end at its path tips; the +// fitting here is a simple two-collar elbow at ±X around its position. +function stubDef( + kind: string, + distributionRole: DistributionRole, + ports: (node: AnyNode) => NodePort[], +): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + distributionRole, + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('duct-segment', 'run', (node) => { + const path = (node as unknown as { path: Point[] }).path + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system }, + { id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system }, + ] +}) +stubDef('duct-fitting', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + ] +}) +stubDef('duct-tee', 'fitting', (node) => { + const position = (node as unknown as { position: Point }).position + const system = (node as unknown as { system: string }).system + return [ + { + id: 'inlet', + position: [position[0] - 0.2, position[1], position[2]], + direction: [-1, 0, 0], + diameter: 6, + system, + }, + { + id: 'outlet', + position: [position[0] + 0.2, position[1], position[2]], + direction: [1, 0, 0], + diameter: 6, + system, + }, + { + id: 'branch', + position: [position[0], position[1], position[2] + 0.2], + direction: [0, 0, 1], + diameter: 6, + system, + }, + ] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function expectPointClose(actual: Point, expected: Point) { + expect(actual[0]).toBeCloseTo(expected[0], 6) + expect(actual[1]).toBeCloseTo(expected[1], 6) + expect(actual[2]).toBeCloseTo(expected[2], 6) +} + +describe('port connectivity — joint follow (stretch vs translate)', () => { + // Layout: duct A ends at the fitting's inlet (−0.2,0,0); duct B starts at the + // fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's + // mated endpoint carries the fitting and duct B; how B reacts depends on + // whether the drag is along its axis (stretch) or across it (translate). + function joint() { + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const ductA = makeNode('duct-segment', { + path: [ + [-3, 0, 0], + [-0.2, 0, 0], + ], + system: 'supply', + }) + const ductB = makeNode('duct-segment', { + path: [ + [0.2, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + return { fitting, ductA, ductB } + } + + function movedA(end: Point): AnyNode { + const { ductA } = joint() + return { ...(ductA as Record), path: [[-3, 0, 0], end] } as AnyNode + } + + test('the fitting and sibling run are picked up as carried connections', () => { + const { fitting, ductA, ductB } = joint() + const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB)) + expect( + connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id), + ).toBeDefined() + expect( + connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id), + ).toBeDefined() + }) + + test('perpendicular drag translates the WHOLE sibling run (no skew)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +1 in Z — perpendicular to B's X axis. + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + expect( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + ).toEqual([0, 0, 1]) + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Both ends ride +1 in Z: the run keeps its length and direction. + expect(bPath[0]).toEqual([0.2, 0, 1]) + expect(bPath[1]).toEqual([3, 0, 1]) + }) + + test('parallel drag stretches the sibling run (only the near end slides)', () => { + const { fitting, ductA, ductB } = joint() + const nodes = sceneOf(fitting, ductA, ductB) + const connectivity = analyzePortConnectivity(ductA, nodes) + + // Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B). + const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0])) + + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + // Near end slid +0.5 in X; far end stayed put → the run shortened. + expect(bPath[0]).toEqual([0.7, 0, 0]) + expect(bPath[1]).toEqual([3, 0, 0]) + }) + + test('perpendicular slide propagates through the sibling run to its far joint', () => { + // Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct + // C hangs off that elbow. A perpendicular drag should carry the whole chain. + const { fitting, ductA, ductB } = joint() + const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' }) + // elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B. + const ductC = makeNode('duct-segment', { + path: [ + [3.4, 0, 0], + [6, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC) + const connectivity = analyzePortConnectivity(ductA, nodes) + + const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1])) + + // Whole chain rode +1 in Z. + const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path + expect(bPath[1]).toEqual([3, 0, 1]) + expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual( + [3.2, 0, 1], + ) + const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path + expect(cPath[0]).toEqual([3.4, 0, 1]) + expect(cPath[1]).toEqual([6, 0, 1]) + }) + + test('a run reached from both ends applies both endpoint deltas', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [0, 0, 1], + [3, 0, 2], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path[0]).toEqual([0, 0, 1]) + expect(path[1]).toEqual([3, 0, 2]) + }) + + test('a polyline run reached from both ends preserves interior bend shape', () => { + const moved = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [3, 0, 3], + ], + system: 'supply', + }) + const follower = makeNode('duct-segment', { + path: [ + [0, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3, 0, 3], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, follower) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.5, 0, 0], + [3.5, 0, 3], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path + expect(path).toEqual([ + [-0.5, 0, 0], + [1, 0, 0], + [1, 0, 3], + [3.5, 0, 3], + ]) + }) + + test('a fitting reached from both collars rebroadcasts its final compatible rigid delta', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-tee', { position: [0, 0, 0], system: 'supply' }) + const downstream = makeNode('duct-segment', { + path: [ + [0, 0, 0.2], + [3, 0, 0.2], + ], + system: 'supply', + }) + const nodes = sceneOf(moved, fitting, downstream) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, 1.00005], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 1.000025], + ) + const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path + expectPointClose(path[0]!, [0, 0, 1.200025]) + expectPointClose(path[1]!, [3, 0, 1.200025]) + }) + + test('a fitting reached from incompatible collars merges constraints deterministically', () => { + const moved = makeNode('duct-segment', { + path: [ + [-0.2, 0, 0], + [0.2, 0, 0], + ], + system: 'supply', + }) + const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' }) + const nodes = sceneOf(moved, fitting) + const connectivity = analyzePortConnectivity(moved, nodes) + const preview = { + ...(moved as Record), + path: [ + [-0.2, 0, 1], + [0.2, 0, -1], + ], + } as AnyNode + + const updates = resolveConnectivityUpdates(connectivity, preview) + + expectPointClose( + (updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position, + [0, 0, 0], + ) + }) + + test('an unrelated run not on the fitting is left alone', () => { + const { fitting, ductA, ductB } = joint() + const distant = makeNode('duct-segment', { + path: [ + [10, 0, 0], + [13, 0, 0], + ], + system: 'supply', + }) + const nodes = sceneOf(fitting, ductA, ductB, distant) + const connectivity = analyzePortConnectivity(ductA, nodes) + expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() + }) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 3dbbbf35..57c0b0d4 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -13,14 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema' * * Pure logic: it asks each node for its ports via `def.ports` (level-local * meters) and does arithmetic. No Three.js, no rendering — it lives in - * core and is consumed by the editor's move tool and the duct-segment - * system alike. + * core and is consumed by the editor's move tool and the duct/pipe + * selection affordances alike. * - * Propagation is intentionally **one hop**: a moved fitting stretches the - * ducts touching it (their near endpoint follows) and rigidly drags any - * fitting mated collar-to-collar, but it does NOT chase the far end of - * those ducts or anything beyond. Bounded and predictable — no runaway - * network rearrangement. + * ## Propagation model + * + * The joint graph is snapshotted once at drag start (`analyzePortConnectivity`) + * and walked every frame (`resolveConnectivityUpdates`) given the moved node's + * live transform. Deltas flow outward from the moved node through coincident + * ports: + * + * - **Fitting** (rigid): a collar pushed by delta `d` translates the whole + * fitting by `d`; every other collar carries that same `d` onward. + * - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is + * split against the run's own axis. The *parallel* part slides only that + * endpoint (the run lengthens / shortens); the *perpendicular* part + * translates the entire run (so its direction is preserved). The far + * endpoint therefore moves by just the perpendicular part, and that part + * propagates onward to whatever is mated to the far endpoint. + * + * Propagation walks the whole connected component so a joint stays welded all + * the way down the chain, with a visited guard so cycles (looped runs) and + * shared joints terminate. First-reached (shortest path) wins on a node + * reachable two ways. */ type Point = readonly [number, number, number] @@ -30,36 +45,55 @@ type Point = readonly [number, number, number] * generous slack for grid-snapped hand placement without false matches. */ const COINCIDENT_EPS_M = 0.05 -/** A node attached to one of the moved node's ports, plus how it follows. */ +/** Below this (meters) a propagated delta is treated as zero — stops the + * walk from chasing sub-millimeter perpendicular residue. */ +const DELTA_EPS_M = 1e-4 +const PROPAGATION_EPS_M = 1e-9 + +/** A node carried by the edit, plus the snapshot needed to revert it. Kept + * deliberately small: the move tools read only `kind` + `nodeId` and the + * matching start snapshot to revert before the single tracked commit. */ export type PortConnection = | { - /** Partner is a duct run: the endpoint touching the moved port slides - * to track it (one hop — the far endpoint stays put, stretching the - * run). */ - kind: 'duct-endpoint' - nodeId: AnyNodeId - /** Index in the duct's `path` that tracks the moved port. */ - pathIndex: number - /** The moved node's port id this endpoint follows. */ - movedPortId: string - /** The duct's full path at edit-start (other points are preserved). */ - startPath: Point[] - } - | { - /** Partner is another fitting mated collar-to-collar: it translates - * rigidly so its collar stays on the moved collar. */ + /** A fitting mated collar-to-collar: it translates rigidly. */ kind: 'rigid-node' nodeId: AnyNodeId - movedPortId: string - /** Partner node's `position` at edit-start. */ + /** Node's `position` at edit-start. */ startPosition: Point } + | { + /** A run whose endpoint(s) ride the edit: it stretches and/or + * translates, never skews. */ + kind: 'run' + nodeId: AnyNodeId + /** The run's full `path` at edit-start. */ + startPath: Point[] + } + +/** One node in the snapshotted joint graph (everything reachable from the + * moved node, excluding the moved node itself). */ +type GraphNode = { + id: AnyNodeId + role: 'run' | 'fitting' + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + startPath?: Point[] + startPosition?: Point +} + +/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */ +type Adjacency = Record>> export type PortConnectivity = { movedNodeId: AnyNodeId - /** The moved node's port world positions at edit-start, keyed by port id. - * Used as the reference each connection's delta is measured from. */ + /** The moved node's port world positions at edit-start, keyed by port id — + * the reference each frame's delta is measured from. */ startMovedPorts: Record + /** Reachable run/fitting nodes (excludes the moved node), keyed by id. */ + graph: Record + /** Port coincidence edges across the moved node + every graph node. */ + adjacency: Adjacency + /** Flat list of carried nodes for the move tools' revert + "anything to + * follow?" check. Derived from `graph`. */ connections: PortConnection[] } @@ -83,85 +117,225 @@ function distSq(a: Point, b: Point): number { return dx * dx + dy * dy + dz * dz } +/** Two ports mate when they coincide AND don't cross incompatible systems + * (a supply duct and a waste pipe that merely touch must not fuse). */ +function portsMate( + a: { position: Point; system?: string }, + b: { position: Point; system?: string }, + epsSq: number, +): boolean { + if (distSq(a.position, b.position) > epsSq) return false + if (a.system && b.system && a.system !== b.system) return false + return true +} + /** - * Snapshot which nodes are connected to `movedNode`'s ports, taken at the + * Snapshot the joint graph reachable from `movedNode`'s ports, taken at the * start of a move/resize. Call once before the drag; feed the result to * `resolveConnectivityUpdates` on every frame. * - * Only `run`-role partners (segments — endpoint stretch) and `fitting`-role - * partners (rigid follow) are tracked — terminals and equipment usually mount - * to a surface and shouldn't be yanked off it when an adjacent fitting nudges. + * Only `run`-role partners (segments) and `fitting`-role partners are walked — + * terminals and equipment usually mount to a surface and shouldn't be yanked + * off it when an adjacent fitting nudges. Fittings that declare + * `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and + * are skipped, so a connected run stretches against them instead. */ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { - const movedPorts = portsOf(movedNode) ?? [] - const startMovedPorts: Record = {} - const movedPortSystem: Record = {} - for (const p of movedPorts) { - startMovedPorts[p.id] = p.position - movedPortSystem[p.id] = p.system - } - - const connections: PortConnection[] = [] const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + const movedPorts = portsOf(movedNode) ?? [] + const startMovedPorts: Record = {} + for (const p of movedPorts) startMovedPorts[p.id] = p.position + + // Candidate partners: every run + every following fitting in the scene. + const candidates: GraphNode[] = [] for (const other of Object.values(nodes)) { if (!other || other.id === movedNode.id) continue - // Generalised across every distribution family (HVAC duct + DWV pipe): - // `run` partners stretch an endpoint, `fitting` partners follow rigidly. - // Terminals/equipment mount to surfaces and are intentionally NOT dragged. - // Fittings that declare `portConnectivityFollow: false` are anchored - // fixtures (e.g. pipe-trap) — moving a connected run stretches the arm. - const otherRole = roleOf(other) - if (otherRole !== 'run' && otherRole !== 'fitting') continue - const otherDef = nodeRegistry.get(other.type) - if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue - const otherPorts = portsOf(other) - if (!otherPorts) continue + const role = roleOf(other) + if (role !== 'run' && role !== 'fitting') continue + if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) { + continue + } + const ports = portsOf(other) + if (!ports) continue + const startPath = + role === 'run' + ? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point) + : undefined + if (role === 'run' && (!startPath || startPath.length < 2)) continue + const startPosition = + role === 'fitting' + ? (() => { + const pos = (other as unknown as { position?: Point }).position + return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined + })() + : undefined + if (role === 'fitting' && !startPosition) continue + candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition }) + } - for (const op of otherPorts) { - // Find which of the moved node's ports this partner port sits on. - let matchedId: string | null = null - for (const mp of movedPorts) { - if (distSq(op.position, mp.position) > epsSq) continue - // Don't fuse ports from incompatible systems (e.g. a supply duct - // and a waste pipe that happen to cross): only mate when both - // ports declare the same system, or at least one is unscoped. - const ms = movedPortSystem[mp.id] - if (ms && op.system && ms !== op.system) continue - matchedId = mp.id - break - } - if (!matchedId) continue + // Walk outward from the moved node, collecting every node reachable through + // coincident ports. The adjacency records each port's mates so the resolver + // can replay the same edges with live deltas. + const adjacency: Adjacency = {} + const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => { + const byPort = adjacency[nodeId] ?? {} + adjacency[nodeId] = byPort + const mates = byPort[portId] ?? [] + byPort[portId] = mates + mates.push(mate) + } - if (otherRole === 'run') { - const path = (other as unknown as { path?: Point[] }).path - if (!Array.isArray(path) || path.length < 2) continue - // Port id 'start' → first point, 'end' → last point. - const pathIndex = op.id === 'start' ? 0 : path.length - 1 - connections.push({ - kind: 'duct-endpoint', - nodeId: other.id, - pathIndex, - movedPortId: matchedId, - startPath: path.map((p) => [...p] as Point), - }) - } else { - const position = (other as unknown as { position?: Point }).position - if (!position) continue - connections.push({ - kind: 'rigid-node', - nodeId: other.id, - movedPortId: matchedId, - startPosition: [position[0], position[1], position[2]], - }) + const graph: Record = {} + const visited = new Set([movedNode.id]) + + // Seed: the moved node's own ports. + const queue: Array<{ + id: string + ports: ReadonlyArray<{ id: string; position: Point; system?: string }> + }> = [{ id: movedNode.id, ports: movedPorts }] + + while (queue.length > 0) { + const { id, ports } = queue.shift()! + for (const port of ports) { + for (const cand of candidates) { + if (cand.id === id) continue + for (const cp of cand.ports) { + if (!portsMate(port, cp, epsSq)) continue + addEdge(id, port.id, { nodeId: cand.id, portId: cp.id }) + addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id }) + if (!visited.has(cand.id)) { + visited.add(cand.id) + graph[cand.id] = cand + queue.push({ id: cand.id, ports: cand.ports }) + } + } } } } - return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } + const connections: PortConnection[] = Object.values(graph).map((g) => + g.role === 'fitting' + ? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! } + : { kind: 'run', nodeId: g.id, startPath: g.startPath! }, + ) + + return { + movedNodeId: movedNode.id as AnyNodeId, + startMovedPorts, + graph, + adjacency, + connections, + } +} + +function add(a: Point, b: Point): Point { + return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +function sub(a: Point, b: Point): Point { + return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +function lenSq(v: Point): number { + return v[0] * v[0] + v[1] * v[1] + v[2] * v[2] +} + +/** Split `delta` into the component along unit `axis` and the remainder. */ +function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } { + const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot] + return { parallel, perp: sub(delta, parallel) } +} + +function scale(v: Point, scalar: number): Point { + return [v[0] * scalar, v[1] * scalar, v[2] * scalar] +} + +function average(deltas: Point[]): Point { + const sum = deltas.reduce((acc, delta) => add(acc, delta), [0, 0, 0]) + return scale(sum, 1 / deltas.length) +} + +function nearlyEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= DELTA_EPS_M * DELTA_EPS_M +} + +function propagationEqual(a: Point, b: Point): boolean { + return lenSq(sub(a, b)) <= PROPAGATION_EPS_M * PROPAGATION_EPS_M +} + +function effectivePortDeltas( + constraints: Record>, +): Record { + return Object.fromEntries( + Object.entries(constraints).map(([portId, bySource]) => [ + portId, + average(Object.values(bySource)), + ]), + ) +} + +/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */ +function endpointAxis(path: Point[], portId: string): Point { + const n = path.length + const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!] + const dir = sub(b, a) + const l2 = lenSq(dir) + if (l2 < 1e-12) return [0, 0, 0] + const l = Math.sqrt(l2) + return [dir[0] / l, dir[1] / l, dir[2] / l] +} + +function runPathFromSinglePortDelta( + startPath: Point[], + portId: 'start' | 'end', + delta: Point, +): Point[] { + const nearIdx = portId === 'start' ? 0 : startPath.length - 1 + const axis = endpointAxis(startPath, portId) + const { parallel, perp } = decompose(delta, axis) + const path = startPath.map((p) => add(p, perp)) + path[nearIdx] = add(path[nearIdx]!, parallel) + return path +} + +function runEndpointDeltas(startPath: Point[], path: Point[]): Record { + return { + start: sub(path[0]!, startPath[0]!), + end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!), + } +} + +function runPathFromPortDeltas(startPath: Point[], portDeltas: Record): Point[] { + const startDelta = portDeltas.start + const endDelta = portDeltas.end + if (startDelta && endDelta) { + if (startPath.length === 2) { + return [add(startPath[0]!, startDelta), add(startPath[1]!, endDelta)] + } + + if (nearlyEqual(startDelta, endDelta)) { + return startPath.map((p) => add(p, startDelta)) + } + + const startParts = decompose(startDelta, endpointAxis(startPath, 'start')) + const endParts = decompose(endDelta, endpointAxis(startPath, 'end')) + const commonPerp = average([startParts.perp, endParts.perp]) + const path = startPath.map((p) => add(p, commonPerp)) + path[0] = add(path[0]!, startParts.parallel) + path[path.length - 1] = add(path[path.length - 1]!, endParts.parallel) + return path + } + + return runPathFromSinglePortDelta( + startPath, + startDelta ? 'start' : 'end', + (startDelta ?? endDelta)!, + ) } /** @@ -169,45 +343,103 @@ export function analyzePortConnectivity( * that keep every connected node attached. `previewNode` is the moved node * with its current drag position/rotation applied so its ports recompute. * - * - Duct endpoint: set the tracked path point to the moved port's new - * position (the joint stays welded; the run stretches). - * - Rigid fitting: translate by the moved port's delta so its mated collar - * rides along. + * Walks the snapshotted graph, propagating each port delta outward: fittings + * translate rigidly, runs stretch along their axis and translate across it + * (never skew when driven from one end), and effective port movement carries on + * to neighbouring joints. Port-level output guards bound cycles while still + * allowing a looped/shared run to accept constraints at both endpoints. */ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity + if (Object.keys(graph).length === 0) return [] + const newPorts = portsOf(previewNode) ?? [] - const newById: Record = {} - for (const p of newPorts) newById[p.id] = p.position + const newMovedPos: Record = {} + for (const p of newPorts) newMovedPos[p.id] = p.position - const updates: { id: AnyNodeId; data: Partial }[] = [] - for (const conn of connectivity.connections) { - const start = connectivity.startMovedPorts[conn.movedPortId] - const now = newById[conn.movedPortId] - if (!start || !now) continue + // Each queue item drives a node's port by a delta ("this collar / endpoint + // must move by this much"). + const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = [] + const results: Record }> = {} + const constrainedPorts: Record>> = {} + const propagatedPorts: Record> = {} - if (conn.kind === 'duct-endpoint') { - const path = conn.startPath.map((p, i) => - i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), - ) - updates.push({ id: conn.nodeId, data: { path } as Partial }) - } else { - const dx = now[0] - start[0] - const dy = now[1] - start[1] - const dz = now[2] - start[2] - updates.push({ - id: conn.nodeId, - data: { - position: [ - conn.startPosition[0] + dx, - conn.startPosition[1] + dy, - conn.startPosition[2] + dz, - ], - } as Partial, + const enqueueMates = (nodeId: string, portId: string, delta: Point) => { + const byPort = propagatedPorts[nodeId] ?? {} + propagatedPorts[nodeId] = byPort + const previous = byPort[portId] + if (previous && propagationEqual(previous, delta)) return + byPort[portId] = delta + + for (const mate of adjacency[nodeId]?.[portId] ?? []) { + if (mate.nodeId === movedNodeId) continue + queue.push({ + nodeId: mate.nodeId, + portId: mate.portId, + delta, + sourceKey: `${nodeId}:${portId}`, }) } } - return updates + + const acceptPortDelta = ( + nodeId: AnyNodeId, + portId: string, + sourceKey: string, + delta: Point, + ): boolean => { + const byPort = constrainedPorts[nodeId] ?? {} + constrainedPorts[nodeId] = byPort + const bySource = byPort[portId] ?? {} + byPort[portId] = bySource + const existing = bySource[sourceKey] + if (existing && propagationEqual(existing, delta)) { + return false + } + bySource[sourceKey] = delta + return true + } + + // Seed from the moved node's live port deltas. + for (const [portId, start] of Object.entries(startMovedPorts)) { + const now = newMovedPos[portId] + if (!now) continue + const delta = sub(now, start) + if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(movedNodeId, portId, delta) + } + + while (queue.length > 0) { + const { nodeId, portId, delta, sourceKey } = queue.shift()! + const node = graph[nodeId] + if (!node) continue + if (!acceptPortDelta(nodeId, portId, sourceKey, delta)) continue + const portDeltas = effectivePortDeltas(constrainedPorts[nodeId]!) + + if (node.role === 'fitting') { + const start = node.startPosition! + const effectiveDelta = average(Object.values(portDeltas)) + results[nodeId] = { + id: nodeId, + data: { position: add(start, effectiveDelta) } as Partial, + } + // Rigid: every collar carries the effective body translation onward. + for (const p of node.ports) { + enqueueMates(nodeId, p.id, effectiveDelta) + } + } else { + const startPath = node.startPath! + const path = runPathFromPortDeltas(startPath, portDeltas) + results[nodeId] = { id: nodeId, data: { path } as Partial } + for (const [nextPortId, nextDelta] of Object.entries(runEndpointDeltas(startPath, path))) { + if (lenSq(nextDelta) <= DELTA_EPS_M * DELTA_EPS_M) continue + enqueueMates(nodeId, nextPortId, nextDelta) + } + } + } + + return Object.values(results) } diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 5de80de9..94997dba 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1,3 +1,4 @@ +import { nodeRegistry } from '../../registry/registry' import { type AnyNode, type AnyNodeId, @@ -1010,6 +1011,24 @@ export const deleteNodesAction = ( } for (const id of allIds) deletedIds.add(id) + // Let each deleted kind undo what it imposed on its neighbours (e.g. an + // auto-inserted elbow re-extends the duct runs it trimmed back onto the + // corner it replaced). Read against pre-deletion `nextNodes`; skip + // patches that target a node also being deleted. + for (const id of allIds) { + const node = nextNodes[id] + if (!node) continue + const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete + if (!onDelete) continue + for (const { id: targetId, data } of onDelete(node, nextNodes)) { + if (allIds.has(targetId)) continue + const target = nextNodes[targetId] + if (!target) continue + nextNodes[targetId] = { ...target, ...data } as AnyNode + nodesToMarkDirty.add(targetId) + } + } + for (const plan of mergePlans) { const primaryWall = nextNodes[plan.primaryWallId] if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) { diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index c7dcbb5e..3d1bf03a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -547,9 +547,18 @@ function migrateNodes(nodes: Record): { // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) const patchedNodes = { ...healed } as Record + // Scene materials minted while moving legacy wall fields onto `node.slots`; // merged into the scene material map by the caller (`setScene`). const mintedMaterials: Record = {} + + // Pass 1: all node types except elevator. + // Elevator migration (migrateElevatorParent) mutates level.children to remove + // the elevator ID. If the elevator is processed before its parent level in + // Object.entries order, the level migration in this same pass would then see + // a children array that still contains the elevator ID and filter it out as + // "missing" — corrupting the level. Running elevators in a second pass after + // all levels are stable avoids the race entirely. for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -682,14 +691,6 @@ function migrateNodes(nodes: Record): { ) } - if (node.type === 'elevator') { - const parentMigrated = migrateElevatorParent(id, node, patchedNodes) - const normalized = normalizeElevatorNode(parentMigrated) - if (normalized) { - patchedNodes[id] = normalized - } - } - // Roof-segment hosting was added in this migration cycle (the same // pattern as shelf above). Older segments saved before the schema // gained `children` need the field initialised so @@ -778,7 +779,59 @@ function migrateNodes(nodes: Record): { patchedNodes[id] = { ...node, children: flattened } } } + + // Level children normalization. + // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node + // map (e.g. elevator IDs that lived under a level before the elevator + // parent migration moved them up to building). If those dangling IDs are + // left in place, collectReachableNodeIds marks the level as having + // reachable children that don't exist, which corrupts the scene graph + // traversal and leaves the LevelNode in a broken state — making floors + // impossible to drag or delete after import. + // We intentionally do NOT filter by type prefix here; being permissive + // about which types are allowed as children prevents data loss when new + // child types are added to the schema in the future. + if (node.type === 'level') { + const rawChildren = getStringArray(node.children) + const validChildren = rawChildren.filter((childId) => { + const exists = Boolean(patchedNodes[childId]) + if (!exists) { + console.warn( + '[migrateNodes] level', + id, + 'references missing child', + childId, + '— dropping', + ) + } + return exists + }) + const levelNumber = getFiniteNumber(node.level, 0) + patchedNodes[id] = { + ...node, + level: levelNumber, + children: validChildren, + } + } } + + // Pass 2: elevator migration. + // migrateElevatorParent mutates the parent level's children array (removes + // the elevator ID from it). Running this after Pass 1 guarantees that the + // level normalization above has already seen a clean children list — if we + // ran elevator migration inside Pass 1, the order of Object.entries + // iteration would be non-deterministic: processing an elevator before its + // parent level would mutate the level's children mid-iteration, potentially + // causing the level branch above to see a stale node reference. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node.type !== 'elevator') continue + const parentMigrated = migrateElevatorParent(id, node, patchedNodes) + const normalized = normalizeElevatorNode(parentMigrated) + if (normalized) { + patchedNodes[id] = normalized + } + } + return { nodes: patchedNodes as Record, mintedMaterials } } diff --git a/packages/editor/package.json b/packages/editor/package.json index b3d999e7..56a41681 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -8,7 +8,7 @@ "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx" }, "scripts": { - "check-types": "tsc --noEmit" + "check-types": "tsgo --noEmit" }, "peerDependencies": { "@pascal-app/core": "^0.9.1", diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx new file mode 100644 index 00000000..5771adee --- /dev/null +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { exportSceneToGlb } from '../../lib/glb-export' + +export function BakeExporter({ + active, + onComplete, + onError, +}: { + active: boolean + onComplete: (buffer: ArrayBuffer) => void + onError: (message: string) => void +}) { + const scene = useThree((s) => s.scene) + const doneRef = useRef(false) + useEffect(() => { + if (!(active && !doneRef.current)) return + doneRef.current = true + const run = async () => { + try { + const sceneGroup = scene.getObjectByName('scene-renderer') + if (!sceneGroup) throw new Error('scene-renderer group not found') + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + onComplete(buffer) + } catch (err) { + onError(err instanceof Error ? err.message : String(err)) + } + } + void run() + }, [active, scene, onComplete, onError]) + return null +} diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index abbfebb3..974c164f 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -1,12 +1,12 @@ 'use client' +import { emitter, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect } from 'react' -import type { Mesh, Object3D } from 'three' -import { GLTFExporter } 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 { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export' export function ExportManager() { const scene = useThree((state) => state.scene) @@ -22,7 +22,26 @@ export function ExportManager() { } const date = new Date().toISOString().split('T')[0] - const exportScene = prepareSceneForExport(sceneGroup) + + if (format === 'glb') { + const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) + const blob = new Blob([buffer], { type: 'model/gltf-binary' }) + downloadBlob(blob, `model_${date}.glb`) + return + } + + // Hide editor affordances that live on the scene layer (selection handles, + // ceiling/site brackets) and let wall-cutout reveal all walls — the same + // 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) + let prepared: ReturnType + try { + prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes) + } finally { + emitter.emit('thumbnail:after-capture', undefined) + } + const { scene: exportScene, animations } = prepared if (format === 'stl') { const exporter = new STLExporter() @@ -39,25 +58,6 @@ export function ExportManager() { downloadBlob(blob, `model_${date}.obj`) return } - - // Default: GLB export (existing behavior) - const exporter = new GLTFExporter() - - return new Promise((resolve, reject) => { - exporter.parse( - exportScene, - (gltf) => { - const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' }) - downloadBlob(blob, `model_${date}.glb`) - resolve() - }, - (error) => { - console.error('Export error:', error) - reject(error) - }, - { binary: true }, - ) - }) } setExportScene(exportFn) @@ -70,33 +70,6 @@ export function ExportManager() { return null } -function prepareSceneForExport(source: Object3D) { - const clone = source.clone(true) - const meshesToRemove: Mesh[] = [] - - clone.traverse((object) => { - if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object) - }) - - for (const mesh of meshesToRemove) { - mesh.removeFromParent() - } - - return clone -} - -function isMeshWithInvalidGeometry(object: Object3D): object is Mesh { - if (!isMesh(object)) return false - - // Three exporters can crash when a Mesh has no readable position attribute. - const position = object.geometry?.getAttribute('position') - return !position || position.count === 0 -} - -function isMesh(object: Object3D): object is Mesh { - return (object as Mesh).isMesh === true -} - function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob) const link = document.createElement('a') diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 980ae420..38e6f33b 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -45,6 +45,7 @@ import { } from 'three' import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import '../../three-types' +import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer' import { closeDoorOpenState, DOOR_SWING_OPEN_ANGLE, @@ -64,8 +65,6 @@ import { type FirstPersonColliderWorld, type FirstPersonSpawn, } from './first-person/build-collider-world' -import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl' -import BVHEcctrl from './first-person/bvh-ecctrl' const CAMERA_EYE_OFFSET = 0.45 const LOOK_SENSITIVITY = 0.002 diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 07b9b6b6..43cb3607 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08 const CHEVRON_BEVEL_THICKNESS = 0.035 const CHEVRON_BEVEL_SIZE = 0.03 const CHEVRON_BEVEL_SEGMENTS = 10 +// Slimmer extrude profile matching the legacy wall side handles +// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier +// default is preserved for every other handle that uses the shared chevron. +const CHEVRON_THIN_DEPTH = 0.045 +const CHEVRON_THIN_BEVEL_THICKNESS = 0.018 +const CHEVRON_THIN_BEVEL_SIZE = 0.02 +const CHEVRON_THIN_BEVEL_SEGMENTS = 8 const MOVE_CROSS_HALF_LENGTH = 0.36 const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03 const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12 @@ -64,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3 const ROTATE_RIBBON_HALF_WIDTH = 0.02 const ROTATE_HEAD_HALF_WIDTH = 0.045 const TRACKER_CUBE_SIZE = 0.16 -export const CORNER_HEX_RADIUS = 0.16 +export const CORNER_HEX_RADIUS = 0.11 export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker' export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross' @@ -90,6 +97,8 @@ export type HandleArrowProps = { indicatorRotation?: readonly [number, number, number] onPointerEnter?: PointerHandler onPointerLeave?: PointerHandler + // Extrude the slimmer wall-handle chevron profile (chevron shape only). + thin?: boolean } function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { @@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() { // Reused chevron+shaft silhouette. The chevron points along +X by default; // callers rotate it around Y for Z-axis handles and into a vertical frame for -// Y-axis handles. -export function createArrowHandleGeometry() { +// Y-axis handles. `thin` extrudes the slimmer wall-handle profile. +export function createArrowHandleGeometry(thin = false) { + const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH const shape = new Shape() shape.moveTo(CHEVRON_MAX_X, 0) shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH) @@ -191,16 +201,16 @@ export function createArrowHandleGeometry() { shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH) shape.lineTo(CHEVRON_MAX_X, 0) const geometry = new ExtrudeGeometry(shape, { - depth: CHEVRON_DEPTH, + depth, bevelEnabled: true, - bevelThickness: CHEVRON_BEVEL_THICKNESS, - bevelSize: CHEVRON_BEVEL_SIZE, + bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS, + bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE, bevelOffset: 0, - bevelSegments: CHEVRON_BEVEL_SEGMENTS, + bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS, curveSegments: 16, steps: 1, }) - geometry.translate(0, 0, -CHEVRON_DEPTH / 2) + geometry.translate(0, 0, -depth / 2) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() geometry.computeBoundingSphere() @@ -326,8 +336,8 @@ export function createEndpointHitAreaGeometry(radius: number) { return geometry } -function createHandleArrowGeometry(shape: HandleArrowShape) { - if (shape === 'chevron') return createArrowHandleGeometry() +function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { + if (shape === 'chevron') return createArrowHandleGeometry(thin) if (shape === 'cross') return createMoveCrossHandleGeometry() if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() if (shape === 'tracker') { @@ -471,9 +481,10 @@ export function HandleArrow({ onPointerDown, onPointerEnter, onPointerLeave, + thin = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape]) + const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin]) const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) const indicatorMaterial = useHandleArrowMaterial(visualShape) const hitMaterial = useInvisibleHitAreaMaterial() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 8ced34af..508094e3 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1261,6 +1261,7 @@ export default function Editor({ + {isFirstPersonMode && } @@ -1319,8 +1320,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? (
- useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> @@ -1384,8 +1391,14 @@ export default function Editor({ {!isLoading && isPreviewMode ? ( <> - useEditor.getState().setPreviewMode(false)} /> -
{previewViewerContent}
+ {isFirstPersonMode ? ( + useEditor.getState().setFirstPersonMode(false)} /> + ) : ( + useEditor.getState().setPreviewMode(false)} /> + )} +
+ {previewViewerContent} +
) : ( <> diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 8dd51fd3..f4d5cbe8 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, type HandlePortal, + type LatchHandle, type LinearResizeHandle, nodeRegistry, type RadialResizeHandle, @@ -44,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' import { createEditorApi } from '../../lib/editor-api' +import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' import useInteractionScope, { @@ -112,11 +114,16 @@ export { ARROW_COLOR, ARROW_HOVER_COLOR, ARROW_SCALE, + createArrowHandleGeometry, createArrowHitAreaGeometry, createEndpointHitAreaGeometry, createMoveCrossHandleGeometry, createRotateArrowHandleGeometry, createRotateArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, HIT_AREA_MARGIN, InvisibleHandleHitArea, NO_RAYCAST, @@ -374,6 +381,21 @@ function NodeArrowHandlesForNode({ // hook count between renders and trip React's rules-of-hooks check. const [activeIndex, setActiveIndex] = useState(null) const [preDragNode, setPreDragNode] = useState(null) + // Latch groups currently toggled open. A `latch` cube descriptor flips its + // group here on click; arrows tagged with a `latchGroup` only render while + // their group is in this set. Local to this mount, so it resets on deselect + // (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode). + const [openLatchGroups, setOpenLatchGroups] = useState>(() => new Set()) + const toggleLatchGroup = useMemo( + () => (group: string) => + setOpenLatchGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }), + [], + ) const dragControls = useMemo( () => ({ onStart: (index: number, snapshot: AnyNode) => { @@ -411,6 +433,21 @@ function NodeArrowHandlesForNode({ const arrows = descriptors.map((descriptor, index) => { if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null + // A `latch` cube toggles its group's visibility; render it always. + if (descriptor.kind === 'latch') { + return ( + + ) + } + // Arrows tagged with a latch group stay hidden until that group is open. + const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined + if (latchGroup && !openLatchGroups.has(latchGroup)) return null return ( { @@ -701,6 +743,10 @@ function LinearArrow({ ? snapScalar(rawNext, gridSnapStep) : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) + if (next !== lastTickValue) { + lastTickValue = next + sfxEmitter.emit('sfx:resize') + } const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial // Let the kind publish live guides for the edge being resized. onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) @@ -714,10 +760,19 @@ function LinearArrow({ // X+Z rotation chain matching DoorHeightArrowHandle. When the handle // sits below the node (placement Y < 0, e.g. window bottom arrow), // flip the Z rotation so the chevron points outward (downward). + // + // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows), + // roll the blade 90° about its own pointing (X) axis so it stands up from + // the horizontal XZ plane into the node's facing plane (XY = the wall + // face) — otherwise the blade is seen edge-on from the front. + const faceNormalX = + descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true const innerRotation: [number, number, number] = descriptor.axis === 'y' ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2] - : [0, 0, 0] + : faceNormalX + ? [Math.PI / 2, 0, 0] + : [0, 0, 0] // Optional guide decoration — linear handles use it for curved-stair // width / inner-radius rings; radial handles use it for the column's @@ -803,6 +858,7 @@ function LinearArrow({ onPointerDown={activate} placement={{ position, rotation: [0, rotationY, 0], baseScale }} shape="chevron" + thin > {showLabel ? : null} @@ -1213,6 +1269,7 @@ function ArcArrow({ baseScale, }} shape={isRotateShape ? 'curved-arrow' : 'chevron'} + thin /> ) @@ -1276,6 +1333,56 @@ function TapActionArrow({ onPointerDown={onActivate} placement={{ position, rotation, baseScale }} shape={shape === 'move-cross' ? 'move-cross' : 'chevron'} + thin + /> + ) +} + +// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles +// the visibility of every arrow tagged with its `latchGroup` on click. Sized +// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE) +// so every latch grip reads the same across the app. Stays highlighted while +// its group is open so the user can tell it's engaged. +function LatchCube({ + descriptor, + node, + open, + onToggle, +}: { + descriptor: LatchHandle + node: AnyNode + open: boolean + onToggle: (group: string) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const { camera } = useThree() + const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1 + const baseScale = zoom + + const placementSceneApi = useMemo(() => createSceneApi(useScene), []) + const position = descriptor.placement.position(node, placementSceneApi) + const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 + + // Route through the shared tap path so the cube click is swallowed before it + // reaches the select tool — stops R3F propagation, suppresses box-select, and + // eats the trailing DOM click that would otherwise select the host node. + const onPointerDown = useHandleDrag({ + kind: 'tap', + onTap: () => { + setIsHovered(false) + onToggle(descriptor.group) + }, + }) + + return ( + ) } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index a60e1a18..0ccf9442 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -60,7 +60,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc' // Match the door arrows: scale the rendered chevron down to ~two-thirds // so the in-world handles read as a single UI family. const ARROW_SCALE = 0.65 -const CORNER_HEX_RADIUS = 0.16 +const CORNER_HEX_RADIUS = 0.11 const CORNER_DASH_SIZE = 0.1 const CORNER_GAP_SIZE = 0.07 const CORNER_DASH_THICKNESS = 0.006 diff --git a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx index 98e91ec7..269af4b8 100644 --- a/packages/editor/src/components/tools/select/plane-box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/plane-box-select-tool.tsx @@ -10,7 +10,6 @@ import { type ZoneNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import type { ThreeElements } from '@react-three/fiber' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useRef } from 'react' import { @@ -34,12 +33,6 @@ import { CursorSphere } from '../shared/cursor-sphere' import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state' import { collectSelectableCandidateIds } from './select-candidates' -declare module 'react/jsx-runtime' { - namespace JSX { - interface IntrinsicElements extends ThreeElements {} - } -} - type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number } const BOX_SELECT_ACCENT_COLOR = '#818cf8' diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index 10af0c08..d403842a 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit { depthWrite?: boolean showTooltip?: boolean height?: number + /** + * Put the bright marker dot at the TIP of the vertical line (y = height) + * instead of on the ground ring. Used when the point being placed hangs + * above the floor (e.g. duct drawn against the ceiling): the dot rides at + * the cursor / placement point while the line drops to a floor ring that + * keeps the plan position readable. + */ + dotAtTip?: boolean /** Custom tooltip content — overrides the auto-detected build tool icon */ tooltipContent?: React.ReactNode } export const CursorSphere = forwardRef(function CursorSphere( - { color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props }, + { + color = '#818cf8', + showTooltip = true, + height = 2.5, + dotAtTip = false, + visible = true, + tooltipContent, + ...props + }, ref, ) { const tool = useEditor((s) => s.tool) @@ -39,19 +55,23 @@ export const CursorSphere = forwardRef(function Cursor return ( - {/* Flat marker on the ground */} + {/* Flat marker on the ground. The bright center dot moves to the tip + of the line in `dotAtTip` mode (the placement point hangs above the + floor), leaving a faint ring here so the plan position stays read. */} - {/* Center dot */} - - - - + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} {/* Outer ring / glow */} @@ -60,7 +80,7 @@ export const CursorSphere = forwardRef(function Cursor color={color} depthTest={false} depthWrite={false} - opacity={0.25} + opacity={dotAtTip ? 0.2 : 0.25} transparent /> @@ -80,6 +100,15 @@ export const CursorSphere = forwardRef(function Cursor )} + {/* Bright marker dot at the tip of the line — the actual placement + point, riding at the cursor while the line drops to the floor. */} + {dotAtTip && height > 0 && ( + + + + + )} + {/* Tool Icon Tooltip at the top of the line */} {isVisible && showTooltip && (activeToolConfig || tooltipContent) && ( , - keywords: ['wall', 'cutaway', 'up', 'down', 'view'], + keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'], badge: () => { const mode = useViewer.getState().wallMode - return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode] + return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode] }, navigate: true, execute: () => navigateTo('wall-mode'), diff --git a/packages/editor/src/components/ui/command-palette/index.tsx b/packages/editor/src/components/ui/command-palette/index.tsx index 4f9d2964..2979d80f 100644 --- a/packages/editor/src/components/ui/command-palette/index.tsx +++ b/packages/editor/src/components/ui/command-palette/index.tsx @@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm setOpen(false) } - const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = { + const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = { cutaway: 'Cutaway', up: 'Up', down: 'Down', + translucent: 'Translucent', } const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { manual: 'Manual', @@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm {/* ── Wall Mode sub-page ────────────────────────────────────── */} {page === 'wall-mode' && ( - {(['cutaway', 'up', 'down'] as const).map((mode) => ( + {(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => ( getActiveContinuationContext(), [scope, mode, tool], ) - const selectModeHints = useMemo( - () => - resolveSelectModeHelpHints({ - selectedCount: selectedNodes.length, - hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), - hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), - commandPressed: modifiers.command, - shiftPressed: modifiers.shift, - }), - [modifiers.command, modifiers.shift, selectedNodes], - ) + const selectModeHints = useMemo(() => { + const single = selectedNodes.length === 1 ? selectedNodes[0] : null + const mepSelection = + single?.type === 'duct-segment' || single?.type === 'pipe-segment' + ? 'run' + : single?.type === 'duct-fitting' || single?.type === 'pipe-fitting' + ? 'fitting' + : null + return resolveSelectModeHelpHints({ + selectedCount: selectedNodes.length, + hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)), + hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)), + commandPressed: modifiers.command, + shiftPressed: modifiers.shift, + mepSelection, + }) + }, [modifiers.command, modifiers.shift, selectedNodes]) // Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch. if (isMobile) return null diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 9e1cc312..e60c5d66 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -25,6 +25,7 @@ import { Check, ChevronRight, Diamond, + Footprints, Layers, Palette, PenLine, @@ -32,8 +33,10 @@ import { Square, } from 'lucide-react' import Link from 'next/link' +import { flushSync } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import { cn } from '../lib/utils' +import useEditor from '../store/use-editor' import { ActionButton } from './ui/action-menu/action-button' import { DropdownMenu, @@ -51,6 +54,24 @@ type ProjectOwner = { image: string | null } +function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + canvas.requestPointerLock?.() + } catch { + return + } +} + const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { stacked: 'Stacked', exploded: 'Exploded', @@ -83,6 +104,12 @@ const wallModeConfig = { ), label: 'Low', }, + translucent: { + icon: (props: any) => ( + Translucent + ), + label: 'Translucent', + }, } const SHADING_OPTIONS = [ @@ -580,7 +607,12 @@ export const ViewerOverlay = ({ } label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`} onClick={() => { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [ + 'cutaway', + 'up', + 'down', + 'translucent', + ] const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') }} @@ -641,6 +673,23 @@ export const ViewerOverlay = ({ src="/icons/topview.webp" /> + +
+ + {/* First-person walkthrough */} + { + flushSync(() => useEditor.getState().setFirstPersonMode(true)) + requestWalkthroughPointerLock() + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + +
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 4c89e429..4be6aba7 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -11,6 +11,7 @@ export { default as Editor } from './components/editor' // they're referenced throughout the editor's own internals; the public // surface uses the shorter, shell-friendly names from the unified // preset-system spec. +export { BakeExporter } from './components/editor/bake-exporter' export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' // Embed surface — the editor's real in-canvas affordances, so a host can mount // authentic selection handles, interactive build tools, and the mover on top @@ -39,7 +40,27 @@ export { formatMeasurement, MeasurementPill, } from './components/editor/measurement-pill' -export { NodeArrowHandles } from './components/editor/node-arrow-handles' +// In-world arrow handle primitives (chevron geometry, invisible hit area, +// shared material, palette + scale constants). Re-exported so kind-owned +// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height / +// extend arrows) reuse the same UI family as the wall / fence side handles. +export { + ARROW_COLOR, + ARROW_HOVER_COLOR, + ARROW_SCALE, + createArrowHandleGeometry, + createArrowHitAreaGeometry, + HandleArrow, + type HandleArrowInputShape, + type HandleArrowPlacement, + type HandleArrowProps, + InvisibleHandleHitArea, + NO_RAYCAST, + NodeArrowHandles, + swallowNextClick, + useArrowMaterial, + useInvisibleHitAreaMaterial, +} from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, @@ -259,6 +280,7 @@ export { getFloorplanWallThickness, } from './lib/floorplan' export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement' +export { exportSceneToGlb } from './lib/glb-export' export { boundaryReshapeScope, curveReshapeScope, diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index 2019849e..fefaecfa 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -38,12 +38,19 @@ export type SelectModeHelpContext = { hasRotatableSelection: boolean commandPressed: boolean shiftPressed: boolean + // When a single MEP node is selected its in-world handle rig (click a dot to + // reveal move arrows) is the real editing path, so the panel leads with the + // handle-specific hints instead of just the generic Cmd-drag tips. + mepSelection?: 'run' | 'fitting' | null } const COMMAND_KEY = 'Cmd/Ctrl' const LEFT_CLICK = 'Left click' const RIGHT_CLICK = 'Right click' const SHIFT_KEY = 'Shift' +const CLICK = 'Click' +const ALT_KEY = 'Alt' +const ROTATE_KEYS = 'R / T' export function resolveSelectModeHelpHints({ selectedCount, @@ -51,6 +58,7 @@ export function resolveSelectModeHelpHints({ hasRotatableSelection, commandPressed, shiftPressed, + mepSelection = null, }: SelectModeHelpContext): ContextualShortcutHint[] { const hints: ContextualShortcutHint[] = [] @@ -65,6 +73,20 @@ export function resolveSelectModeHelpHints({ return hints } + // MEP handle workflow — duct/pipe runs and fittings are edited through the + // in-world arrow rig that a click on the handle dot reveals, so surface those + // hints first. A run endpoint's side / up-down arrows swing the run and Alt + // detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with + // R / T (and Alt to switch axis) for keyboard rotation. + if (mepSelection === 'run') { + hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' }) + hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' }) + } else if (mepSelection === 'fitting') { + hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' }) + hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' }) + hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' }) + } + if (commandPressed) { if (hasMovableSelection) { hints.push({ diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts new file mode 100644 index 00000000..74b24f70 --- /dev/null +++ b/packages/editor/src/lib/glb-export.test.ts @@ -0,0 +1,267 @@ +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' + +afterEach(() => { + sceneRegistry.clear() +}) + +function nodeMaterial(overrides: Record = {}) { + // Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial: + // the exporter keys off `isNodeMaterial` and reads plain PBR props. + return { + isNodeMaterial: true, + name: 'painted', + color: new THREE.Color('#cc3300'), + roughness: 0.3, + metalness: 0.7, + transparent: false, + opacity: 1, + side: THREE.FrontSide, + alphaTest: 0, + depthWrite: true, + depthTest: true, + vertexColors: false, + toneMapped: true, + ...overrides, + } as unknown as THREE.Material +} + +function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh { + const geometry = new THREE.BoxGeometry(1, 1, 1) + return new THREE.Mesh(geometry, material) +} + +describe('prepareSceneForExport', () => { + test('converts NodeMaterials to classic glTF-standard materials', () => { + const root = new THREE.Group() + root.name = 'scene-renderer' + const mesh = meshWithNodeMaterial(nodeMaterial()) + root.add(mesh) + + const { scene } = prepareSceneForExport(root, {}) + + const exported = scene.children[0] as THREE.Mesh + const material = exported.material as THREE.MeshStandardMaterial + expect(material.isMeshStandardMaterial).toBe(true) + expect(material.roughness).toBeCloseTo(0.3) + expect(material.metalness).toBeCloseTo(0.7) + expect(material.color.getHexString()).toBe('cc3300') + }) + + test('shared NodeMaterial instances convert to a single shared material', () => { + const root = new THREE.Group() + const shared = nodeMaterial() + root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared)) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes = scene.children as THREE.Mesh[] + expect(meshes[0]!.material).toBe(meshes[1]!.material) + }) + + test('strips editor overlays that live off the scene layer', () => { + const root = new THREE.Group() + const realMesh = meshWithNodeMaterial(nodeMaterial()) + const overlay = meshWithNodeMaterial(nodeMaterial()) + overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0 + root.add(realMesh, overlay) + + const { scene } = prepareSceneForExport(root, {}) + + const meshes: THREE.Mesh[] = [] + scene.traverse((o) => { + if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) + }) + expect(meshes).toHaveLength(1) + }) + + 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. + const root = new THREE.Group() + const hitbox = new THREE.Mesh( + new THREE.BoxGeometry(1, 2, 0.2), + new THREE.MeshBasicMaterial({ visible: false }), + ) + const leaf = meshWithNodeMaterial(nodeMaterial()) + hitbox.add(leaf) + root.add(hitbox) + + const doorId = 'door_hitbox' + sceneRegistry.nodes.set(doorId, hitbox) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh + expect(exported).toBeDefined() + // Geometry emptied -> GLTFExporter emits a plain node, no solid block. + expect(exported.geometry.getAttribute('position')).toBeUndefined() + // The visible leaf survives as a child. + const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh) + expect(visibleChildren).toHaveLength(1) + }) + + test('stamps identity from the scene registry and strips other userData', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_test' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { + object: 'node', + id: doorId, + type: 'door', + name: 'Front door', + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exportedDoor = scene.getObjectByProperty('name', doorId) + expect(exportedDoor).toBeDefined() + expect(exportedDoor?.userData).toEqual({ + pascalId: doorId, + kind: 'door', + label: 'Front door', + openable: true, + clips: ['Front door: open'], + }) + + // The swing-leaf marker must not survive into glTF extras. + let leafMarkerSurvived = false + scene.traverse((object) => { + if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true + }) + expect(leafMarkerSurvived).toBe(false) + }) + + test('does not flag a door/window openable when no open clip bakes', () => { + // A cased opening (no swing leaf) / fixed window (no operable sash) builds + // no movable part, so no clip bakes and the node must not claim openable. + const root = new THREE.Group() + const openingGroup = new THREE.Group() + openingGroup.add(meshWithNodeMaterial(nodeMaterial())) + root.add(openingGroup) + + const openingId = 'door_opening' + sceneRegistry.nodes.set(openingId, openingGroup) + const nodes: Record = { + [openingId]: { + object: 'node', + id: openingId, + type: 'door', + name: 'Cased opening', + } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(animations).toHaveLength(0) + const exported = scene.getObjectByProperty('name', openingId) + expect(exported?.userData).toEqual({ + pascalId: openingId, + kind: 'door', + label: 'Cased opening', + }) + expect(exported?.userData.openable).toBeUndefined() + expect(exported?.userData.clips).toBeUndefined() + }) + + test('keeps the zone identity node with its polygon and strips the fill mesh', () => { + const root = new THREE.Group() + const zoneGroup = new THREE.Group() + const fill = meshWithNodeMaterial(nodeMaterial()) + fill.layers.set(2) // ZONE_LAYER + zoneGroup.add(fill) + zoneGroup.visible = false // the editor often hides zones at export time + root.add(zoneGroup) + + const zoneId = 'zone_living' + const polygon: [number, number][] = [ + [0, 0], + [4, 0], + [4, 3], + ] + sceneRegistry.nodes.set(zoneId, zoneGroup) + const nodes: Record = { + [zoneId]: { + object: 'node', + id: zoneId, + type: 'zone', + name: 'Living Room', + polygon, + color: '#ff0000', + } as unknown as AnyNode, + } + + const { scene } = prepareSceneForExport(root, nodes) + + const exported = scene.getObjectByProperty('name', zoneId) + expect(exported).toBeDefined() + // Forced visible so GLTFExporter's onlyVisible keeps the metadata node. + expect(exported?.visible).toBe(true) + expect(exported?.userData).toEqual({ + pascalId: zoneId, + kind: 'zone', + label: 'Living Room', + polygon, + color: '#ff0000', + }) + // The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead). + let hasMesh = false + exported?.traverse((o) => { + if ((o as THREE.Mesh).isMesh) hasMesh = true + }) + expect(hasMesh).toBe(false) + }) + + test('bakes a swing door into an open quaternion clip', () => { + const root = new THREE.Group() + const doorGroup = new THREE.Group() + const leaf = new THREE.Group() + leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 } + leaf.add(meshWithNodeMaterial(nodeMaterial())) + doorGroup.add(leaf) + root.add(doorGroup) + + const doorId = 'door_swing' + sceneRegistry.nodes.set(doorId, doorGroup) + const nodes: Record = { + [doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode, + } + + const { scene, animations } = prepareSceneForExport(root, nodes) + + expect(animations).toHaveLength(1) + const clip = animations[0]! + expect(clip.name).toBe('Door: open') + expect(clip.duration).toBe(1) + // Playback intent carried in extras so consumers can play once and hold. + expect(clip.userData).toEqual({ loop: false }) + + const track = clip.tracks[0]! + expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack) + expect(track.name.endsWith('.quaternion')).toBe(true) + expect(Array.from(track.times)).toEqual([0, 1]) + + // The track must target an object that exists in the exported tree. + const targetUuid = track.name.replace('.quaternion', '') + const target = scene.getObjectByProperty('uuid', targetUuid) + expect(target).toBeDefined() + + // Rest pose is closed: the first keyframe is the identity rotation. + const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4)) + expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) + }) +}) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts new file mode 100644 index 00000000..7f614993 --- /dev/null +++ b/packages/editor/src/lib/glb-export.ts @@ -0,0 +1,653 @@ +import { + type AnyNode, + emitter, + getLevelDisplayName, + itemClipRegistry, + type LevelNode, + sceneRegistry, + type WindowNode, + type ZoneNode, +} from '@pascal-app/core' +import { poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions } from '@pascal-app/viewer' +import type { Object3D } from 'three' +import * as THREE from 'three' +import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js' +import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js' + +/** + * Two TRS samples (closed vs open) differing by less than this are treated as + * stationary, so only genuinely moving parts get an animation track. + */ +const POSE_EPSILON = 1e-5 + +/** + * Marker stamped on a door's swing-leaf group by the door system. `axis` is the + * hinge axis and `openRotationY` is the fully-open angle (radians). The export + * reads it to bake an open clip from a single closed pose; see `door-system`. + */ +type SwingLeafMarker = { axis: 'y'; openRotationY: number } + +export type GlbExport = { + scene: THREE.Object3D + animations: THREE.AnimationClip[] +} + +export async function exportSceneToGlb( + sceneGroup: Object3D, + nodes: Record, +): Promise { + emitter.emit('thumbnail:before-capture', undefined) + // Snap levels to their true stacked positions (like thumbnail capture) so the + // export always reflects the clean stacked building, regardless of the live + // levelMode (exploded/solo) or an unsettled level lerp that could otherwise + // bake a level at a stray offset. + const restoreLevels = snapLevelsToTruePositions() + let prepared: ReturnType + try { + prepared = prepareSceneForExport(sceneGroup, nodes) + } finally { + restoreLevels() + emitter.emit('thumbnail:after-capture', undefined) + } + const { scene: exportScene, animations } = prepared + + const exporter = new GLTFExporter() + // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read + // those directly. WebGPUTextureUtils blits each one to RGBA on its own + // offscreen renderer (passing the live renderer would resize/draw over the + // editor canvas), letting the exporter embed standard textures. + exporter.setTextureUtils(WebGPUTextureUtils) + + return new Promise((resolve, reject) => { + exporter.parse( + exportScene, + (gltf) => { + resolve(gltf as ArrayBuffer) + }, + (error) => { + reject(error) + }, + { binary: true, animations }, + ) + }) +} + +/** + * Build an engine-agnostic export tree from the live scene graph. The result is + * a standalone three.js scene plus glTF animation clips, ready for + * `GLTFExporter` — it carries no Pascal runtime dependency. + * + * - Clones the source so live objects are never mutated. + * - Converts WebGPU NodeMaterials to classic glTF-standard materials. + * `GLTFExporter` only recognises `isMeshStandardMaterial` / + * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set + * `isNodeMaterial` instead, so without this every surface exports as a blank + * default material. + * - Bakes each openable door/window's open motion into a glTF animation clip + * via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors, + * `poseWindowMovingParts` for windows). + * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover + * survive the bake with no in-memory registry, and strips all other userData + * so editor/runtime ephemera never leak into glTF extras. + */ +export function prepareSceneForExport( + source: THREE.Object3D, + nodes: Record, +): GlbExport { + const scene = source.clone(true) + const cloneByOriginal = pairClones(source, scene) + + // Scans (LiDAR meshes) and guides (floorplan images) are heavy reference + // assets stored elsewhere and aren't part of the compiled building. Drop them + // from the artifact entirely — `/viewer` re-adds them from the scene graph, + // gated by the project's public-visibility flags, so they never bloat the + // shared GLB nor slip past those flags into a static public file. + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + if (node?.type === 'scan' || node?.type === 'guide') { + cloneByOriginal.get(original)?.removeFromParent() + } + } + + // 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 + // reconstructs the room from. + const identityNodes = new Set() + for (const original of sceneRegistry.nodes.values()) { + const clone = cloneByOriginal.get(original) + if (clone) identityNodes.add(clone) + } + + pruneNonRenderableMeshes(scene, identityNodes) + convertMaterials(scene) + + const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes) + + stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode) + + return { scene, animations: clips } +} + +/** + * 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 + * map `sceneRegistry`'s live refs onto the export tree without mutating either. + */ +function pairClones( + source: THREE.Object3D, + clone: THREE.Object3D, +): Map { + const originals: THREE.Object3D[] = [] + const clones: THREE.Object3D[] = [] + source.traverse((object) => originals.push(object)) + clone.traverse((object) => clones.push(object)) + + const map = new Map() + for (let i = 0; i < originals.length; i++) { + const target = clones[i] + if (target) map.set(originals[i]!, target) + } + return map +} + +// A single empty geometry shared by every container mesh we neutralise below — +// it has no attributes, so GLTFExporter's processMesh returns null and emits a +// plain transform node instead of a primitive. +const EMPTY_GEOMETRY = new THREE.BufferGeometry() + +// Hidden placeholder for a neutralised renderable that has no material: a valid +// material keeps GLTFExporter from crashing on `material.isShaderMaterial`, while +// EMPTY_GEOMETRY makes it emit a transform node instead of a primitive. +const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false }) + +/** + * Strip everything that must not bake into the model: + * - Editor overlays on non-scene layers (gizmos, selection handles, ground + * grid, zone fills). The editor camera shows them via extra layers; a + * thumbnail/bake is layer 0 only. Scene-layer affordances that can't be + * layer-filtered (ceiling/site brackets) are hidden by the caller's + * `thumbnail:before-capture` emit before the clone instead. + * - Selection hitboxes, whose invisibility lives on `material.visible = false` + * (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox + * root is a box spanning the wall opening — left in, it plugs the cutout. + * With children (it parents the visible frame + leaf) it keeps its node but + * loses its geometry; childless ones are removed outright. + */ +function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set) { + const toRemove: THREE.Object3D[] = [] + root.traverse((object) => { + // Editor-only overlays (gizmos, selection handles, ground grid, zone fills) + // live off the scene layer; the editor camera shows them via extra layers + // but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree — + // except identity nodes, which we keep (their off-layer mesh children are + // still pruned as the traversal continues). + if (!object.layers.isEnabled(SCENE_LAYER)) { + if (identityNodes.has(object)) return + toRemove.push(object) + return + } + // A renderable (Mesh / Line / Points) with no material can't produce valid + // glTF and crashes GLTFExporter, which reads `material.isShaderMaterial` + // unconditionally — e.g. an imported sub-model that left a mesh material-less. + // Non-Mesh renderables also slip past the `isMesh` checks below and the + // material conversion. Neutralise it: keep the node (so children survive) but + // strip its geometry + give it the hidden placeholder, or drop it if a leaf. + const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean } + if ( + (renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) && + renderable.material == null + ) { + if (object.children.length > 0) { + renderable.geometry = EMPTY_GEOMETRY + renderable.material = PLACEHOLDER_MATERIAL + } else { + toRemove.push(object) + } + return + } + const mesh = object as THREE.Mesh + if (!mesh.isMesh || isRenderableMesh(mesh)) return + if (mesh.children.length > 0) { + mesh.geometry = EMPTY_GEOMETRY + } else { + toRemove.push(mesh) + } + }) + for (const object of toRemove) { + object.removeFromParent() + } +} + +function isRenderableMesh(mesh: THREE.Mesh): boolean { + const position = mesh.geometry?.getAttribute('position') + if (!position || position.count === 0) return false + const material = mesh.material + return Array.isArray(material) + ? material.some((m) => m?.visible !== false) + : material?.visible !== false +} + +// --- Material conversion ------------------------------------------------- + +const STANDARD_MAP_SLOTS = [ + 'map', + 'normalMap', + 'roughnessMap', + 'metalnessMap', + 'aoMap', + 'emissiveMap', + 'alphaMap', + 'lightMap', + 'displacementMap', + 'bumpMap', +] as const + +function convertMaterials(root: THREE.Object3D) { + const cache = new Map() + root.traverse((object) => { + const mesh = object as THREE.Mesh + if (!mesh.isMesh) return + const material = mesh.material + if (Array.isArray(material)) { + mesh.material = material.map((m) => convertMaterial(m, cache)) + return + } + // glTF has no BackSide — GLTFExporter renders the *front* face for any + // non-DoubleSide material, which inverts a BackSide surface (e.g. the + // ceiling underside, meant to be seen from the room). Flip the mesh winding + // so the intended face shows with the FrontSide material convertMaterial + // produces. Per-mesh geometry clone keeps shared geometry untouched. + if ( + (material as { isNodeMaterial?: boolean }).isNodeMaterial && + material.side === THREE.BackSide + ) { + mesh.geometry = flipGeometryWinding(mesh.geometry) + } + mesh.material = convertMaterial(material, cache) + }) +} + +/** + * Reverse triangle winding and negate normals so a surface authored for + * `BackSide` reads correctly once exported as `FrontSide` (glTF can't express + * back-face-only rendering). + */ +function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry { + const flipped = geometry.clone() + const index = flipped.getIndex() + if (index) { + const a = index.array + for (let i = 0; i < a.length; i += 3) { + const tmp = a[i]! + a[i] = a[i + 2]! + a[i + 2] = tmp + } + index.needsUpdate = true + } else { + for (const attribute of Object.values(flipped.attributes)) { + const { array, itemSize } = attribute + for (let i = 0; i < array.length; i += itemSize * 3) { + for (let k = 0; k < itemSize; k++) { + const tmp = array[i + k]! + array[i + k] = array[i + 2 * itemSize + k]! + array[i + 2 * itemSize + k] = tmp + } + } + attribute.needsUpdate = true + } + } + const normal = flipped.getAttribute('normal') + if (normal) { + for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]! + normal.needsUpdate = true + } + return flipped +} + +/** + * Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the + * glTF exporter understands. Classic materials pass through untouched, and the + * cache preserves material sharing (one source instance -> one target), so the + * exporter still dedups shared surfaces. + */ +function convertMaterial( + material: THREE.Material, + cache: Map, +): THREE.Material { + if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material + + const cached = cache.get(material) + if (cached) return cached + + const src = material as THREE.Material & Record + const target = new THREE.MeshStandardMaterial() + + target.name = material.name + if (src.color instanceof THREE.Color) target.color.copy(src.color) + if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive) + if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity + // Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully + // rough, non-metallic surface is the faithful lit fallback. + target.roughness = typeof src.roughness === 'number' ? src.roughness : 1 + target.metalness = typeof src.metalness === 'number' ? src.metalness : 0 + // Only genuinely see-through surfaces stay transparent. Several viewer + // materials set `transparent: true` while fully opaque (opacity 1); exporting + // those as alphaMode=BLEND makes them render see-through with no depth write + // (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept. + target.transparent = material.transparent && material.opacity < 1 + target.opacity = material.opacity + // BackSide is flipped to FrontSide (with the mesh winding reversed in + // convertMaterials) because glTF has no back-face-only mode. + target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side + target.alphaTest = material.alphaTest + target.depthWrite = material.depthWrite + target.depthTest = material.depthTest + target.vertexColors = material.vertexColors + target.toneMapped = material.toneMapped + if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale) + if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity + if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale + + for (const slot of STANDARD_MAP_SLOTS) { + const texture = src[slot] + if (texture instanceof THREE.Texture) { + ;(target as unknown as Record)[slot] = texture + } + } + + cache.set(material, target) + return target +} + +// --- Animation clip baking ---------------------------------------------- + +function bakeAnimationClips( + cloneByOriginal: Map, + nodes: Record, +): { clips: THREE.AnimationClip[]; clipNamesByNode: Map } { + const clips: THREE.AnimationClip[] = [] + const clipNamesByNode = new Map() + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + const clip = + node.type === 'door' + ? bakeDoorClip(id, node, target) + : node.type === 'window' + ? bakeWindowClip(id, node as WindowNode, target) + : node.type === 'item' + ? bakeItemClip(id, target) + : null + + if (clip) { + clips.push(clip) + clipNamesByNode.set(id, [clip.name]) + } + } + + return { clips, clipNamesByNode } +} + +/** + * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked + * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); + * since every fan shares those names, we rebind each track to the specific + * cloned node's uuid so multiple fans animate independently. The clip is named + * per node (`: loop`) so the baked viewer can drive each one on its own. + */ +function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null { + const entry = itemClipRegistry.get(id) + if (!entry) return null + + const tracks: THREE.KeyframeTrack[] = [] + // The catalog node names (e.g. "lamp_018") repeat across every instance of the + // item, and the glTF export→import roundtrip rebinds clip tracks by node name — + // so a shared name would make all fans share one clip. Uniquify the targeted + // node's name per item once, then bind tracks by its (stable) uuid. + const renamed = new Map() + for (const track of entry.clip.tracks) { + const dot = track.name.lastIndexOf('.') + if (dot < 0) continue + const targetName = track.name.slice(0, dot) + const property = track.name.slice(dot + 1) + let targetNode = renamed.get(targetName) + if (!targetNode) { + const found = itemObject.getObjectByName(targetName) + if (!found) continue + found.name = `${id}__${targetName}` + renamed.set(targetName, found) + targetNode = found + } + const retargeted = track.clone() + retargeted.name = `${targetNode.uuid}.${property}` + tracks.push(retargeted) + } + + if (tracks.length === 0) return null + const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks) + clip.userData = { loop: entry.loop } + return clip +} + +/** + * Bake a swing door's open motion. Each marked leaf is rotated from closed + * (rest pose) to its fully-open angle and emitted as a 1-second quaternion + * track; the leaf is left at the closed pose so the GLB's rest state is shut. + */ +function bakeDoorClip( + id: string, + node: AnyNode, + doorObject: THREE.Object3D, +): THREE.AnimationClip | null { + const tracks: THREE.KeyframeTrack[] = [] + + doorObject.traverse((object) => { + const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined + if (!marker || marker.axis !== 'y') return + + object.rotation.y = 0 + const closed = object.quaternion.clone() + object.rotation.y = marker.openRotationY + const open = object.quaternion.clone() + object.rotation.y = 0 + + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${object.uuid}.quaternion`, + [0, 1], + [...closed.toArray(), ...open.toArray()], + ), + ) + }) + + if (tracks.length === 0) return null + return openClip(id, node, tracks) +} + +/** + * Wrap an open motion in a named 1-second clip. The name uses the node's label + * when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling + * back to the id. glTF has no core loop flag — the player decides — so we stamp + * `extras.loop = false` (via the clip's userData, which `GLTFExporter` + * serialises onto the animation): Pascal's `/viewer` and any extras-aware + * consumer play it once and hold the open pose; a dumb glTF player still loops. + * Consumers map a clip back to its node by walking up from a channel's target to + * the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic. + */ +function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip { + const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks) + clip.userData = { loop: false } + return clip +} + +/** + * Bake a window's open motion generically: snapshot every part's pose closed, + * pose the subtree open, and emit a track for whichever parts actually moved + * (translation for sliding/hung sashes, rotation for casement/awning/louvre). + * Reusing the live `poseWindowMovingParts` keeps one source of truth for window + * kinematics. The subtree is left posed closed as the GLB's rest state. + */ +function bakeWindowClip( + id: string, + node: WindowNode, + windowObject: THREE.Object3D, +): THREE.AnimationClip | null { + poseWindowMovingParts(node, windowObject, 0) + + const closedPoses = new Map< + THREE.Object3D, + { position: THREE.Vector3; quaternion: THREE.Quaternion } + >() + windowObject.traverse((object) => { + closedPoses.set(object, { + position: object.position.clone(), + quaternion: object.quaternion.clone(), + }) + }) + + if (!poseWindowMovingParts(node, windowObject, 1)) return null + + const tracks: THREE.KeyframeTrack[] = [] + windowObject.traverse((object) => { + const closed = closedPoses.get(object) + if (!closed) return + + if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${object.uuid}.position`, + [0, 1], + [...closed.position.toArray(), ...object.position.toArray()], + ), + ) + } + if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) { + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${object.uuid}.quaternion`, + [0, 1], + [...closed.quaternion.toArray(), ...object.quaternion.toArray()], + ), + ) + } + }) + + poseWindowMovingParts(node, windowObject, 0) + + if (tracks.length === 0) return null + return openClip(id, node, tracks) +} + +// --- Identity stamping --------------------------------------------------- + +/** + * Replace every clone's userData with `{}`, then stamp identity onto the nodes + * that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker + * (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the + * file describes itself with exactly the fields a consumer needs. + */ +/** + * Human-readable label for a baked node, mirroring the viewer's `getNodeName`: + * an explicit name wins, items fall back to their catalog asset name, other + * kinds to a capitalized type. Levels override this with their display name. + */ +function nodeDisplayLabel(node: AnyNode): string { + if (node.name) return node.name + switch (node.type) { + case 'item': + return (node as { asset?: { name?: string } }).asset?.name || 'Item' + case 'wall': + return 'Wall' + case 'door': + return 'Door' + case 'window': + return 'Window' + case 'slab': + return 'Slab' + case 'ceiling': + return 'Ceiling' + case 'roof': + return 'Roof' + case 'fence': + return 'Fence' + case 'column': + return 'Column' + case 'stair': + return 'Stairs' + default: + return node.type + } +} + +function stampIdentity( + scene: THREE.Object3D, + cloneByOriginal: Map, + nodes: Record, + clipNamesByNode: Map, +) { + scene.traverse((object) => { + object.userData = {} + }) + + for (const [id, original] of sceneRegistry.nodes) { + const node = nodes[id] + const target = cloneByOriginal.get(original) + if (!node || !target) continue + + target.name = id + const extras: Record = { pascalId: id, kind: node.type } + // Stamp a human label for every node (catalog name for items, a type label + // otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds. + extras.label = nodeDisplayLabel(node) + // Camera bookmarks ride on the identity node (any kind can carry one) so the + // baked viewer flies to a saved pose on selection without a side file. + if (node.camera) extras.camera = node.camera + // Levels carry no stored name; stamp the editor's display name ("Level 1") + // so the baked viewer's level/breadcrumb UI reads the same labels. Force the + // node visible: the bake must capture every floor regardless of the editor's + // current level mode (solo/hidden floors would otherwise be dropped by + // GLTFExporter's `onlyVisible`). + if (node.type === 'level') { + extras.label = getLevelDisplayName(node as LevelNode) + target.visible = true + } + // Only doors/windows that actually baked an open clip are openable. A cased + // opening (no leaf) or a fixed window (no operable sash) produces no clip, so + // it stays unflagged — the file never claims a part opens when nothing moves. + if (node.type === 'door' || node.type === 'window') { + const clipNames = clipNamesByNode.get(id) + if (clipNames?.length) { + extras.openable = true + extras.clips = clipNames + } + } + // Items with a baked ambient clip (a fan's spin) carry the clip name but no + // `openable` flag — nothing opens; the clip just loops. + if (node.type === 'item') { + const clipNames = clipNamesByNode.get(id) + if (clipNames?.length) extras.clips = clipNames + } + if (node.type === 'zone') { + // Zone fills are stripped from the bake; /viewer rebuilds the room from + // this polygon. Force the identity node visible so GLTFExporter's + // `onlyVisible` keeps it even when the editor had zones hidden at export. + const zone = node as ZoneNode + extras.polygon = zone.polygon + extras.color = zone.color + target.visible = true + } + if (node.type === 'spawn') { + // The spawn marker's visible mesh lives on a non-scene overlay layer (and + // is pruned), so this identity node is an empty transform. Keep it + force + // visible so the baked walkthrough can read its world position/yaw and + // start the player there (`extras.rotation` mirrors the node's yaw). + extras.rotation = (node as { rotation?: number }).rotation ?? 0 + target.visible = true + } + target.userData = extras + } +} diff --git a/packages/editor/src/lib/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index 262525d5..a4551410 100644 --- a/packages/editor/src/lib/sfx-bus.ts +++ b/packages/editor/src/lib/sfx-bus.ts @@ -10,6 +10,7 @@ type SFXEvents = { 'sfx:item-pick': undefined 'sfx:item-place': undefined 'sfx:item-rotate': undefined + 'sfx:resize': undefined 'sfx:structure-build-start': undefined 'sfx:structure-build': undefined 'sfx:structure-delete': undefined @@ -40,6 +41,7 @@ export function initSFXBus() { sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick')) sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace')) sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate')) + sfxEmitter.on('sfx:resize', () => playSFX('resize')) sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart')) sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd')) sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete')) diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index f4d12e65..e50538d0 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -59,6 +59,16 @@ export const SFX: Record = { volumeRange: [0.92, 1.0], panJitter: 0.15, }, + // Ticks as a resize handle is dragged across snap steps. Fires in rapid + // succession, so it mirrors gridSnap: three variations cycled round-robin + // with pitch/pan jitter and a gap so the run reads as texture, not a tone. + resize: { + src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'], + rateRange: [0.98, 1.02], + volumeRange: [0.26, 0.34], + panJitter: 0.15, + minIntervalMs: 80, + }, // Fired when a structure draft begins (first click of a wall/slab/etc). structureBuildStart: { src: '/audios/sfx/structure_build_start.mp3', diff --git a/packages/ifc-converter/package.json b/packages/ifc-converter/package.json index b639fa22..a6a59c82 100644 --- a/packages/ifc-converter/package.json +++ b/packages/ifc-converter/package.json @@ -18,7 +18,7 @@ ], "scripts": { "build": "tsc --build", - "dev": "tsc --build --watch", + "dev": "tsgo --build --watch", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/packages/mcp/package.json b/packages/mcp/package.json index b8eb6207..aaadac8a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -48,7 +48,7 @@ ], "scripts": { "build": "tsc --build", - "dev": "tsc --build --watch", + "dev": "tsgo --build --watch", "start": "bun dist/bin/pascal-mcp.js", "test": "bun test", "smoke": "bun run scripts/smoke.ts", diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 00026c01..78b16690 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -18,7 +18,7 @@ ], "scripts": { "build": "tsc --build", - "dev": "tsc --build --watch", + "dev": "tsgo --build --watch", "test": "bun test", "prepublishOnly": "bun run build && bun test" }, diff --git a/packages/nodes/src/box-vent/move-tool.tsx b/packages/nodes/src/box-vent/move-tool.tsx index 6553f7a3..1c9ec302 100644 --- a/packages/nodes/src/box-vent/move-tool.tsx +++ b/packages/nodes/src/box-vent/move-tool.tsx @@ -5,6 +5,7 @@ import { type BoxVentNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import BoxVentPreview from './preview' /** @@ -72,10 +79,21 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -102,12 +120,18 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -152,6 +176,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -172,6 +197,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -191,6 +217,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -223,6 +250,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) { // the original mesh visible rather than stranded invisible. const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index 23c8af63..27dd2da6 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { boxVentDefinition } from './definition' import BoxVentPreview from './preview' @@ -85,6 +90,15 @@ const BoxVentTool = () => { setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode({ + ...previewNode, + rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment), + }), + }) event.stopPropagation() } @@ -109,6 +123,7 @@ const BoxVentTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [vent.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -120,8 +135,9 @@ const BoxVentTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -131,6 +147,7 @@ const BoxVentTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/chimney/move-tool.tsx b/packages/nodes/src/chimney/move-tool.tsx index 5e4690f9..bf056266 100644 --- a/packages/nodes/src/chimney/move-tool.tsx +++ b/packages/nodes/src/chimney/move-tool.tsx @@ -10,11 +10,25 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { consumePlacementDragRelease, triggerSFX, useEditor } from '@pascal-app/editor' +import { + consumePlacementDragRelease, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { createRelativeRoofDrag, type RelativeRoofDragTarget } from '../shared/relative-roof-drag' +import { + createRelativeRoofDrag, + type RelativeRoofDragTarget, + snapRelativeRoofDragTarget, +} from '../shared/relative-roof-drag' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import ChimneyPreview from './preview' const tmpMatrix = new THREE.Matrix4() @@ -67,6 +81,25 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { useEffect(() => { if (!activeBuildingId) return + useScene.temporal.getState().pause() + + const original = { + position: [...node.position] as [number, number, number], + rotation: node.rotation ?? 0, + roofSegmentId: node.roofSegmentId, + parentId: node.parentId, + metadata: node.metadata, + } + const meta = + node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) + ? (node.metadata as Record) + : {} + const isNew = !!meta.isNew + + if (node.id) { + const chimneyObj = sceneRegistry.nodes.get(node.id) + if (chimneyObj) chimneyObj.visible = false + } const computeSegmentXform = (segmentId: string): SegmentTransform | null => { const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId) @@ -84,25 +117,33 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } let lastTarget: RelativeRoofDragTarget | null = null + let committed = false const roofDrag = createRelativeRoofDrag({ - position: [...node.position] as [number, number, number], - roofSegmentId: node.roofSegmentId, + position: original.position, + roofSegmentId: original.roofSegmentId, }) + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) + } + const clearTarget = () => { lastTarget = null setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) - if (!target) { - clearTarget() - return - } - lastTarget = target + const target = resolveSnappedTarget(event) + if (!target) return clearTarget() const sx = Math.round(target.localX * 20) / 20 const sz = Math.round(target.localZ * 20) / 20 @@ -113,26 +154,32 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { } const xform = computeSegmentXform(target.segment.id) - if (!xform) return + if (!xform) return clearTarget() + lastTarget = target setSegmentXform(xform) setHitLocal([target.localX, target.localY, target.localZ]) setPreviewSegment(target.segment) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onClick = (event: RoofEvent) => { - const target = lastTarget ?? roofDrag.resolve(event) + if (committed) return + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return + committed = true const state = useScene.getState() // Strip the `isNew` flag — only used to mark a duplicate clone // that hasn't been committed yet. - const meta = - node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata) - ? (node.metadata as Record) - : {} const { isNew, ...restMeta } = meta as { isNew?: boolean } const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined + const targetSegmentId = target.segment.id as AnyNodeId // Duplicate (clone with no committed id yet) → create a fresh // chimney parented to the hit segment. Plain move (existing id, @@ -143,29 +190,105 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { ...node, id: undefined as never, roofSegmentId: target.segment.id, + parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + visible: true, metadata: cleanedMeta, }) - state.createNode(committed, target.segment.id as AnyNodeId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().resume() + state.applyNodeChanges({ + delete: node.id ? [node.id as AnyNodeId] : [], + create: [{ node: committed, parentId: targetSegmentId }], + }) + state.dirtyNodes.add(targetSegmentId) setSelection({ selectedIds: [committed.id] }) + useScene.temporal.getState().pause() } else { - const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined + const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined + const reparenting = Boolean(prevSegmentId && prevSegmentId !== targetSegmentId) + // Resume BEFORE any scene edits so the reparent (both segments' + // children arrays + the chimney's own host/position update) lands as + // one tracked transaction. Otherwise undo reverts the chimney but + // leaves the children arrays inconsistent with its parentId. + useScene.temporal.getState().resume() + if (reparenting) { + const oldSeg = state.nodes[prevSegmentId!] as RoofSegmentNode | undefined + if (oldSeg) { + state.updateNode(prevSegmentId!, { + children: (oldSeg.children ?? []).filter((id) => id !== node.id), + }) + } + const newSeg = state.nodes[targetSegmentId] as RoofSegmentNode | undefined + if (newSeg && !(newSeg.children ?? []).includes(node.id)) { + state.updateNode(targetSegmentId, { + children: [...(newSeg.children ?? []), node.id], + }) + } + state.dirtyNodes.add(prevSegmentId!) + } state.updateNode(node.id as AnyNodeId, { roofSegmentId: target.segment.id, parentId: target.segment.id, position: [target.localX, target.localY, target.localZ], + rotation: original.rotation, + visible: true, metadata: cleanedMeta, }) - if (prevSegmentId) state.dirtyNodes.add(prevSegmentId) - state.dirtyNodes.add(target.segment.id as AnyNodeId) + useScene.temporal.getState().pause() + state.dirtyNodes.add(targetSegmentId) + state.dirtyNodes.add(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } + const obj = node.id && !isNew ? sceneRegistry.nodes.get(node.id) : null + if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() setMovingNode(null) triggerSFX('sfx:item-place') event.stopPropagation() } + const onCancel = () => { + if (isNew) { + if (node.id) { + const parentId = original.roofSegmentId as AnyNodeId | undefined + if (parentId) { + const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined + if (parent) { + useScene.getState().updateNode(parentId, { + children: (parent.children ?? []).filter((id) => id !== node.id), + }) + } + } + useScene.getState().deleteNode(node.id as AnyNodeId) + } + useScene.temporal.getState().resume() + markToolCancelConsumed() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + return + } + + if (node.id) { + useScene.getState().updateNode(node.id as AnyNodeId, { + position: original.position, + rotation: original.rotation, + roofSegmentId: original.roofSegmentId as AnyNodeId | undefined, + parentId: original.parentId as AnyNodeId | undefined, + metadata: original.metadata, + }) + if (original.roofSegmentId) { + useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId) + } + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + } + + useScene.temporal.getState().resume() + markToolCancelConsumed() + clearRoofSurfacePlacementGuides() + setMovingNode(null) + } + const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return if (!lastTarget) return @@ -179,6 +302,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.on('roof:enter', updatePreview) emitter.on('roof:click', onClick) emitter.on('roof:leave', clearTarget) + emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { @@ -186,7 +310,15 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => { emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) emitter.off('roof:leave', clearTarget) + emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + + if (node.id) { + const obj = sceneRegistry.nodes.get(node.id) + if (obj) obj.visible = true + } + clearRoofSurfacePlacementGuides() + useScene.temporal.getState().resume() } }, [activeBuildingId, node, setMovingNode, setSelection]) diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index 19a7ada0..e8455b02 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -16,6 +16,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { chimneyDefinition } from './definition' import ChimneyPreview from './preview' @@ -102,6 +107,12 @@ const ChimneyTool = () => { setSegmentXform(xform) setHitLocal([hit.localX, hit.localY, hit.localZ]) setPreviewSegment(hit.segment) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode, { segment: hit.segment }), + }) event.stopPropagation() } @@ -126,6 +137,7 @@ const ChimneyTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [chimney.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -137,8 +149,9 @@ const ChimneyTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -149,6 +162,7 @@ const ChimneyTool = () => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index fb070206..53a6d5bd 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -2404,18 +2404,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { textures, colorPreset, }), - [ - shading, - textures, - colorPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.materialPreset, - node.slots, - sceneMaterials, - ], + [shading, textures, colorPreset, node, sceneMaterials], ) useRegistry(node.id, node.type, ref) diff --git a/packages/nodes/src/cupola/move-tool.tsx b/packages/nodes/src/cupola/move-tool.tsx index 1d888217..0e02936b 100644 --- a/packages/nodes/src/cupola/move-tool.tsx +++ b/packages/nodes/src/cupola/move-tool.tsx @@ -5,6 +5,7 @@ import { type CupolaNode, emitter, type RoofEvent, + type RoofNode, type RoofSegmentNode, sceneRegistry, useScene, @@ -21,8 +22,14 @@ import { createRelativeRoofDrag, type RelativeRoofDragTarget, roofSegmentLocalToBuildingLocal, + snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfaceNodePlacementGuides, + snapRoofSurfaceNodeTarget, +} from '../shared/roof-surface-placement-guides' import CupolaPreview from './preview' /** @@ -70,10 +77,21 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { lastSnap = null setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() + } + + const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => { + const rawTarget = roofDrag.resolve(event) + if (!rawTarget) return null + return snapRoofSurfaceNodeTarget({ + target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true), + node, + bypass: event.nativeEvent?.shiftKey === true, + }) } const updatePreview = (event: RoofEvent) => { - const target = roofDrag.resolve(event) + const target = resolveSnappedTarget(event) if (!target) { clearTarget() return @@ -100,12 +118,18 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { target.localZ, ]), ) + publishRoofSurfaceNodePlacementGuides({ + roof: event.node as RoofNode, + segment: target.segment, + center: [target.localX, target.localY, target.localZ], + node, + }) event.stopPropagation() } const onRoofClick = (event: RoofEvent) => { if (committed) return - const target = lastTarget ?? roofDrag.resolve(event) + const target = lastTarget ?? resolveSnappedTarget(event) if (!target) return committed = true const targetSegmentId = target.segment.id as AnyNodeId @@ -146,6 +170,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { if (obj) obj.visible = true triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() exitMoveMode() event.stopPropagation() } @@ -164,6 +189,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.getState().deleteNode(node.id as AnyNodeId) useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() return } @@ -183,6 +209,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { useScene.temporal.getState().resume() markToolCancelConsumed() + clearRoofSurfacePlacementGuides() exitMoveMode() } @@ -212,6 +239,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) { const obj = sceneRegistry.nodes.get(node.id) if (obj) obj.visible = true + clearRoofSurfacePlacementGuides() useScene.temporal.getState().resume() } }, [exitMoveMode, node]) diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 2e137394..fbf004de 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -16,6 +16,11 @@ import * as THREE from 'three' import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview' import { resolveRoofSegmentHit } from '../shared/roof-segment-hit' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { + clearRoofSurfacePlacementGuides, + publishRoofSurfacePlacementGuides, + roofSurfaceFootprintFromNode, +} from '../shared/roof-surface-placement-guides' import { cupolaDefinition } from './definition' import CupolaPreview from './preview' @@ -77,6 +82,12 @@ const CupolaTool = () => { setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion())) setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) setPreviewPos(worldToBuildingLocal(wx, wy, wz)) + publishRoofSurfacePlacementGuides({ + roof: event.node as RoofNode, + segment: hit.segment, + center: [hit.localX, hit.localY, hit.localZ], + footprint: roofSurfaceFootprintFromNode(previewNode), + }) event.stopPropagation() } @@ -101,6 +112,7 @@ const CupolaTool = () => { state.dirtyNodes.add(hit.segment.id as AnyNodeId) setSelection({ selectedIds: [cupola.id] }) triggerSFX('sfx:item-place') + clearRoofSurfacePlacementGuides() event.stopPropagation() } @@ -112,8 +124,9 @@ const CupolaTool = () => { emitter.off('roof:move', updatePreview) emitter.off('roof:enter', updatePreview) emitter.off('roof:click', onClick) + clearRoofSurfacePlacementGuides() } - }, [activeBuildingId, setSelection]) + }, [activeBuildingId, setSelection, previewNode]) return ( <> @@ -123,6 +136,7 @@ const CupolaTool = () => { onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) + clearRoofSurfacePlacementGuides() }} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/door/preview.tsx b/packages/nodes/src/door/preview.tsx index 8cd9b637..f2441caf 100644 --- a/packages/nodes/src/door/preview.tsx +++ b/packages/nodes/src/door/preview.tsx @@ -29,7 +29,7 @@ const DoorPreview = ({ const m = buildDoorPreviewMesh(node) m.layers.set(EDITOR_LAYER) return m - }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + }, [node]) // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; // its cleanup only disposes the clones it made. diff --git a/packages/nodes/src/dormer/__tests__/schema.test.ts b/packages/nodes/src/dormer/__tests__/schema.test.ts index 0a16e44c..eb833c34 100644 --- a/packages/nodes/src/dormer/__tests__/schema.test.ts +++ b/packages/nodes/src/dormer/__tests__/schema.test.ts @@ -12,7 +12,7 @@ describe('DormerNode schema', () => { expect(parsed.height).toBe(0) expect(parsed.roofType).toBe('gable') expect(parsed.windowShape).toBe('rectangle') - expect(parsed.windowSill).toBe(true) + expect(parsed.windowSill).toBe(false) }) test('windowColumns / windowRows clamped to [1, 8]', () => { diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index 17e9581d..ab7a1518 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -33,6 +33,9 @@ const MAX_SKIRT = 6 const WINDOW_SIDE_HANDLE_OFFSET = 0.15 const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15 const WINDOW_FACE_Z_OFFSET = 0.05 +// The four window-edge arrows latch behind a cube at the window center; +// they stay hidden until the user clicks that cube to open the group. +const WINDOW_LATCH_GROUP = 'dormer-window' // Lower clamp for window dims matches the geometry's internal clamp // in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the // dormer dimensions and are resolved per-handle via the function form @@ -109,21 +112,43 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor { +// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric resize: +// dragging one arrow grows the dormer outward from its own edge while +// the opposite edge stays world-fixed in segment frame — same pattern +// as `dormerWidthHandle`, just on the Z axis. `apply` recomputes +// `position` so the anchored edge stays at the same segment-local point +// even when the dormer is Y-rotated: project the dormer's local +Z onto +// segment frame via (sin r, cos r), find the anchored edge's segment- +// local XZ from the pre-drag node, then place the new center half a new- +// depth away from that anchor in the same direction. +function dormerDepthHandle(side: 'front' | 'back'): HandleDescriptor { + const sign = side === 'front' ? 1 : -1 return { kind: 'linear-resize', axis: 'z', - anchor: 'center', + // 'min' = -Z edge anchored (front arrow grows the +Z edge outward). + // 'max' = +Z edge anchored (back arrow grows the -Z edge outward). + anchor: side === 'front' ? 'min' : 'max', min: MIN_DIM, currentValue: (n) => n.depth, - apply: (_n, newValue) => ({ depth: newValue }), + apply: (initial, newDepth) => { + const rotY = initial.rotation ?? 0 + const armX = Math.sin(rotY) + const armZ = Math.cos(rotY) + const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX + const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ + const newCenterX = anchorX + sign * (newDepth / 2) * armX + const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ + return { + depth: newDepth, + position: [newCenterX, initial.position[1], newCenterZ], + } + }, placement: { - position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET], + position: (n) => [0, getBodyMidY(n), sign * (n.depth / 2 + SIDE_HANDLE_OFFSET)], + // The renderer auto-yaws axis-'z' chevrons by -π/2 so the default + // points +Z (front). Flip the back chevron 180° to point -Z. + rotationY: () => (side === 'front' ? 0 : Math.PI), }, } } @@ -273,6 +298,11 @@ function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor