Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,12 +17,13 @@ https://github.com/user-attachments/assets/8b50e7cf-cebe-4579-9cf3-8786b35f7b6b
|
|||||||
This is a Turborepo monorepo with three main packages:
|
This is a Turborepo monorepo with three main packages:
|
||||||
|
|
||||||
```
|
```
|
||||||
editor-v2/
|
editor/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ └── editor/ # Next.js application
|
│ └── editor/ # Next.js application
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── core/ # Schema definitions, state management, systems
|
│ ├── core/ # Schema definitions, state management, systems
|
||||||
│ └── viewer/ # 3D rendering components
|
│ ├── viewer/ # 3D rendering components
|
||||||
|
│ └── ui/ # Shared UI components
|
||||||
```
|
```
|
||||||
|
|
||||||
### Separation of Concerns
|
### Separation of Concerns
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ A 3D building editor built with React Three Fiber and WebGPU.
|
|||||||
This is a Turborepo monorepo with three main packages:
|
This is a Turborepo monorepo with three main packages:
|
||||||
|
|
||||||
```
|
```
|
||||||
editor-v2/
|
editor/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ └── editor/ # Next.js application (this package)
|
│ └── editor/ # Next.js application (this package)
|
||||||
├── packages/
|
├── packages/
|
||||||
|
|||||||
@@ -172,7 +172,8 @@ export function BuildTab() {
|
|||||||
const ductContext =
|
const ductContext =
|
||||||
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
|
mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting')
|
||||||
const pipeContext =
|
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 liquidLineContext = mode === 'build' && activeTool === 'liquid-line'
|
||||||
|
|
||||||
const isMepItemActive = (item: MepItem) =>
|
const isMepItemActive = (item: MepItem) =>
|
||||||
@@ -445,6 +446,30 @@ export function BuildTab() {
|
|||||||
/>
|
/>
|
||||||
Add Fitting
|
Add Fitting
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all duration-200',
|
||||||
|
activeTool === 'pipe-trap'
|
||||||
|
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'bg-muted/40 hover:bg-muted',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
triggerSFX('sfx:menu-click')
|
||||||
|
activateBuildTool(activeTool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap')
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
className="size-4 object-contain"
|
||||||
|
height={16}
|
||||||
|
src="/icons/dwv-pipes.png"
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
Add Trap
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -119,11 +119,12 @@ const levelModeLabels: Record<string, string> = {
|
|||||||
solo: 'Solo',
|
solo: 'Solo',
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallModeOrder = ['cutaway', 'up', 'down'] as const
|
const wallModeOrder = ['cutaway', 'up', 'down', 'translucent'] as const
|
||||||
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
const wallModeConfig: Record<string, { icon: string; label: string }> = {
|
||||||
up: { icon: '/icons/room.webp', label: 'Full height' },
|
up: { icon: '/icons/room.webp', label: 'Full height' },
|
||||||
cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' },
|
cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' },
|
||||||
down: { icon: '/icons/walllow.webp', label: 'Low' },
|
down: { icon: '/icons/walllow.webp', label: 'Low' },
|
||||||
|
translucent: { icon: '/icons/wall.webp', label: 'Translucent' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHADING_OPTIONS = [
|
const SHADING_OPTIONS = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "dotenv -e ../../.env.local -- next build",
|
"build": "dotenv -e ../../.env.local -- next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"check-types": "next typegen && tsc --noEmit"
|
"check-types": "next typegen && tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.0",
|
"geist": "^1.7.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ import { Box, Grid2x2, Layers, Layers2, Maximize, ScanLine, Square } from 'lucid
|
|||||||
import { type ReactNode, useMemo } from 'react'
|
import { type ReactNode, useMemo } from 'react'
|
||||||
|
|
||||||
const levelModes = ['stacked', 'solo', 'exploded', 'manual'] as const
|
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> = {
|
const levelLabel: Record<(typeof levelModes)[number], string> = {
|
||||||
stacked: 'Stack',
|
stacked: 'Stack',
|
||||||
@@ -27,6 +27,7 @@ const wallLabel: Record<(typeof wallModes)[number], string> = {
|
|||||||
up: 'Full',
|
up: 'Full',
|
||||||
cutaway: 'Cutaway',
|
cutaway: 'Cutaway',
|
||||||
down: 'Down',
|
down: 'Down',
|
||||||
|
translucent: 'Translucent',
|
||||||
}
|
}
|
||||||
|
|
||||||
function cycle<T>(list: readonly T[], current: T): T {
|
function cycle<T>(list: readonly T[], current: T): T {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"check-types": "next typegen && tsc --noEmit"
|
"check-types": "next typegen && tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pascal-app/core": "*",
|
"@pascal-app/core": "*",
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"next": "16.2.6",
|
"next": "16.2.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"name": "editor",
|
"name": "editor",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.4.16",
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20260624.1",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"turbo": "^2.9.17",
|
"turbo": "^2.9.17",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.7.0",
|
"geist": "^1.7.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^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=="],
|
"@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/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=="],
|
"@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-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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.4.16",
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20260624.1",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"turbo": "^2.9.17",
|
"turbo": "^2.9.17",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsc --build --watch",
|
"dev": "tsgo --build --watch",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
"bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
|
|||||||
@@ -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<string, ItemClipEntry>()
|
||||||
@@ -35,6 +35,7 @@ export type {
|
|||||||
ZoneEvent,
|
ZoneEvent,
|
||||||
} from './events/bus'
|
} from './events/bus'
|
||||||
export { emitter, eventSuffixes } from './events/bus'
|
export { emitter, eventSuffixes } from './events/bus'
|
||||||
|
export { type ItemClipEntry, itemClipRegistry } from './hooks/scene-registry/item-clip-registry'
|
||||||
export {
|
export {
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useRegistry,
|
useRegistry,
|
||||||
|
|||||||
@@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSceneMaterialIdFromRef(materialRef?: string | null): 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)
|
return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,25 @@ export type LinearResizeHandle<N> = {
|
|||||||
* the roof shell below it. Only consulted when `shape === 'tracker'`.
|
* the roof shell below it. Only consulted when `shape === 'tracker'`.
|
||||||
*/
|
*/
|
||||||
trackerBaseY?: (node: N, sceneApi: SceneApi) => number
|
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<N = any> = {
|
|||||||
portal?: HandlePortal
|
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<N = any> = {
|
||||||
|
kind: 'latch'
|
||||||
|
/** The `latchGroup` name whose arrows this cube reveals / hides. */
|
||||||
|
group: string
|
||||||
|
placement: HandlePlacement<N>
|
||||||
|
portal?: HandlePortal
|
||||||
|
}
|
||||||
|
|
||||||
export type HandleDescriptor<N = any> =
|
export type HandleDescriptor<N = any> =
|
||||||
| LinearResizeHandle<N>
|
| LinearResizeHandle<N>
|
||||||
| RadialResizeHandle<N>
|
| RadialResizeHandle<N>
|
||||||
@@ -372,6 +410,7 @@ export type HandleDescriptor<N = any> =
|
|||||||
| EndpointMoveHandle<N>
|
| EndpointMoveHandle<N>
|
||||||
| TapActionHandle<N>
|
| TapActionHandle<N>
|
||||||
| TranslateHandle<N>
|
| TranslateHandle<N>
|
||||||
|
| LatchHandle<N>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static array, or a function for shape-dependent cases (column
|
* Static array, or a function for shape-dependent cases (column
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
HandleList,
|
HandleList,
|
||||||
HandlePlacement,
|
HandlePlacement,
|
||||||
HandlePortal,
|
HandlePortal,
|
||||||
|
LatchHandle,
|
||||||
LinearResizeHandle,
|
LinearResizeHandle,
|
||||||
RadialResizeHandle,
|
RadialResizeHandle,
|
||||||
TapActionHandle,
|
TapActionHandle,
|
||||||
|
|||||||
@@ -1665,6 +1665,23 @@ export type ParametricDescriptor<N> = {
|
|||||||
* `updateNodes`.
|
* `updateNodes`.
|
||||||
*/
|
*/
|
||||||
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
|
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
|
||||||
|
/**
|
||||||
|
* 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<AnyNodeId, AnyNode>,
|
||||||
|
) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
|
||||||
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||||
/**
|
/**
|
||||||
* Extra buttons rendered in the inspector's Actions section
|
* Extra buttons rendered in the inspector's Actions section
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,7 +27,8 @@ export type MaterialProperties = z.infer<typeof MaterialProperties>
|
|||||||
|
|
||||||
export const MaterialSchema = z.object({
|
export const MaterialSchema = z.object({
|
||||||
id: z.string().optional(),
|
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(),
|
properties: MaterialProperties.optional(),
|
||||||
texture: z
|
texture: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({
|
|||||||
windowCornerRadii: z
|
windowCornerRadii: z
|
||||||
.tuple([z.number(), z.number(), z.number(), z.number()])
|
.tuple([z.number(), z.number(), z.number(), z.number()])
|
||||||
.default(DEFAULT_CORNER_RADII),
|
.default(DEFAULT_CORNER_RADII),
|
||||||
windowSill: z.boolean().default(true),
|
windowSill: z.boolean().default(false),
|
||||||
windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH),
|
windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH),
|
||||||
windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS),
|
windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS),
|
||||||
}).describe(
|
}).describe(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const DuctFittingNode = BaseNode.extend({
|
|||||||
// matching the trunk the fitting sits in. Reducers ignore the shape.
|
// matching the trunk the fitting sits in. Reducers ignore the shape.
|
||||||
// When non-round, `diameter` carries the area-equivalent round size
|
// When non-round, `diameter` carries the area-equivalent round size
|
||||||
// (drives leg lengths + advertised ports).
|
// (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').
|
// Rect / oval run-leg profile in inches (used when shape ≠ 'round').
|
||||||
width: z.number().min(4).max(60).default(14),
|
width: z.number().min(4).max(60).default(14),
|
||||||
height: z.number().min(3).max(40).default(8),
|
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
|
// rect / oval profile matching the duct drawn off the tap. When
|
||||||
// non-round, `diameter2` carries the branch's area-equivalent round
|
// non-round, `diameter2` carries the branch's area-equivalent round
|
||||||
// size. A cross's two opposed branches share this one profile.
|
// 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').
|
// Rect / oval branch profile in inches (used when shape2 ≠ 'round').
|
||||||
width2: z.number().min(4).max(60).default(14),
|
width2: z.number().min(4).max(60).default(14),
|
||||||
height2: z.number().min(3).max(40).default(8),
|
height2: z.number().min(3).max(40).default(8),
|
||||||
// Elbow turn angle in degrees. Residential sheet-metal elbows come in
|
// Elbow turn angle in degrees. Residential sheet-metal elbows come in
|
||||||
// 90° and 45°; adjustable elbows cover the range between.
|
// 90° and 45°; adjustable elbows cover the range between. 0° is a
|
||||||
angle: z.number().min(15).max(90).default(90),
|
// 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°
|
// Tee branch angle in degrees, measured off the +X (outlet) axis: 90°
|
||||||
// is a square straight tee, <90° a lateral whose branch sweeps
|
// is a square straight tee, <90° a lateral whose branch sweeps
|
||||||
// downstream toward the outlet (flow merges), >90° leans the branch
|
// 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),
|
diameter2: z.number().min(2).max(48).default(6),
|
||||||
ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'),
|
ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'),
|
||||||
system: z.enum(['supply', 'return']).default('supply'),
|
system: z.enum(['supply', 'return']).default('supply'),
|
||||||
|
slots: z.record(z.string(), z.string()).optional(),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.
|
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({
|
|||||||
// Which side of the air loop this segment belongs to. Drives visual tint
|
// Which side of the air loop this segment belongs to. Drives visual tint
|
||||||
// and (in later slices) System graph membership.
|
// and (in later slices) System graph membership.
|
||||||
system: z.enum(['supply', 'return']).default('supply'),
|
system: z.enum(['supply', 'return']).default('supply'),
|
||||||
|
slots: z.record(z.string(), z.string()).optional(),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Duct segment - polyline of 3D points connected by duct sections.
|
Duct segment - polyline of 3D points connected by duct sections.
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ export const PipeFittingNode = BaseNode.extend({
|
|||||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'),
|
fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'),
|
||||||
// Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long
|
// Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long
|
||||||
// sweep" for drains); adjustable range matches the duct elbow.
|
// sweep" for drains); adjustable range matches the duct elbow. 0° is a
|
||||||
angle: z.number().min(15).max(90).default(90),
|
// 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.
|
// Run nominal size in inches.
|
||||||
diameter: z.number().min(1.25).max(8).default(2),
|
diameter: z.number().min(1.25).max(8).default(2),
|
||||||
// Branch collar size (wye / sanitary-tee).
|
// Branch collar size (wye / sanitary-tee).
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({
|
|||||||
// Yaw in radians (the arm direction in plan).
|
// Yaw in radians (the arm direction in plan).
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
// Trap size in inches — matches the fixture drain it serves.
|
// 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'),
|
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
|
||||||
// Developed length of the trap arm (trap weir → vent) in meters. The
|
// Developed length of the trap arm (trap weir → vent) in meters. The
|
||||||
// draw tool measures it when the arm is drawn; editable in the
|
// draw tool measures it when the arm is drawn; editable in the
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export {
|
|||||||
} from './hosting'
|
} from './hosting'
|
||||||
export {
|
export {
|
||||||
DEFAULT_LEVEL_HEIGHT,
|
DEFAULT_LEVEL_HEIGHT,
|
||||||
|
getCeilingAt,
|
||||||
|
getCeilingHeightAt,
|
||||||
getLevelHeight,
|
getLevelHeight,
|
||||||
} from './level-height'
|
} from './level-height'
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||||
|
|
||||||
@@ -40,3 +41,46 @@ export function getLevelHeight(
|
|||||||
|
|
||||||
return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
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<AnyNodeId, AnyNode>,
|
||||||
|
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<AnyNodeId, AnyNode>,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
): number | null {
|
||||||
|
const ceiling = getCeilingAt(levelId, nodes, x, z)
|
||||||
|
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>): AnyNode {
|
||||||
|
nextId += 1
|
||||||
|
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
|
||||||
|
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>), 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<string, unknown>),
|
||||||
|
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<string, unknown>),
|
||||||
|
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<string, unknown>),
|
||||||
|
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<string, unknown>),
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,14 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema'
|
|||||||
*
|
*
|
||||||
* Pure logic: it asks each node for its ports via `def.ports` (level-local
|
* 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
|
* 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
|
* core and is consumed by the editor's move tool and the duct/pipe
|
||||||
* system alike.
|
* selection affordances alike.
|
||||||
*
|
*
|
||||||
* Propagation is intentionally **one hop**: a moved fitting stretches the
|
* ## Propagation model
|
||||||
* 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
|
* The joint graph is snapshotted once at drag start (`analyzePortConnectivity`)
|
||||||
* those ducts or anything beyond. Bounded and predictable — no runaway
|
* and walked every frame (`resolveConnectivityUpdates`) given the moved node's
|
||||||
* network rearrangement.
|
* 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]
|
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. */
|
* generous slack for grid-snapped hand placement without false matches. */
|
||||||
const COINCIDENT_EPS_M = 0.05
|
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 =
|
export type PortConnection =
|
||||||
| {
|
| {
|
||||||
/** Partner is a duct run: the endpoint touching the moved port slides
|
/** A fitting mated collar-to-collar: it translates rigidly. */
|
||||||
* 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. */
|
|
||||||
kind: 'rigid-node'
|
kind: 'rigid-node'
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
movedPortId: string
|
/** Node's `position` at edit-start. */
|
||||||
/** Partner node's `position` at edit-start. */
|
|
||||||
startPosition: Point
|
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<string, Record<string, Array<{ nodeId: AnyNodeId; portId: string }>>>
|
||||||
|
|
||||||
export type PortConnectivity = {
|
export type PortConnectivity = {
|
||||||
movedNodeId: AnyNodeId
|
movedNodeId: AnyNodeId
|
||||||
/** The moved node's port world positions at edit-start, keyed by port id.
|
/** 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 reference each frame's delta is measured from. */
|
||||||
startMovedPorts: Record<string, Point>
|
startMovedPorts: Record<string, Point>
|
||||||
|
/** Reachable run/fitting nodes (excludes the moved node), keyed by id. */
|
||||||
|
graph: Record<string, GraphNode>
|
||||||
|
/** 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[]
|
connections: PortConnection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,85 +117,225 @@ function distSq(a: Point, b: Point): number {
|
|||||||
return dx * dx + dy * dy + dz * dz
|
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
|
* start of a move/resize. Call once before the drag; feed the result to
|
||||||
* `resolveConnectivityUpdates` on every frame.
|
* `resolveConnectivityUpdates` on every frame.
|
||||||
*
|
*
|
||||||
* Only `run`-role partners (segments — endpoint stretch) and `fitting`-role
|
* Only `run`-role partners (segments) and `fitting`-role partners are walked —
|
||||||
* partners (rigid follow) are tracked — terminals and equipment usually mount
|
* terminals and equipment usually mount to a surface and shouldn't be yanked
|
||||||
* to a surface and shouldn't be yanked off it when an adjacent fitting nudges.
|
* 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(
|
export function analyzePortConnectivity(
|
||||||
movedNode: AnyNode,
|
movedNode: AnyNode,
|
||||||
nodes: Record<string, AnyNode>,
|
nodes: Record<string, AnyNode>,
|
||||||
): PortConnectivity {
|
): PortConnectivity {
|
||||||
const movedPorts = portsOf(movedNode) ?? []
|
|
||||||
const startMovedPorts: Record<string, Point> = {}
|
|
||||||
const movedPortSystem: Record<string, string | undefined> = {}
|
|
||||||
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 epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||||
|
|
||||||
|
const movedPorts = portsOf(movedNode) ?? []
|
||||||
|
const startMovedPorts: Record<string, Point> = {}
|
||||||
|
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)) {
|
for (const other of Object.values(nodes)) {
|
||||||
if (!other || other.id === movedNode.id) continue
|
if (!other || other.id === movedNode.id) continue
|
||||||
// Generalised across every distribution family (HVAC duct + DWV pipe):
|
const role = roleOf(other)
|
||||||
// `run` partners stretch an endpoint, `fitting` partners follow rigidly.
|
if (role !== 'run' && role !== 'fitting') continue
|
||||||
// Terminals/equipment mount to surfaces and are intentionally NOT dragged.
|
if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) {
|
||||||
// Fittings that declare `portConnectivityFollow: false` are anchored
|
continue
|
||||||
// fixtures (e.g. pipe-trap) — moving a connected run stretches the arm.
|
}
|
||||||
const otherRole = roleOf(other)
|
const ports = portsOf(other)
|
||||||
if (otherRole !== 'run' && otherRole !== 'fitting') continue
|
if (!ports) continue
|
||||||
const otherDef = nodeRegistry.get(other.type)
|
const startPath =
|
||||||
if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue
|
role === 'run'
|
||||||
const otherPorts = portsOf(other)
|
? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point)
|
||||||
if (!otherPorts) continue
|
: undefined
|
||||||
|
if (role === 'run' && (!startPath || startPath.length < 2)) continue
|
||||||
for (const op of otherPorts) {
|
const startPosition =
|
||||||
// Find which of the moved node's ports this partner port sits on.
|
role === 'fitting'
|
||||||
let matchedId: string | null = null
|
? (() => {
|
||||||
for (const mp of movedPorts) {
|
const pos = (other as unknown as { position?: Point }).position
|
||||||
if (distSq(op.position, mp.position) > epsSq) continue
|
return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined
|
||||||
// Don't fuse ports from incompatible systems (e.g. a supply duct
|
})()
|
||||||
// and a waste pipe that happen to cross): only mate when both
|
: undefined
|
||||||
// ports declare the same system, or at least one is unscoped.
|
if (role === 'fitting' && !startPosition) continue
|
||||||
const ms = movedPortSystem[mp.id]
|
candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition })
|
||||||
if (ms && op.system && ms !== op.system) continue
|
|
||||||
matchedId = mp.id
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (!matchedId) continue
|
|
||||||
|
|
||||||
if (otherRole === 'run') {
|
// Walk outward from the moved node, collecting every node reachable through
|
||||||
const path = (other as unknown as { path?: Point[] }).path
|
// coincident ports. The adjacency records each port's mates so the resolver
|
||||||
if (!Array.isArray(path) || path.length < 2) continue
|
// can replay the same edges with live deltas.
|
||||||
// Port id 'start' → first point, 'end' → last point.
|
const adjacency: Adjacency = {}
|
||||||
const pathIndex = op.id === 'start' ? 0 : path.length - 1
|
const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => {
|
||||||
connections.push({
|
const byPort = adjacency[nodeId] ?? {}
|
||||||
kind: 'duct-endpoint',
|
adjacency[nodeId] = byPort
|
||||||
nodeId: other.id,
|
const mates = byPort[portId] ?? []
|
||||||
pathIndex,
|
byPort[portId] = mates
|
||||||
movedPortId: matchedId,
|
mates.push(mate)
|
||||||
startPath: path.map((p) => [...p] as Point),
|
}
|
||||||
})
|
|
||||||
} else {
|
const graph: Record<string, GraphNode> = {}
|
||||||
const position = (other as unknown as { position?: Point }).position
|
const visited = new Set<string>([movedNode.id])
|
||||||
if (!position) continue
|
|
||||||
connections.push({
|
// Seed: the moved node's own ports.
|
||||||
kind: 'rigid-node',
|
const queue: Array<{
|
||||||
nodeId: other.id,
|
id: string
|
||||||
movedPortId: matchedId,
|
ports: ReadonlyArray<{ id: string; position: Point; system?: string }>
|
||||||
startPosition: [position[0], position[1], position[2]],
|
}> = [{ 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<Point>((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<string, Record<string, Point>>,
|
||||||
|
): Record<string, Point> {
|
||||||
|
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<string, Point> {
|
||||||
|
return {
|
||||||
|
start: sub(path[0]!, startPath[0]!),
|
||||||
|
end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPathFromPortDeltas(startPath: Point[], portDeltas: Record<string, Point>): 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
|
* that keep every connected node attached. `previewNode` is the moved node
|
||||||
* with its current drag position/rotation applied so its ports recompute.
|
* with its current drag position/rotation applied so its ports recompute.
|
||||||
*
|
*
|
||||||
* - Duct endpoint: set the tracked path point to the moved port's new
|
* Walks the snapshotted graph, propagating each port delta outward: fittings
|
||||||
* position (the joint stays welded; the run stretches).
|
* translate rigidly, runs stretch along their axis and translate across it
|
||||||
* - Rigid fitting: translate by the moved port's delta so its mated collar
|
* (never skew when driven from one end), and effective port movement carries on
|
||||||
* rides along.
|
* 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(
|
export function resolveConnectivityUpdates(
|
||||||
connectivity: PortConnectivity,
|
connectivity: PortConnectivity,
|
||||||
previewNode: AnyNode,
|
previewNode: AnyNode,
|
||||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
|
||||||
|
const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity
|
||||||
|
if (Object.keys(graph).length === 0) return []
|
||||||
|
|
||||||
const newPorts = portsOf(previewNode) ?? []
|
const newPorts = portsOf(previewNode) ?? []
|
||||||
const newById: Record<string, Point> = {}
|
const newMovedPos: Record<string, Point> = {}
|
||||||
for (const p of newPorts) newById[p.id] = p.position
|
for (const p of newPorts) newMovedPos[p.id] = p.position
|
||||||
|
|
||||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
// Each queue item drives a node's port by a delta ("this collar / endpoint
|
||||||
for (const conn of connectivity.connections) {
|
// must move by this much").
|
||||||
const start = connectivity.startMovedPorts[conn.movedPortId]
|
const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = []
|
||||||
const now = newById[conn.movedPortId]
|
const results: Record<string, { id: AnyNodeId; data: Partial<AnyNode> }> = {}
|
||||||
if (!start || !now) continue
|
const constrainedPorts: Record<string, Record<string, Record<string, Point>>> = {}
|
||||||
|
const propagatedPorts: Record<string, Record<string, Point>> = {}
|
||||||
|
|
||||||
if (conn.kind === 'duct-endpoint') {
|
const enqueueMates = (nodeId: string, portId: string, delta: Point) => {
|
||||||
const path = conn.startPath.map((p, i) =>
|
const byPort = propagatedPorts[nodeId] ?? {}
|
||||||
i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point),
|
propagatedPorts[nodeId] = byPort
|
||||||
)
|
const previous = byPort[portId]
|
||||||
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
|
if (previous && propagationEqual(previous, delta)) return
|
||||||
} else {
|
byPort[portId] = delta
|
||||||
const dx = now[0] - start[0]
|
|
||||||
const dy = now[1] - start[1]
|
for (const mate of adjacency[nodeId]?.[portId] ?? []) {
|
||||||
const dz = now[2] - start[2]
|
if (mate.nodeId === movedNodeId) continue
|
||||||
updates.push({
|
queue.push({
|
||||||
id: conn.nodeId,
|
nodeId: mate.nodeId,
|
||||||
data: {
|
portId: mate.portId,
|
||||||
position: [
|
delta,
|
||||||
conn.startPosition[0] + dx,
|
sourceKey: `${nodeId}:${portId}`,
|
||||||
conn.startPosition[1] + dy,
|
|
||||||
conn.startPosition[2] + dz,
|
|
||||||
],
|
|
||||||
} as Partial<AnyNode>,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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<AnyNode>,
|
||||||
|
}
|
||||||
|
// 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<AnyNode> }
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { nodeRegistry } from '../../registry/registry'
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
@@ -1010,6 +1011,24 @@ export const deleteNodesAction = (
|
|||||||
}
|
}
|
||||||
for (const id of allIds) deletedIds.add(id)
|
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) {
|
for (const plan of mergePlans) {
|
||||||
const primaryWall = nextNodes[plan.primaryWallId]
|
const primaryWall = nextNodes[plan.primaryWallId]
|
||||||
if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) {
|
if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) {
|
||||||
|
|||||||
@@ -547,9 +547,18 @@ function migrateNodes(nodes: Record<string, any>): {
|
|||||||
// any per-type migration runs, so already-saved scenes load cleanly.
|
// any per-type migration runs, so already-saved scenes load cleanly.
|
||||||
const { nodes: healed } = healSceneNodes(nodes)
|
const { nodes: healed } = healSceneNodes(nodes)
|
||||||
const patchedNodes = { ...healed } as Record<string, any>
|
const patchedNodes = { ...healed } as Record<string, any>
|
||||||
|
|
||||||
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
||||||
// merged into the scene material map by the caller (`setScene`).
|
// merged into the scene material map by the caller (`setScene`).
|
||||||
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
|
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
|
||||||
|
|
||||||
|
// 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)) {
|
for (const [id, node] of Object.entries(patchedNodes)) {
|
||||||
// 1. Item scale migration
|
// 1. Item scale migration
|
||||||
if (node.type === 'item' && !('scale' in node)) {
|
if (node.type === 'item' && !('scale' in node)) {
|
||||||
@@ -682,14 +691,6 @@ function migrateNodes(nodes: Record<string, any>): {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// Roof-segment hosting was added in this migration cycle (the same
|
||||||
// pattern as shelf above). Older segments saved before the schema
|
// pattern as shelf above). Older segments saved before the schema
|
||||||
// gained `children` need the field initialised so
|
// gained `children` need the field initialised so
|
||||||
@@ -778,7 +779,59 @@ function migrateNodes(nodes: Record<string, any>): {
|
|||||||
patchedNodes[id] = { ...node, children: flattened }
|
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<string, AnyNode>, mintedMaterials }
|
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsgo --noEmit"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@pascal-app/core": "^0.9.1",
|
"@pascal-app/core": "^0.9.1",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { emitter, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useEffect } from 'react'
|
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 { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||||
|
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
||||||
|
|
||||||
export function ExportManager() {
|
export function ExportManager() {
|
||||||
const scene = useThree((state) => state.scene)
|
const scene = useThree((state) => state.scene)
|
||||||
@@ -22,7 +22,26 @@ export function ExportManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0]
|
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<typeof prepareSceneForExport>
|
||||||
|
try {
|
||||||
|
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
||||||
|
} finally {
|
||||||
|
emitter.emit('thumbnail:after-capture', undefined)
|
||||||
|
}
|
||||||
|
const { scene: exportScene, animations } = prepared
|
||||||
|
|
||||||
if (format === 'stl') {
|
if (format === 'stl') {
|
||||||
const exporter = new STLExporter()
|
const exporter = new STLExporter()
|
||||||
@@ -39,25 +58,6 @@ export function ExportManager() {
|
|||||||
downloadBlob(blob, `model_${date}.obj`)
|
downloadBlob(blob, `model_${date}.obj`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: GLB export (existing behavior)
|
|
||||||
const exporter = new GLTFExporter()
|
|
||||||
|
|
||||||
return new Promise<void>((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)
|
setExportScene(exportFn)
|
||||||
@@ -70,33 +70,6 @@ export function ExportManager() {
|
|||||||
return null
|
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) {
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
} from 'three'
|
} from 'three'
|
||||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||||
import '../../three-types'
|
import '../../three-types'
|
||||||
|
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
closeDoorOpenState,
|
closeDoorOpenState,
|
||||||
DOOR_SWING_OPEN_ANGLE,
|
DOOR_SWING_OPEN_ANGLE,
|
||||||
@@ -64,8 +65,6 @@ import {
|
|||||||
type FirstPersonColliderWorld,
|
type FirstPersonColliderWorld,
|
||||||
type FirstPersonSpawn,
|
type FirstPersonSpawn,
|
||||||
} from './first-person/build-collider-world'
|
} 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 CAMERA_EYE_OFFSET = 0.45
|
||||||
const LOOK_SENSITIVITY = 0.002
|
const LOOK_SENSITIVITY = 0.002
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
|
|||||||
const CHEVRON_BEVEL_THICKNESS = 0.035
|
const CHEVRON_BEVEL_THICKNESS = 0.035
|
||||||
const CHEVRON_BEVEL_SIZE = 0.03
|
const CHEVRON_BEVEL_SIZE = 0.03
|
||||||
const CHEVRON_BEVEL_SEGMENTS = 10
|
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_HALF_LENGTH = 0.36
|
||||||
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
|
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
|
||||||
const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
|
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_RIBBON_HALF_WIDTH = 0.02
|
||||||
const ROTATE_HEAD_HALF_WIDTH = 0.045
|
const ROTATE_HEAD_HALF_WIDTH = 0.045
|
||||||
const TRACKER_CUBE_SIZE = 0.16
|
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 HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
|
||||||
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
||||||
@@ -90,6 +97,8 @@ export type HandleArrowProps = {
|
|||||||
indicatorRotation?: readonly [number, number, number]
|
indicatorRotation?: readonly [number, number, number]
|
||||||
onPointerEnter?: PointerHandler
|
onPointerEnter?: PointerHandler
|
||||||
onPointerLeave?: PointerHandler
|
onPointerLeave?: PointerHandler
|
||||||
|
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
|
||||||
|
thin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
|
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;
|
// 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
|
// callers rotate it around Y for Z-axis handles and into a vertical frame for
|
||||||
// Y-axis handles.
|
// Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
|
||||||
export function createArrowHandleGeometry() {
|
export function createArrowHandleGeometry(thin = false) {
|
||||||
|
const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
|
||||||
const shape = new Shape()
|
const shape = new Shape()
|
||||||
shape.moveTo(CHEVRON_MAX_X, 0)
|
shape.moveTo(CHEVRON_MAX_X, 0)
|
||||||
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
|
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_NOTCH_X, -CHEVRON_HALF_WIDTH)
|
||||||
shape.lineTo(CHEVRON_MAX_X, 0)
|
shape.lineTo(CHEVRON_MAX_X, 0)
|
||||||
const geometry = new ExtrudeGeometry(shape, {
|
const geometry = new ExtrudeGeometry(shape, {
|
||||||
depth: CHEVRON_DEPTH,
|
depth,
|
||||||
bevelEnabled: true,
|
bevelEnabled: true,
|
||||||
bevelThickness: CHEVRON_BEVEL_THICKNESS,
|
bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
|
||||||
bevelSize: CHEVRON_BEVEL_SIZE,
|
bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
|
||||||
bevelOffset: 0,
|
bevelOffset: 0,
|
||||||
bevelSegments: CHEVRON_BEVEL_SEGMENTS,
|
bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
|
||||||
curveSegments: 16,
|
curveSegments: 16,
|
||||||
steps: 1,
|
steps: 1,
|
||||||
})
|
})
|
||||||
geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
|
geometry.translate(0, 0, -depth / 2)
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
geometry.computeBoundingSphere()
|
geometry.computeBoundingSphere()
|
||||||
@@ -326,8 +336,8 @@ export function createEndpointHitAreaGeometry(radius: number) {
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHandleArrowGeometry(shape: HandleArrowShape) {
|
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
|
||||||
if (shape === 'chevron') return createArrowHandleGeometry()
|
if (shape === 'chevron') return createArrowHandleGeometry(thin)
|
||||||
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
||||||
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
||||||
if (shape === 'tracker') {
|
if (shape === 'tracker') {
|
||||||
@@ -471,9 +481,10 @@ export function HandleArrow({
|
|||||||
onPointerDown,
|
onPointerDown,
|
||||||
onPointerEnter,
|
onPointerEnter,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
|
thin = false,
|
||||||
}: HandleArrowProps) {
|
}: HandleArrowProps) {
|
||||||
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
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 hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
|
||||||
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
||||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||||
|
|||||||
@@ -1261,6 +1261,7 @@ export default function Editor({
|
|||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<RoofEditSystem />
|
<RoofEditSystem />
|
||||||
<StairEditSystem />
|
<StairEditSystem />
|
||||||
|
{isFirstPersonMode && <FirstPersonControls />}
|
||||||
<CustomCameraControls />
|
<CustomCameraControls />
|
||||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||||
<InteractiveSystem />
|
<InteractiveSystem />
|
||||||
@@ -1319,8 +1320,14 @@ export default function Editor({
|
|||||||
|
|
||||||
{!isLoading && isPreviewMode ? (
|
{!isLoading && isPreviewMode ? (
|
||||||
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
|
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
|
||||||
|
{isFirstPersonMode ? (
|
||||||
|
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||||
|
) : (
|
||||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||||
<div className="h-full w-full">{previewViewerContent}</div>
|
)}
|
||||||
|
<div className="h-full w-full" data-pascal-viewer-3d>
|
||||||
|
{previewViewerContent}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -1384,8 +1391,14 @@ export default function Editor({
|
|||||||
|
|
||||||
{!isLoading && isPreviewMode ? (
|
{!isLoading && isPreviewMode ? (
|
||||||
<>
|
<>
|
||||||
|
{isFirstPersonMode ? (
|
||||||
|
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||||
|
) : (
|
||||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||||
<div className="h-full w-full">{previewViewerContent}</div>
|
)}
|
||||||
|
<div className="h-full w-full" data-pascal-viewer-3d>
|
||||||
|
{previewViewerContent}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
type HandlePortal,
|
type HandlePortal,
|
||||||
|
type LatchHandle,
|
||||||
type LinearResizeHandle,
|
type LinearResizeHandle,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
type RadialResizeHandle,
|
type RadialResizeHandle,
|
||||||
@@ -44,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { createEditorApi } from '../../lib/editor-api'
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
@@ -112,11 +114,16 @@ export {
|
|||||||
ARROW_COLOR,
|
ARROW_COLOR,
|
||||||
ARROW_HOVER_COLOR,
|
ARROW_HOVER_COLOR,
|
||||||
ARROW_SCALE,
|
ARROW_SCALE,
|
||||||
|
createArrowHandleGeometry,
|
||||||
createArrowHitAreaGeometry,
|
createArrowHitAreaGeometry,
|
||||||
createEndpointHitAreaGeometry,
|
createEndpointHitAreaGeometry,
|
||||||
createMoveCrossHandleGeometry,
|
createMoveCrossHandleGeometry,
|
||||||
createRotateArrowHandleGeometry,
|
createRotateArrowHandleGeometry,
|
||||||
createRotateArrowHitAreaGeometry,
|
createRotateArrowHitAreaGeometry,
|
||||||
|
HandleArrow,
|
||||||
|
type HandleArrowInputShape,
|
||||||
|
type HandleArrowPlacement,
|
||||||
|
type HandleArrowProps,
|
||||||
HIT_AREA_MARGIN,
|
HIT_AREA_MARGIN,
|
||||||
InvisibleHandleHitArea,
|
InvisibleHandleHitArea,
|
||||||
NO_RAYCAST,
|
NO_RAYCAST,
|
||||||
@@ -374,6 +381,21 @@ function NodeArrowHandlesForNode({
|
|||||||
// hook count between renders and trip React's rules-of-hooks check.
|
// hook count between renders and trip React's rules-of-hooks check.
|
||||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||||
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
|
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(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<ReadonlySet<string>>(() => 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<HandleDragControls>(
|
const dragControls = useMemo<HandleDragControls>(
|
||||||
() => ({
|
() => ({
|
||||||
onStart: (index: number, snapshot: AnyNode) => {
|
onStart: (index: number, snapshot: AnyNode) => {
|
||||||
@@ -411,6 +433,21 @@ function NodeArrowHandlesForNode({
|
|||||||
|
|
||||||
const arrows = descriptors.map((descriptor, index) => {
|
const arrows = descriptors.map((descriptor, index) => {
|
||||||
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
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 (
|
||||||
|
<LatchCube
|
||||||
|
descriptor={descriptor}
|
||||||
|
key={index}
|
||||||
|
node={node}
|
||||||
|
onToggle={toggleLatchGroup}
|
||||||
|
open={openLatchGroups.has(descriptor.group)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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 (
|
return (
|
||||||
<ArrowHandle
|
<ArrowHandle
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
@@ -670,6 +707,11 @@ function LinearArrow({
|
|||||||
? 1
|
? 1
|
||||||
: -1
|
: -1
|
||||||
|
|
||||||
|
// Last value an emitted resize tick fired at — a new tick fires only
|
||||||
|
// when the (snapped + clamped) value actually changes, so the cue
|
||||||
|
// tracks real size steps instead of every sub-pixel pointer jitter.
|
||||||
|
let lastTickValue = initialValue
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overrideId,
|
overrideId,
|
||||||
onBegin: () => {
|
onBegin: () => {
|
||||||
@@ -701,6 +743,10 @@ function LinearArrow({
|
|||||||
? snapScalar(rawNext, gridSnapStep)
|
? snapScalar(rawNext, gridSnapStep)
|
||||||
: rawNext
|
: rawNext
|
||||||
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
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<AnyNode>
|
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||||
// Let the kind publish live guides for the edge being resized.
|
// Let the kind publish live guides for the edge being resized.
|
||||||
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
||||||
@@ -714,9 +760,18 @@ function LinearArrow({
|
|||||||
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
||||||
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
||||||
// flip the Z rotation so the chevron points outward (downward).
|
// 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] =
|
const innerRotation: [number, number, number] =
|
||||||
descriptor.axis === 'y'
|
descriptor.axis === 'y'
|
||||||
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
||||||
|
: faceNormalX
|
||||||
|
? [Math.PI / 2, 0, 0]
|
||||||
: [0, 0, 0]
|
: [0, 0, 0]
|
||||||
|
|
||||||
// Optional guide decoration — linear handles use it for curved-stair
|
// Optional guide decoration — linear handles use it for curved-stair
|
||||||
@@ -803,6 +858,7 @@ function LinearArrow({
|
|||||||
onPointerDown={activate}
|
onPointerDown={activate}
|
||||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||||
shape="chevron"
|
shape="chevron"
|
||||||
|
thin
|
||||||
>
|
>
|
||||||
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
||||||
</HandleArrow>
|
</HandleArrow>
|
||||||
@@ -1213,6 +1269,7 @@ function ArcArrow({
|
|||||||
baseScale,
|
baseScale,
|
||||||
}}
|
}}
|
||||||
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
||||||
|
thin
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1276,6 +1333,56 @@ function TapActionArrow({
|
|||||||
onPointerDown={onActivate}
|
onPointerDown={onActivate}
|
||||||
placement={{ position, rotation, baseScale }}
|
placement={{ position, rotation, baseScale }}
|
||||||
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
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<AnyNode>
|
||||||
|
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 (
|
||||||
|
<HandleArrow
|
||||||
|
cursor="grab"
|
||||||
|
hover={isHovered || open}
|
||||||
|
hoverScale={1.15}
|
||||||
|
onHoverChange={setIsHovered}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||||
|
shape="tracker"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc'
|
|||||||
// Match the door arrows: scale the rendered chevron down to ~two-thirds
|
// Match the door arrows: scale the rendered chevron down to ~two-thirds
|
||||||
// so the in-world handles read as a single UI family.
|
// so the in-world handles read as a single UI family.
|
||||||
const ARROW_SCALE = 0.65
|
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_DASH_SIZE = 0.1
|
||||||
const CORNER_GAP_SIZE = 0.07
|
const CORNER_GAP_SIZE = 0.07
|
||||||
const CORNER_DASH_THICKNESS = 0.006
|
const CORNER_DASH_THICKNESS = 0.006
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import type { ThreeElements } from '@react-three/fiber'
|
|
||||||
import { useThree } from '@react-three/fiber'
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -34,12 +33,6 @@ import { CursorSphere } from '../shared/cursor-sphere'
|
|||||||
import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
|
import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
|
||||||
import { collectSelectableCandidateIds } from './select-candidates'
|
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 }
|
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
|
||||||
|
|
||||||
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
||||||
|
|||||||
@@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
|||||||
depthWrite?: boolean
|
depthWrite?: boolean
|
||||||
showTooltip?: boolean
|
showTooltip?: boolean
|
||||||
height?: number
|
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 */
|
/** Custom tooltip content — overrides the auto-detected build tool icon */
|
||||||
tooltipContent?: React.ReactNode
|
tooltipContent?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
export const CursorSphere = forwardRef<Group, CursorSphereProps>(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,
|
ref,
|
||||||
) {
|
) {
|
||||||
const tool = useEditor((s) => s.tool)
|
const tool = useEditor((s) => s.tool)
|
||||||
@@ -39,9 +55,12 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={ref} {...props} visible={isVisible}>
|
<group ref={ref} {...props} visible={isVisible}>
|
||||||
{/* 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. */}
|
||||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
{/* Center dot */}
|
{/* Center dot — at the ground unless the placement point is elevated */}
|
||||||
|
{!dotAtTip && (
|
||||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||||
<circleGeometry args={[0.06, 32]} />
|
<circleGeometry args={[0.06, 32]} />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
@@ -52,6 +71,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Outer ring / glow */}
|
{/* Outer ring / glow */}
|
||||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||||
@@ -60,7 +80,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
color={color}
|
color={color}
|
||||||
depthTest={false}
|
depthTest={false}
|
||||||
depthWrite={false}
|
depthWrite={false}
|
||||||
opacity={0.25}
|
opacity={dotAtTip ? 0.2 : 0.25}
|
||||||
transparent
|
transparent
|
||||||
/>
|
/>
|
||||||
</mesh>
|
</mesh>
|
||||||
@@ -80,6 +100,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
|||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<mesh layers={EDITOR_LAYER} position={[0, height, 0]} renderOrder={2}>
|
||||||
|
<sphereGeometry args={[0.08, 20, 14]} />
|
||||||
|
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tool Icon Tooltip at the top of the line */}
|
{/* Tool Icon Tooltip at the top of the line */}
|
||||||
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
|
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
|
||||||
<Html
|
<Html
|
||||||
|
|||||||
@@ -245,10 +245,10 @@ export function EditorCommands() {
|
|||||||
label: 'Wall Mode',
|
label: 'Wall Mode',
|
||||||
group: 'Viewer Controls',
|
group: 'Viewer Controls',
|
||||||
icon: <Layers className="h-4 w-4" />,
|
icon: <Layers className="h-4 w-4" />,
|
||||||
keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
|
keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'],
|
||||||
badge: () => {
|
badge: () => {
|
||||||
const mode = useViewer.getState().wallMode
|
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,
|
navigate: true,
|
||||||
execute: () => navigateTo('wall-mode'),
|
execute: () => navigateTo('wall-mode'),
|
||||||
|
|||||||
@@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {
|
const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = {
|
||||||
cutaway: 'Cutaway',
|
cutaway: 'Cutaway',
|
||||||
up: 'Up',
|
up: 'Up',
|
||||||
down: 'Down',
|
down: 'Down',
|
||||||
|
translucent: 'Translucent',
|
||||||
}
|
}
|
||||||
const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
@@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
|||||||
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
|
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
|
||||||
{page === 'wall-mode' && (
|
{page === 'wall-mode' && (
|
||||||
<Command.Group heading="Wall Mode">
|
<Command.Group heading="Wall Mode">
|
||||||
{(['cutaway', 'up', 'down'] as const).map((mode) => (
|
{(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => (
|
||||||
<OptionItem
|
<OptionItem
|
||||||
isActive={wallMode === mode}
|
isActive={wallMode === mode}
|
||||||
key={mode}
|
key={mode}
|
||||||
|
|||||||
@@ -119,17 +119,23 @@ export function HelperManager() {
|
|||||||
() => getActiveContinuationContext(),
|
() => getActiveContinuationContext(),
|
||||||
[scope, mode, tool],
|
[scope, mode, tool],
|
||||||
)
|
)
|
||||||
const selectModeHints = useMemo(
|
const selectModeHints = useMemo(() => {
|
||||||
() =>
|
const single = selectedNodes.length === 1 ? selectedNodes[0] : null
|
||||||
resolveSelectModeHelpHints({
|
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,
|
selectedCount: selectedNodes.length,
|
||||||
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
||||||
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
||||||
commandPressed: modifiers.command,
|
commandPressed: modifiers.command,
|
||||||
shiftPressed: modifiers.shift,
|
shiftPressed: modifiers.shift,
|
||||||
}),
|
mepSelection,
|
||||||
[modifiers.command, modifiers.shift, selectedNodes],
|
})
|
||||||
)
|
}, [modifiers.command, modifiers.shift, selectedNodes])
|
||||||
|
|
||||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||||
if (isMobile) return null
|
if (isMobile) return null
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Diamond,
|
Diamond,
|
||||||
|
Footprints,
|
||||||
Layers,
|
Layers,
|
||||||
Palette,
|
Palette,
|
||||||
PenLine,
|
PenLine,
|
||||||
@@ -32,8 +33,10 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { flushSync } from 'react-dom'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
|
import useEditor from '../store/use-editor'
|
||||||
import { ActionButton } from './ui/action-menu/action-button'
|
import { ActionButton } from './ui/action-menu/action-button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -51,6 +54,24 @@ type ProjectOwner = {
|
|||||||
image: string | null
|
image: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestWalkthroughPointerLock() {
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>('[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> = {
|
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||||
stacked: 'Stacked',
|
stacked: 'Stacked',
|
||||||
exploded: 'Exploded',
|
exploded: 'Exploded',
|
||||||
@@ -83,6 +104,12 @@ const wallModeConfig = {
|
|||||||
),
|
),
|
||||||
label: 'Low',
|
label: 'Low',
|
||||||
},
|
},
|
||||||
|
translucent: {
|
||||||
|
icon: (props: any) => (
|
||||||
|
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
|
||||||
|
),
|
||||||
|
label: 'Translucent',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHADING_OPTIONS = [
|
const SHADING_OPTIONS = [
|
||||||
@@ -580,7 +607,12 @@ export const ViewerOverlay = ({
|
|||||||
}
|
}
|
||||||
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
||||||
onClick={() => {
|
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
|
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
||||||
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||||
}}
|
}}
|
||||||
@@ -641,6 +673,23 @@ export const ViewerOverlay = ({
|
|||||||
src="/icons/topview.webp"
|
src="/icons/topview.webp"
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|
||||||
|
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||||
|
|
||||||
|
{/* First-person walkthrough */}
|
||||||
|
<ActionButton
|
||||||
|
className="hover:bg-white/5 hover:text-emerald-400"
|
||||||
|
label="Walkthrough"
|
||||||
|
onClick={() => {
|
||||||
|
flushSync(() => useEditor.getState().setFirstPersonMode(true))
|
||||||
|
requestWalkthroughPointerLock()
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
tooltipSide="top"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Footprints className="h-6 w-6" />
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
|
|||||||
// they're referenced throughout the editor's own internals; the public
|
// they're referenced throughout the editor's own internals; the public
|
||||||
// surface uses the shorter, shell-friendly names from the unified
|
// surface uses the shorter, shell-friendly names from the unified
|
||||||
// preset-system spec.
|
// preset-system spec.
|
||||||
|
export { BakeExporter } from './components/editor/bake-exporter'
|
||||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
// 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
|
// authentic selection handles, interactive build tools, and the mover on top
|
||||||
@@ -39,7 +40,27 @@ export {
|
|||||||
formatMeasurement,
|
formatMeasurement,
|
||||||
MeasurementPill,
|
MeasurementPill,
|
||||||
} from './components/editor/measurement-pill'
|
} 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 {
|
export {
|
||||||
type SnapshotCameraData,
|
type SnapshotCameraData,
|
||||||
ThumbnailGenerator,
|
ThumbnailGenerator,
|
||||||
@@ -259,6 +280,7 @@ export {
|
|||||||
getFloorplanWallThickness,
|
getFloorplanWallThickness,
|
||||||
} from './lib/floorplan'
|
} from './lib/floorplan'
|
||||||
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
||||||
|
export { exportSceneToGlb } from './lib/glb-export'
|
||||||
export {
|
export {
|
||||||
boundaryReshapeScope,
|
boundaryReshapeScope,
|
||||||
curveReshapeScope,
|
curveReshapeScope,
|
||||||
|
|||||||
@@ -38,12 +38,19 @@ export type SelectModeHelpContext = {
|
|||||||
hasRotatableSelection: boolean
|
hasRotatableSelection: boolean
|
||||||
commandPressed: boolean
|
commandPressed: boolean
|
||||||
shiftPressed: 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 COMMAND_KEY = 'Cmd/Ctrl'
|
||||||
const LEFT_CLICK = 'Left click'
|
const LEFT_CLICK = 'Left click'
|
||||||
const RIGHT_CLICK = 'Right click'
|
const RIGHT_CLICK = 'Right click'
|
||||||
const SHIFT_KEY = 'Shift'
|
const SHIFT_KEY = 'Shift'
|
||||||
|
const CLICK = 'Click'
|
||||||
|
const ALT_KEY = 'Alt'
|
||||||
|
const ROTATE_KEYS = 'R / T'
|
||||||
|
|
||||||
export function resolveSelectModeHelpHints({
|
export function resolveSelectModeHelpHints({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
@@ -51,6 +58,7 @@ export function resolveSelectModeHelpHints({
|
|||||||
hasRotatableSelection,
|
hasRotatableSelection,
|
||||||
commandPressed,
|
commandPressed,
|
||||||
shiftPressed,
|
shiftPressed,
|
||||||
|
mepSelection = null,
|
||||||
}: SelectModeHelpContext): ContextualShortcutHint[] {
|
}: SelectModeHelpContext): ContextualShortcutHint[] {
|
||||||
const hints: ContextualShortcutHint[] = []
|
const hints: ContextualShortcutHint[] = []
|
||||||
|
|
||||||
@@ -65,6 +73,20 @@ export function resolveSelectModeHelpHints({
|
|||||||
return hints
|
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 (commandPressed) {
|
||||||
if (hasMovableSelection) {
|
if (hasMovableSelection) {
|
||||||
hints.push({
|
hints.push({
|
||||||
|
|||||||
@@ -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<string, unknown> = {}) {
|
||||||
|
// 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<string, AnyNode> = {
|
||||||
|
[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<string, AnyNode> = {
|
||||||
|
[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<string, AnyNode> = {
|
||||||
|
[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<string, AnyNode> = {
|
||||||
|
[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<string, AnyNode> = {
|
||||||
|
[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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, AnyNode>,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
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<typeof prepareSceneForExport>
|
||||||
|
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<ArrayBuffer>((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<string, AnyNode>,
|
||||||
|
): 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<THREE.Object3D>()
|
||||||
|
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<THREE.Object3D, THREE.Object3D> {
|
||||||
|
const originals: THREE.Object3D[] = []
|
||||||
|
const clones: THREE.Object3D[] = []
|
||||||
|
source.traverse((object) => originals.push(object))
|
||||||
|
clone.traverse((object) => clones.push(object))
|
||||||
|
|
||||||
|
const map = new Map<THREE.Object3D, THREE.Object3D>()
|
||||||
|
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<THREE.Object3D>) {
|
||||||
|
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<THREE.Material, THREE.Material>()
|
||||||
|
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, THREE.Material>,
|
||||||
|
): 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<string, unknown>
|
||||||
|
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<string, THREE.Texture>)[slot] = texture
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(material, target)
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Animation clip baking ----------------------------------------------
|
||||||
|
|
||||||
|
function bakeAnimationClips(
|
||||||
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
|
||||||
|
const clips: THREE.AnimationClip[] = []
|
||||||
|
const clipNamesByNode = new Map<string, string[]>()
|
||||||
|
|
||||||
|
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 (`<id>: 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<string, THREE.Object3D>()
|
||||||
|
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<THREE.Object3D, THREE.Object3D>,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
clipNamesByNode: Map<string, string[]>,
|
||||||
|
) {
|
||||||
|
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<string, unknown> = { 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type SFXEvents = {
|
|||||||
'sfx:item-pick': undefined
|
'sfx:item-pick': undefined
|
||||||
'sfx:item-place': undefined
|
'sfx:item-place': undefined
|
||||||
'sfx:item-rotate': undefined
|
'sfx:item-rotate': undefined
|
||||||
|
'sfx:resize': undefined
|
||||||
'sfx:structure-build-start': undefined
|
'sfx:structure-build-start': undefined
|
||||||
'sfx:structure-build': undefined
|
'sfx:structure-build': undefined
|
||||||
'sfx:structure-delete': undefined
|
'sfx:structure-delete': undefined
|
||||||
@@ -40,6 +41,7 @@ export function initSFXBus() {
|
|||||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||||
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||||
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
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-start', () => playSFX('structureBuildStart'))
|
||||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
||||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
|
|||||||
volumeRange: [0.92, 1.0],
|
volumeRange: [0.92, 1.0],
|
||||||
panJitter: 0.15,
|
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).
|
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
||||||
structureBuildStart: {
|
structureBuildStart: {
|
||||||
src: '/audios/sfx/structure_build_start.mp3',
|
src: '/audios/sfx/structure_build_start.mp3',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsc --build --watch",
|
"dev": "tsgo --build --watch",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsc --build --watch",
|
"dev": "tsgo --build --watch",
|
||||||
"start": "bun dist/bin/pascal-mcp.js",
|
"start": "bun dist/bin/pascal-mcp.js",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"smoke": "bun run scripts/smoke.ts",
|
"smoke": "bun run scripts/smoke.ts",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsc --build --watch",
|
"dev": "tsgo --build --watch",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"prepublishOnly": "bun run build && bun test"
|
"prepublishOnly": "bun run build && bun test"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type BoxVentNode,
|
type BoxVentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -21,8 +22,14 @@ import {
|
|||||||
createRelativeRoofDrag,
|
createRelativeRoofDrag,
|
||||||
type RelativeRoofDragTarget,
|
type RelativeRoofDragTarget,
|
||||||
roofSegmentLocalToBuildingLocal,
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
snapRelativeRoofDragTarget,
|
||||||
} from '../shared/relative-roof-drag'
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfaceNodePlacementGuides,
|
||||||
|
snapRoofSurfaceNodeTarget,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import BoxVentPreview from './preview'
|
import BoxVentPreview from './preview'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,10 +79,21 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
lastSnap = null
|
lastSnap = null
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(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 updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = resolveSnappedTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
clearTarget()
|
clearTarget()
|
||||||
return
|
return
|
||||||
@@ -102,12 +120,18 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
target.localZ,
|
target.localZ,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
publishRoofSurfaceNodePlacementGuides({
|
||||||
|
roof: event.node as RoofNode,
|
||||||
|
segment: target.segment,
|
||||||
|
center: [target.localX, target.localY, target.localZ],
|
||||||
|
node,
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
if (committed) return
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
committed = true
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
@@ -152,6 +176,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -172,6 +197,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -191,6 +217,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +250,7 @@ export default function MoveBoxVentTool({ node }: { node: BoxVentNode }) {
|
|||||||
// the original mesh visible rather than stranded invisible.
|
// the original mesh visible rather than stranded invisible.
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node])
|
}, [exitMoveMode, node])
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import * as THREE from 'three'
|
|||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfacePlacementGuides,
|
||||||
|
roofSurfaceFootprintFromNode,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { boxVentDefinition } from './definition'
|
import { boxVentDefinition } from './definition'
|
||||||
import BoxVentPreview from './preview'
|
import BoxVentPreview from './preview'
|
||||||
|
|
||||||
@@ -85,6 +90,15 @@ const BoxVentTool = () => {
|
|||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +123,7 @@ const BoxVentTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [vent.id] })
|
setSelection({ selectedIds: [vent.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +135,9 @@ const BoxVentTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -131,6 +147,7 @@ const BoxVentTool = () => {
|
|||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
|
|||||||
@@ -10,11 +10,25 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} 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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
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'
|
import ChimneyPreview from './preview'
|
||||||
|
|
||||||
const tmpMatrix = new THREE.Matrix4()
|
const tmpMatrix = new THREE.Matrix4()
|
||||||
@@ -67,6 +81,25 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeBuildingId) return
|
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<string, unknown>)
|
||||||
|
: {}
|
||||||
|
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 computeSegmentXform = (segmentId: string): SegmentTransform | null => {
|
||||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||||
@@ -84,25 +117,33 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lastTarget: RelativeRoofDragTarget | null = null
|
let lastTarget: RelativeRoofDragTarget | null = null
|
||||||
|
let committed = false
|
||||||
const roofDrag = createRelativeRoofDrag({
|
const roofDrag = createRelativeRoofDrag({
|
||||||
position: [...node.position] as [number, number, number],
|
position: original.position,
|
||||||
roofSegmentId: node.roofSegmentId,
|
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 = () => {
|
const clearTarget = () => {
|
||||||
lastTarget = null
|
lastTarget = null
|
||||||
setSegmentXform(null)
|
setSegmentXform(null)
|
||||||
setHitLocal(null)
|
setHitLocal(null)
|
||||||
setPreviewSegment(null)
|
setPreviewSegment(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePreview = (event: RoofEvent) => {
|
const updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = resolveSnappedTarget(event)
|
||||||
if (!target) {
|
if (!target) return clearTarget()
|
||||||
clearTarget()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastTarget = target
|
|
||||||
|
|
||||||
const sx = Math.round(target.localX * 20) / 20
|
const sx = Math.round(target.localX * 20) / 20
|
||||||
const sz = Math.round(target.localZ * 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)
|
const xform = computeSegmentXform(target.segment.id)
|
||||||
if (!xform) return
|
if (!xform) return clearTarget()
|
||||||
|
lastTarget = target
|
||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
setHitLocal([target.localX, target.localY, target.localZ])
|
setHitLocal([target.localX, target.localY, target.localZ])
|
||||||
setPreviewSegment(target.segment)
|
setPreviewSegment(target.segment)
|
||||||
|
publishRoofSurfaceNodePlacementGuides({
|
||||||
|
roof: event.node,
|
||||||
|
segment: target.segment,
|
||||||
|
center: [target.localX, target.localY, target.localZ],
|
||||||
|
node,
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (event: RoofEvent) => {
|
const onClick = (event: RoofEvent) => {
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
if (committed) return
|
||||||
|
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
committed = true
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
|
|
||||||
// Strip the `isNew` flag — only used to mark a duplicate clone
|
// Strip the `isNew` flag — only used to mark a duplicate clone
|
||||||
// that hasn't been committed yet.
|
// that hasn't been committed yet.
|
||||||
const meta =
|
|
||||||
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
|
|
||||||
? (node.metadata as Record<string, unknown>)
|
|
||||||
: {}
|
|
||||||
const { isNew, ...restMeta } = meta as { isNew?: boolean }
|
const { isNew, ...restMeta } = meta as { isNew?: boolean }
|
||||||
const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined
|
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
|
// Duplicate (clone with no committed id yet) → create a fresh
|
||||||
// chimney parented to the hit segment. Plain move (existing id,
|
// chimney parented to the hit segment. Plain move (existing id,
|
||||||
@@ -143,29 +190,105 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
...node,
|
...node,
|
||||||
id: undefined as never,
|
id: undefined as never,
|
||||||
roofSegmentId: target.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
|
parentId: target.segment.id,
|
||||||
position: [target.localX, target.localY, target.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
|
visible: true,
|
||||||
metadata: cleanedMeta,
|
metadata: cleanedMeta,
|
||||||
})
|
})
|
||||||
state.createNode(committed, target.segment.id as AnyNodeId)
|
useScene.temporal.getState().resume()
|
||||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
state.applyNodeChanges({
|
||||||
|
delete: node.id ? [node.id as AnyNodeId] : [],
|
||||||
|
create: [{ node: committed, parentId: targetSegmentId }],
|
||||||
|
})
|
||||||
|
state.dirtyNodes.add(targetSegmentId)
|
||||||
setSelection({ selectedIds: [committed.id] })
|
setSelection({ selectedIds: [committed.id] })
|
||||||
|
useScene.temporal.getState().pause()
|
||||||
} else {
|
} 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, {
|
state.updateNode(node.id as AnyNodeId, {
|
||||||
roofSegmentId: target.segment.id,
|
roofSegmentId: target.segment.id,
|
||||||
parentId: target.segment.id,
|
parentId: target.segment.id,
|
||||||
position: [target.localX, target.localY, target.localZ],
|
position: [target.localX, target.localY, target.localZ],
|
||||||
|
rotation: original.rotation,
|
||||||
|
visible: true,
|
||||||
metadata: cleanedMeta,
|
metadata: cleanedMeta,
|
||||||
})
|
})
|
||||||
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
|
useScene.temporal.getState().pause()
|
||||||
state.dirtyNodes.add(target.segment.id as AnyNodeId)
|
state.dirtyNodes.add(targetSegmentId)
|
||||||
|
state.dirtyNodes.add(node.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [node.id] })
|
setSelection({ selectedIds: [node.id] })
|
||||||
}
|
}
|
||||||
|
const obj = node.id && !isNew ? sceneRegistry.nodes.get(node.id) : null
|
||||||
|
if (obj) obj.visible = true
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
setMovingNode(null)
|
setMovingNode(null)
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
event.stopPropagation()
|
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) => {
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
if (!consumePlacementDragRelease(event)) return
|
if (!consumePlacementDragRelease(event)) return
|
||||||
if (!lastTarget) return
|
if (!lastTarget) return
|
||||||
@@ -179,6 +302,7 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
emitter.on('roof:enter', updatePreview)
|
emitter.on('roof:enter', updatePreview)
|
||||||
emitter.on('roof:click', onClick)
|
emitter.on('roof:click', onClick)
|
||||||
emitter.on('roof:leave', clearTarget)
|
emitter.on('roof:leave', clearTarget)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -186,7 +310,15 @@ const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
|
|||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
emitter.off('roof:leave', clearTarget)
|
emitter.off('roof:leave', clearTarget)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
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])
|
}, [activeBuildingId, node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfacePlacementGuides,
|
||||||
|
roofSurfaceFootprintFromNode,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { chimneyDefinition } from './definition'
|
import { chimneyDefinition } from './definition'
|
||||||
import ChimneyPreview from './preview'
|
import ChimneyPreview from './preview'
|
||||||
|
|
||||||
@@ -102,6 +107,12 @@ const ChimneyTool = () => {
|
|||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||||
setPreviewSegment(hit.segment)
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +137,7 @@ const ChimneyTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [chimney.id] })
|
setSelection({ selectedIds: [chimney.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +149,9 @@ const ChimneyTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -149,6 +162,7 @@ const ChimneyTool = () => {
|
|||||||
setSegmentXform(null)
|
setSegmentXform(null)
|
||||||
setHitLocal(null)
|
setHitLocal(null)
|
||||||
setPreviewSegment(null)
|
setPreviewSegment(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
{activeBuildingId && segmentXform && hitLocal && previewSegment && (
|
||||||
|
|||||||
@@ -2404,18 +2404,7 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => {
|
|||||||
textures,
|
textures,
|
||||||
colorPreset,
|
colorPreset,
|
||||||
}),
|
}),
|
||||||
[
|
[shading, textures, colorPreset, node, sceneMaterials],
|
||||||
shading,
|
|
||||||
textures,
|
|
||||||
colorPreset,
|
|
||||||
node.material,
|
|
||||||
node.material?.preset,
|
|
||||||
node.material?.properties,
|
|
||||||
node.material?.texture,
|
|
||||||
node.materialPreset,
|
|
||||||
node.slots,
|
|
||||||
sceneMaterials,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
useRegistry(node.id, node.type, ref)
|
useRegistry(node.id, node.type, ref)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type CupolaNode,
|
type CupolaNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -21,8 +22,14 @@ import {
|
|||||||
createRelativeRoofDrag,
|
createRelativeRoofDrag,
|
||||||
type RelativeRoofDragTarget,
|
type RelativeRoofDragTarget,
|
||||||
roofSegmentLocalToBuildingLocal,
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
snapRelativeRoofDragTarget,
|
||||||
} from '../shared/relative-roof-drag'
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfaceNodePlacementGuides,
|
||||||
|
snapRoofSurfaceNodeTarget,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import CupolaPreview from './preview'
|
import CupolaPreview from './preview'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,10 +77,21 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
lastSnap = null
|
lastSnap = null
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(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 updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = resolveSnappedTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
clearTarget()
|
clearTarget()
|
||||||
return
|
return
|
||||||
@@ -100,12 +118,18 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
target.localZ,
|
target.localZ,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
publishRoofSurfaceNodePlacementGuides({
|
||||||
|
roof: event.node as RoofNode,
|
||||||
|
segment: target.segment,
|
||||||
|
center: [target.localX, target.localY, target.localZ],
|
||||||
|
node,
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
if (committed) return
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
committed = true
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
@@ -146,6 +170,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -164,6 +189,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,6 +209,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +239,7 @@ export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
|||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node])
|
}, [exitMoveMode, node])
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import * as THREE from 'three'
|
|||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfacePlacementGuides,
|
||||||
|
roofSurfaceFootprintFromNode,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { cupolaDefinition } from './definition'
|
import { cupolaDefinition } from './definition'
|
||||||
import CupolaPreview from './preview'
|
import CupolaPreview from './preview'
|
||||||
|
|
||||||
@@ -77,6 +82,12 @@ const CupolaTool = () => {
|
|||||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +112,7 @@ const CupolaTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [cupola.id] })
|
setSelection({ selectedIds: [cupola.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +124,9 @@ const CupolaTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -123,6 +136,7 @@ const CupolaTool = () => {
|
|||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const DoorPreview = ({
|
|||||||
const m = buildDoorPreviewMesh(node)
|
const m = buildDoorPreviewMesh(node)
|
||||||
m.layers.set(EDITOR_LAYER)
|
m.layers.set(EDITOR_LAYER)
|
||||||
return m
|
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;
|
// Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips;
|
||||||
// its cleanup only disposes the clones it made.
|
// its cleanup only disposes the clones it made.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe('DormerNode schema', () => {
|
|||||||
expect(parsed.height).toBe(0)
|
expect(parsed.height).toBe(0)
|
||||||
expect(parsed.roofType).toBe('gable')
|
expect(parsed.roofType).toBe('gable')
|
||||||
expect(parsed.windowShape).toBe('rectangle')
|
expect(parsed.windowShape).toBe('rectangle')
|
||||||
expect(parsed.windowSill).toBe(true)
|
expect(parsed.windowSill).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('windowColumns / windowRows clamped to [1, 8]', () => {
|
test('windowColumns / windowRows clamped to [1, 8]', () => {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const MAX_SKIRT = 6
|
|||||||
const WINDOW_SIDE_HANDLE_OFFSET = 0.15
|
const WINDOW_SIDE_HANDLE_OFFSET = 0.15
|
||||||
const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15
|
const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15
|
||||||
const WINDOW_FACE_Z_OFFSET = 0.05
|
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
|
// Lower clamp for window dims matches the geometry's internal clamp
|
||||||
// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the
|
// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the
|
||||||
// dormer dimensions and are resolved per-handle via the function form
|
// dormer dimensions and are resolved per-handle via the function form
|
||||||
@@ -109,21 +112,43 @@ function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor<DormerNodeT
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth arrow on the +Z side. Symmetric (anchor 'center') to match
|
// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric resize:
|
||||||
// chimney's known-working handle count — splitting depth into asymmetric
|
// dragging one arrow grows the dormer outward from its own edge while
|
||||||
// front + back chevrons puts the dormer over the per-node MRT/TSL
|
// the opposite edge stays world-fixed in segment frame — same pattern
|
||||||
// budget that chimney already documents (see `chimneyHandles` factory).
|
// as `dormerWidthHandle`, just on the Z axis. `apply` recomputes
|
||||||
// Re-evaluate the split once that pipeline issue is pinned down.
|
// `position` so the anchored edge stays at the same segment-local point
|
||||||
function dormerDepthHandle(): HandleDescriptor<DormerNodeType> {
|
// 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<DormerNodeType> {
|
||||||
|
const sign = side === 'front' ? 1 : -1
|
||||||
return {
|
return {
|
||||||
kind: 'linear-resize',
|
kind: 'linear-resize',
|
||||||
axis: 'z',
|
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,
|
min: MIN_DIM,
|
||||||
currentValue: (n) => n.depth,
|
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: {
|
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<Dorme
|
|||||||
return {
|
return {
|
||||||
kind: 'linear-resize',
|
kind: 'linear-resize',
|
||||||
axis: 'x',
|
axis: 'x',
|
||||||
|
// Stand the blade up into the gable face so it reads flat-on like the
|
||||||
|
// top/bottom window-height arrows instead of edge-on.
|
||||||
|
faceNormal: true,
|
||||||
|
// Hidden until the user clicks the window-center latch cube.
|
||||||
|
latchGroup: WINDOW_LATCH_GROUP,
|
||||||
anchor: side === 'right' ? 'min' : 'max',
|
anchor: side === 'right' ? 'min' : 'max',
|
||||||
min: MIN_WINDOW_DIM,
|
min: MIN_WINDOW_DIM,
|
||||||
// Cap at the dormer's window field — keep a 0.1m gap on each side
|
// Cap at the dormer's window field — keep a 0.1m gap on each side
|
||||||
@@ -317,6 +347,8 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
|
|||||||
return {
|
return {
|
||||||
kind: 'linear-resize',
|
kind: 'linear-resize',
|
||||||
axis: 'y',
|
axis: 'y',
|
||||||
|
// Hidden until the user clicks the window-center latch cube.
|
||||||
|
latchGroup: WINDOW_LATCH_GROUP,
|
||||||
// 'min' = bottom edge anchored (top arrow grows the top edge up).
|
// 'min' = bottom edge anchored (top arrow grows the top edge up).
|
||||||
// 'max' = top edge anchored (bottom arrow drops the bottom edge).
|
// 'max' = top edge anchored (bottom arrow drops the bottom edge).
|
||||||
anchor: side === 'top' ? 'min' : 'max',
|
anchor: side === 'top' ? 'min' : 'max',
|
||||||
@@ -350,12 +382,37 @@ function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<Dorm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window-center latch cube. Sits at the window center on the exposed
|
||||||
|
// gable face; clicking it reveals / hides the four window edge arrows
|
||||||
|
// (width L/R + height top/bottom) tagged with `WINDOW_LATCH_GROUP`.
|
||||||
|
// Mirrors the duct-fitting selection cube but driven by the shared
|
||||||
|
// latch descriptor so the dense window cluster stays collapsed behind
|
||||||
|
// one grip until the user opts in.
|
||||||
|
function dormerWindowLatchHandle(): HandleDescriptor<DormerNodeType> {
|
||||||
|
return {
|
||||||
|
kind: 'latch',
|
||||||
|
group: WINDOW_LATCH_GROUP,
|
||||||
|
placement: {
|
||||||
|
position: (n, sceneApi) => {
|
||||||
|
const faceSign = getExposedFaceZSign(n, sceneApi)
|
||||||
|
return [
|
||||||
|
n.windowOffsetX,
|
||||||
|
getWindowCenterY(n),
|
||||||
|
faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const dormerHandles: HandleDescriptor<DormerNodeType>[] = [
|
const dormerHandles: HandleDescriptor<DormerNodeType>[] = [
|
||||||
dormerWidthHandle('right'),
|
dormerWidthHandle('right'),
|
||||||
dormerWidthHandle('left'),
|
dormerWidthHandle('left'),
|
||||||
dormerDepthHandle(),
|
dormerDepthHandle('front'),
|
||||||
|
dormerDepthHandle('back'),
|
||||||
dormerWallHeightHandle(),
|
dormerWallHeightHandle(),
|
||||||
dormerRotateHandle(),
|
dormerRotateHandle(),
|
||||||
|
dormerWindowLatchHandle(),
|
||||||
dormerWindowWidthHandle('right'),
|
dormerWindowWidthHandle('right'),
|
||||||
dormerWindowWidthHandle('left'),
|
dormerWindowWidthHandle('left'),
|
||||||
dormerWindowHeightHandle('top'),
|
dormerWindowHeightHandle('top'),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useEditor } from '@pascal-app/editor'
|
import { useEditor } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { DormerPlacementGuides } from './placement-guides'
|
||||||
import DormerPreview from './preview'
|
import DormerPreview from './preview'
|
||||||
import { useDormerPlacement } from './use-dormer-placement'
|
import { useDormerPlacement } from './use-dormer-placement'
|
||||||
|
|
||||||
@@ -74,7 +75,8 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
|||||||
}
|
}
|
||||||
}, [node.id, isNew])
|
}, [node.id, isNew])
|
||||||
|
|
||||||
const { activeBuildingId, segmentXform, hitLocal, ghostRotation } = useDormerPlacement({
|
const { activeBuildingId, segmentXform, hitSegment, hitLocal, ghostRotation } =
|
||||||
|
useDormerPlacement({
|
||||||
initialRotation: originalRotation,
|
initialRotation: originalRotation,
|
||||||
relativeStart: {
|
relativeStart: {
|
||||||
position: [...node.position] as [number, number, number],
|
position: [...node.position] as [number, number, number],
|
||||||
@@ -152,6 +154,16 @@ const MoveDormerTool = ({ node }: { node: DormerNode }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
{hitSegment && (
|
||||||
|
<DormerPlacementGuides
|
||||||
|
center={hitLocal}
|
||||||
|
depth={previewNode.depth}
|
||||||
|
movingId={node.id}
|
||||||
|
rotation={ghostRotation}
|
||||||
|
segment={hitSegment}
|
||||||
|
width={previewNode.width}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<group position={hitLocal}>
|
<group position={hitLocal}>
|
||||||
<group rotation-y={ghostRotation}>
|
<group rotation-y={ghostRotation}>
|
||||||
<DormerPreview node={previewNode} />
|
<DormerPreview node={previewNode} />
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { RoofSegmentNode } from '@pascal-app/core'
|
||||||
|
import { EDITOR_LAYER, formatMeasurement } from '@pascal-app/editor'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
|
||||||
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { getRoofSurfaceFaceBoundsAt } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
roofFaceKey,
|
||||||
|
roofGuideBounds,
|
||||||
|
roofSiblingSpacing,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
|
|
||||||
|
// Indigo — matches the wall/window 3D proximity guide accent so every
|
||||||
|
// "distance to edge" readout reads the same across the app.
|
||||||
|
const GUIDE_COLOR = 0x81_8c_f8
|
||||||
|
const ALIGN_COLOR = 0xef_44_44
|
||||||
|
const PILL_BG = '#6366f1'
|
||||||
|
const BADGE_BG = '#ec4899'
|
||||||
|
// Lift the lines a hair off the sloped surface so they don't z-fight the
|
||||||
|
// roof + dormer ghost.
|
||||||
|
const SURFACE_LIFT = 0.02
|
||||||
|
// Hide a gap that has collapsed (dormer edge flush to / past the roof edge)
|
||||||
|
// so we don't draw a degenerate "0m" pill.
|
||||||
|
const MIN_GAP_M = 0.02
|
||||||
|
|
||||||
|
const guideMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: GUIDE_COLOR,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
transparent: true,
|
||||||
|
})
|
||||||
|
const alignMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: ALIGN_COLOR,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
toneMapped: false,
|
||||||
|
transparent: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
type Vec3 = [number, number, number]
|
||||||
|
type DormerGuide =
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
from: Vec3
|
||||||
|
to: Vec3
|
||||||
|
kind: 'align-line' | 'dimension'
|
||||||
|
value?: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string
|
||||||
|
at: Vec3
|
||||||
|
kind: 'badge'
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "distance to roof edge" guides shown while a dormer ghost is being
|
||||||
|
* placed or dragged — the roof-plane analog of the window's sill/head +
|
||||||
|
* edge-proximity pills. Renders measured lines from each side-center of
|
||||||
|
* the dormer's occupied roof area out to the active roof face edges, each
|
||||||
|
* with a distance pill at its midpoint.
|
||||||
|
*
|
||||||
|
* Mounted as a sibling of `<DormerPreview>` INSIDE the segment-local frame
|
||||||
|
* (the `segmentXform` group) but OUTSIDE the dormer's `hitLocal` + rotation
|
||||||
|
* groups, so its coordinates are segment-local. The roof-face boundary is
|
||||||
|
* resolved from the actual visible top face under `center`, not from the
|
||||||
|
* wall footprint dimensions.
|
||||||
|
*
|
||||||
|
* Normal roof accessories use side-center readouts. Linear accessories
|
||||||
|
* like ridge vents and gutters use their own two-end guide mode.
|
||||||
|
*/
|
||||||
|
export function DormerPlacementGuides({
|
||||||
|
segment,
|
||||||
|
center,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
|
rotation,
|
||||||
|
movingId,
|
||||||
|
}: {
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
center: Vec3
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
rotation: number
|
||||||
|
movingId?: string
|
||||||
|
}) {
|
||||||
|
const unit = useViewer((s) => s.unit)
|
||||||
|
|
||||||
|
const [cx, , cz] = center
|
||||||
|
const faceBounds = getRoofSurfaceFaceBoundsAt(segment, cx, cz)
|
||||||
|
const halfW = Math.max(0, width) / 2
|
||||||
|
const halfD = Math.max(0, depth) / 2
|
||||||
|
const cos = Math.cos(rotation)
|
||||||
|
const sin = Math.sin(rotation)
|
||||||
|
const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD
|
||||||
|
const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD
|
||||||
|
const movingBounds = roofGuideBounds(center, { width, depth, rotation })
|
||||||
|
|
||||||
|
const surfaceY = (x: number, z: number): number => faceBounds.surfaceYAt(x, z) + SURFACE_LIFT
|
||||||
|
|
||||||
|
const xInterval = faceBounds.xIntervalAtZ(cz)
|
||||||
|
const zInterval = faceBounds.zIntervalAtX(cx)
|
||||||
|
|
||||||
|
const guides: DormerGuide[] = []
|
||||||
|
const push = (id: string, ax: number, az: number, bx: number, bz: number) => {
|
||||||
|
const from: Vec3 = [ax, surfaceY(ax, az), az]
|
||||||
|
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
|
||||||
|
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
|
||||||
|
if (value < MIN_GAP_M) return
|
||||||
|
guides.push({
|
||||||
|
id,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
kind: 'dimension',
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const siblingSpacing = roofSiblingSpacing<DormerGuide>({
|
||||||
|
segment,
|
||||||
|
movingId,
|
||||||
|
movingBounds,
|
||||||
|
faceKey: roofFaceKey(faceBounds.polygon),
|
||||||
|
dimension: (id, [ax, az], [bx, bz]) => {
|
||||||
|
const from: Vec3 = [ax, surfaceY(ax, az), az]
|
||||||
|
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
|
||||||
|
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
|
||||||
|
if (value < MIN_GAP_M) return null
|
||||||
|
return { id, from, to, kind: 'dimension', value }
|
||||||
|
},
|
||||||
|
alignLine: (id, [ax, az], [bx, bz]) => {
|
||||||
|
const from: Vec3 = [ax, surfaceY(ax, az), az]
|
||||||
|
const to: Vec3 = [bx, surfaceY(bx, bz), bz]
|
||||||
|
const value = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2])
|
||||||
|
if (value < MIN_GAP_M) return null
|
||||||
|
return { id, from, to, kind: 'align-line' }
|
||||||
|
},
|
||||||
|
badge: (id, [x, z], value) => {
|
||||||
|
if (value < MIN_GAP_M) return null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
at: [x, surfaceY(x, z), z],
|
||||||
|
kind: 'badge',
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
measure: ([ax, az], [bx, bz]) => {
|
||||||
|
const ay = surfaceY(ax, az)
|
||||||
|
const by = surfaceY(bx, bz)
|
||||||
|
return Math.hypot(bx - ax, by - ay, bz - az)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (xInterval) {
|
||||||
|
const [faceMinX, faceMaxX] = xInterval
|
||||||
|
const itemMinX = Math.max(faceMinX, Math.min(faceMaxX, cx - halfX))
|
||||||
|
const itemMaxX = Math.max(faceMinX, Math.min(faceMaxX, cx + halfX))
|
||||||
|
if (!siblingSpacing.blockedSides.left && itemMinX > faceMinX + MIN_GAP_M) {
|
||||||
|
push('left', faceMinX, cz, itemMinX, cz)
|
||||||
|
}
|
||||||
|
if (!siblingSpacing.blockedSides.right && itemMaxX < faceMaxX - MIN_GAP_M) {
|
||||||
|
push('right', itemMaxX, cz, faceMaxX, cz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (zInterval) {
|
||||||
|
const [faceMinZ, faceMaxZ] = zInterval
|
||||||
|
const itemMinZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz - halfZ))
|
||||||
|
const itemMaxZ = Math.max(faceMinZ, Math.min(faceMaxZ, cz + halfZ))
|
||||||
|
if (!siblingSpacing.blockedSides.bottom && itemMinZ > faceMinZ + MIN_GAP_M) {
|
||||||
|
push('back', cx, faceMinZ, cx, itemMinZ)
|
||||||
|
}
|
||||||
|
if (!siblingSpacing.blockedSides.top && itemMaxZ < faceMaxZ - MIN_GAP_M) {
|
||||||
|
push('front', cx, itemMaxZ, cx, faceMaxZ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guides.push(...siblingSpacing.guides)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{guides.map((g) => (
|
||||||
|
<Guide key={g.id} guide={g} unit={unit} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Guide({ guide, unit }: { guide: DormerGuide; unit: 'metric' | 'imperial' }) {
|
||||||
|
if (guide.kind === 'badge') {
|
||||||
|
return <GuideBadge at={guide.at} pill={`= ${formatMeasurement(guide.value, unit)}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GuideLine
|
||||||
|
from={guide.from}
|
||||||
|
kind={guide.kind}
|
||||||
|
pill={guide.value === undefined ? undefined : formatMeasurement(guide.value, unit)}
|
||||||
|
to={guide.to}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideBadge({ at, pill }: { at: Vec3; pill: string }) {
|
||||||
|
return (
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={at}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[20, 0]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-semibold font-sans text-[11px] text-white"
|
||||||
|
style={{ backgroundColor: BADGE_BG }}
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuideLine({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
pill,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
from: Vec3
|
||||||
|
to: Vec3
|
||||||
|
pill?: string
|
||||||
|
kind: DormerGuide['kind']
|
||||||
|
}) {
|
||||||
|
const { line, position } = useMemo(() => {
|
||||||
|
const position = new Float32BufferAttribute(new Float32Array(6), 3)
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', position)
|
||||||
|
const line = new ThreeLine(geometry, kind === 'align-line' ? alignMaterial : guideMaterial)
|
||||||
|
line.frustumCulled = false
|
||||||
|
line.layers.set(EDITOR_LAYER)
|
||||||
|
line.renderOrder = 1000
|
||||||
|
return { line, position }
|
||||||
|
}, [kind])
|
||||||
|
|
||||||
|
position.setXYZ(0, from[0], from[1], from[2])
|
||||||
|
position.setXYZ(1, to[0], to[1], to[2])
|
||||||
|
position.needsUpdate = true
|
||||||
|
|
||||||
|
useEffect(() => () => line.geometry.dispose(), [line])
|
||||||
|
|
||||||
|
const mid: Vec3 = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2, (from[2] + to[2]) / 2]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<primitive object={line} />
|
||||||
|
{pill ? (
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
position={mid}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[20, 0]}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
|
||||||
|
style={{ backgroundColor: PILL_BG }}
|
||||||
|
>
|
||||||
|
{pill}
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { dormerDefinition } from './definition'
|
import { dormerDefinition } from './definition'
|
||||||
|
import { DormerPlacementGuides } from './placement-guides'
|
||||||
import DormerPreview from './preview'
|
import DormerPreview from './preview'
|
||||||
import { useDormerPlacement } from './use-dormer-placement'
|
import { useDormerPlacement } from './use-dormer-placement'
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ const DormerTool = () => {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { activeBuildingId, clearPreview, segmentXform, hitLocal, ghostRotation } =
|
const { activeBuildingId, clearPreview, segmentXform, hitSegment, hitLocal, ghostRotation } =
|
||||||
useDormerPlacement({
|
useDormerPlacement({
|
||||||
onCommit: (hit, rotation) => {
|
onCommit: (hit, rotation) => {
|
||||||
const state = useScene.getState()
|
const state = useScene.getState()
|
||||||
@@ -78,6 +79,15 @@ const DormerTool = () => {
|
|||||||
/>
|
/>
|
||||||
{activeBuildingId && segmentXform && hitLocal && (
|
{activeBuildingId && segmentXform && hitLocal && (
|
||||||
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
|
||||||
|
{hitSegment && (
|
||||||
|
<DormerPlacementGuides
|
||||||
|
center={hitLocal}
|
||||||
|
depth={previewNode.depth}
|
||||||
|
rotation={ghostRotation}
|
||||||
|
segment={hitSegment}
|
||||||
|
width={previewNode.width}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<group position={hitLocal}>
|
<group position={hitLocal}>
|
||||||
<group rotation-y={ghostRotation}>
|
<group rotation-y={ghostRotation}>
|
||||||
<DormerPreview node={previewNode} />
|
<DormerPreview node={previewNode} />
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ export function useDormerPlacement(opts: {
|
|||||||
activeBuildingId: string | undefined
|
activeBuildingId: string | undefined
|
||||||
clearPreview: () => void
|
clearPreview: () => void
|
||||||
segmentXform: DormerSegmentTransform | null
|
segmentXform: DormerSegmentTransform | null
|
||||||
|
hitSegment: RoofSegmentNode | null
|
||||||
hitLocal: [number, number, number] | null
|
hitLocal: [number, number, number] | null
|
||||||
ghostRotation: number
|
ghostRotation: number
|
||||||
} {
|
} {
|
||||||
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||||
|
|
||||||
const [segmentXform, setSegmentXform] = useState<DormerSegmentTransform | null>(null)
|
const [segmentXform, setSegmentXform] = useState<DormerSegmentTransform | null>(null)
|
||||||
|
const [hitSegment, setHitSegment] = useState<RoofSegmentNode | null>(null)
|
||||||
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
|
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
|
||||||
const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0)
|
const [ghostRotation, setGhostRotation] = useState(opts.initialRotation ?? 0)
|
||||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||||
@@ -81,6 +83,7 @@ export function useDormerPlacement(opts: {
|
|||||||
|
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
setSegmentXform(null)
|
setSegmentXform(null)
|
||||||
|
setHitSegment(null)
|
||||||
setHitLocal(null)
|
setHitLocal(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +139,7 @@ export function useDormerPlacement(opts: {
|
|||||||
const xform = computeSegmentXform(hit.segment.id)
|
const xform = computeSegmentXform(hit.segment.id)
|
||||||
if (!xform) return
|
if (!xform) return
|
||||||
setSegmentXform(xform)
|
setSegmentXform(xform)
|
||||||
|
setHitSegment(hit.segment)
|
||||||
// Lift the ghost to the actual roof-surface Y at the cursor so
|
// Lift the ghost to the actual roof-surface Y at the cursor so
|
||||||
// it tracks the mouse along the slope. The CSG inside
|
// it tracks the mouse along the slope. The CSG inside
|
||||||
// `generateDormerGeometry` carves the dormer against the host
|
// `generateDormerGeometry` carves the dormer against the host
|
||||||
@@ -200,6 +204,7 @@ export function useDormerPlacement(opts: {
|
|||||||
activeBuildingId: activeBuildingId ?? undefined,
|
activeBuildingId: activeBuildingId ?? undefined,
|
||||||
clearPreview,
|
clearPreview,
|
||||||
segmentXform,
|
segmentXform,
|
||||||
|
hitSegment,
|
||||||
hitLocal,
|
hitLocal,
|
||||||
ghostRotation,
|
ghostRotation,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NodeDefinition } from '@pascal-app/core'
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
|
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||||
import { rotateFittingNode } from '../shared/fitting-rotation'
|
import { rotateFittingNode } from '../shared/fitting-rotation'
|
||||||
import { buildDuctFittingFloorplan } from './floorplan'
|
import { buildDuctFittingFloorplan } from './floorplan'
|
||||||
import { buildDuctFittingGeometry } from './geometry'
|
import { buildDuctFittingGeometry } from './geometry'
|
||||||
@@ -30,16 +31,16 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
|||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
rotation: [0, 0, 0],
|
rotation: [0, 0, 0],
|
||||||
fittingType: 'elbow',
|
fittingType: 'elbow',
|
||||||
shape: 'round',
|
shape: 'rect',
|
||||||
width: 14,
|
width: 14,
|
||||||
height: 8,
|
height: 8,
|
||||||
shape2: 'round',
|
shape2: 'rect',
|
||||||
width2: 14,
|
width2: 14,
|
||||||
height2: 8,
|
height2: 8,
|
||||||
angle: 90,
|
angle: 90,
|
||||||
branchAngle: 90,
|
branchAngle: 90,
|
||||||
diameter: 6,
|
diameter: 12,
|
||||||
diameter2: 6,
|
diameter2: 12,
|
||||||
ductMaterial: 'sheet-metal',
|
ductMaterial: 'sheet-metal',
|
||||||
system: 'supply',
|
system: 'supply',
|
||||||
}),
|
}),
|
||||||
@@ -52,6 +53,8 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
|||||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
|
slots: () => ductBodySlots(),
|
||||||
|
paint: ductBodyPaint,
|
||||||
},
|
},
|
||||||
|
|
||||||
parametrics: ductFittingParametrics,
|
parametrics: ductFittingParametrics,
|
||||||
@@ -76,6 +79,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
|||||||
n.diameter2,
|
n.diameter2,
|
||||||
n.ductMaterial,
|
n.ductMaterial,
|
||||||
n.system,
|
n.system,
|
||||||
|
n.slots,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
ports: getDuctFittingPorts,
|
ports: getDuctFittingPorts,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GeometryContext } from '@pascal-app/core'
|
||||||
|
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
BufferGeometry,
|
BufferGeometry,
|
||||||
CylinderGeometry,
|
CylinderGeometry,
|
||||||
@@ -5,8 +7,8 @@ import {
|
|||||||
Euler,
|
Euler,
|
||||||
Float32BufferAttribute,
|
Float32BufferAttribute,
|
||||||
Group,
|
Group,
|
||||||
|
type Material,
|
||||||
Mesh,
|
Mesh,
|
||||||
type MeshStandardMaterial,
|
|
||||||
SphereGeometry,
|
SphereGeometry,
|
||||||
TorusGeometry,
|
TorusGeometry,
|
||||||
Vector3,
|
Vector3,
|
||||||
@@ -18,6 +20,7 @@ import {
|
|||||||
createDuctMaterial,
|
createDuctMaterial,
|
||||||
INCHES_TO_METERS,
|
INCHES_TO_METERS,
|
||||||
} from '../duct-segment/geometry'
|
} from '../duct-segment/geometry'
|
||||||
|
import { DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||||
import { localFittingPorts } from './ports'
|
import { localFittingPorts } from './ports'
|
||||||
import type { DuctFittingNode } from './schema'
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ function buildMiteredElbow(
|
|||||||
sweepM: number,
|
sweepM: number,
|
||||||
cheekM: number,
|
cheekM: number,
|
||||||
profileShape: 'rect' | 'oval',
|
profileShape: 'rect' | 'oval',
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
): Mesh {
|
): Mesh {
|
||||||
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
|
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
|
||||||
const travelOut = outletPos.clone().normalize() // junction → outlet
|
const travelOut = outletPos.clone().normalize() // junction → outlet
|
||||||
@@ -155,7 +158,7 @@ function buildRectToRoundLoft(
|
|||||||
widthM: number,
|
widthM: number,
|
||||||
heightM: number,
|
heightM: number,
|
||||||
radius: number,
|
radius: number,
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
): Mesh {
|
): Mesh {
|
||||||
const hw = widthM / 2
|
const hw = widthM / 2
|
||||||
const hh = heightM / 2
|
const hh = heightM / 2
|
||||||
@@ -212,9 +215,23 @@ function buildRectToRoundLoft(
|
|||||||
* height rides local +Y — for the horizontal-plane orientations trunks
|
* height rides local +Y — for the horizontal-plane orientations trunks
|
||||||
* are drawn in, that's world-vertical.
|
* are drawn in, that's world-vertical.
|
||||||
*/
|
*/
|
||||||
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
export function buildDuctFittingGeometry(
|
||||||
|
node: DuctFittingNode,
|
||||||
|
ctx?: GeometryContext,
|
||||||
|
shading: RenderShading = 'rendered',
|
||||||
|
textures = true,
|
||||||
|
colorPreset: ColorPreset = 'clay',
|
||||||
|
sceneTheme?: string,
|
||||||
|
): Group {
|
||||||
const group = new Group()
|
const group = new Group()
|
||||||
const material = createDuctMaterial(node)
|
const material = createDuctMaterial(
|
||||||
|
node,
|
||||||
|
ctx?.materials,
|
||||||
|
shading,
|
||||||
|
textures,
|
||||||
|
colorPreset,
|
||||||
|
sceneTheme,
|
||||||
|
)
|
||||||
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
|
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
|
||||||
const ports = localFittingPorts(node)
|
const ports = localFittingPorts(node)
|
||||||
const widthM = node.width * INCHES_TO_METERS
|
const widthM = node.width * INCHES_TO_METERS
|
||||||
@@ -459,5 +476,10 @@ export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
|||||||
group.add(collar)
|
group.add(collar)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group.traverse((object) => {
|
||||||
|
const mesh = object as Mesh
|
||||||
|
if (mesh.isMesh) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||||
|
})
|
||||||
|
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ActionButton } from '@pascal-app/editor'
|
||||||
|
import { ArrowLeftRight } from 'lucide-react'
|
||||||
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
|
const WIDTH_MIN = 4
|
||||||
|
const WIDTH_MAX = 60
|
||||||
|
const HEIGHT_MIN = 3
|
||||||
|
const HEIGHT_MAX = 40
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuctFittingSizeSwapEditor({
|
||||||
|
node,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
node: DuctFittingNode
|
||||||
|
onUpdate: (patch: Partial<DuctFittingNode>) => void
|
||||||
|
}) {
|
||||||
|
const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX)
|
||||||
|
const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-2">
|
||||||
|
<ActionButton
|
||||||
|
className="h-8 w-full flex-none"
|
||||||
|
icon={<ArrowLeftRight className="h-3.5 w-3.5" />}
|
||||||
|
label="Swap W/H"
|
||||||
|
onClick={() => onUpdate({ width: nextWidth, height: nextHeight })}
|
||||||
|
title="Swap width and height"
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
@@ -24,11 +25,13 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
|
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
|
||||||
|
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
||||||
import {
|
import {
|
||||||
type Aabb2D,
|
type Aabb2D,
|
||||||
collectGhostAlignmentCandidates,
|
collectGhostAlignmentCandidates,
|
||||||
resolveGhostAlignment,
|
resolveGhostAlignment,
|
||||||
} from '../shared/ghost-alignment'
|
} from '../shared/ghost-alignment'
|
||||||
|
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||||
import { buildDuctFittingGeometry } from './geometry'
|
import { buildDuctFittingGeometry } from './geometry'
|
||||||
|
|
||||||
type Vec3 = [number, number, number]
|
type Vec3 = [number, number, number]
|
||||||
@@ -176,9 +179,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
if (existedAtStart) setMeshHidden(true)
|
if (existedAtStart) setMeshHidden(true)
|
||||||
|
|
||||||
|
// Carry connected ducts as the fitting slides: the part of the move along
|
||||||
|
// a run's axis stretches it, the part across translates the whole run (and
|
||||||
|
// propagates to its far joint). Snapshot once at drag start; only existing
|
||||||
|
// fittings are mated to anything.
|
||||||
|
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||||
|
? startRunMoveConnectivity(node)
|
||||||
|
: null
|
||||||
|
|
||||||
let lastPos: Vec3 = originalPosition
|
let lastPos: Vec3 = originalPosition
|
||||||
|
// Tracks whether the last frame held Alt: the fitting is detached from its
|
||||||
|
// connected ducts for the drag, so they stay put (no follow) and the
|
||||||
|
// commit omits their updates. Mirrors the duct endpoint's Alt-detach.
|
||||||
|
let lastDetached = false
|
||||||
|
|
||||||
const onMove = (event: GridEvent) => {
|
const onMove = (event: GridEvent) => {
|
||||||
|
// Alt = detach: drop the connected-duct follow so the fitting moves on
|
||||||
|
// its own, leaving every mated run where it sits.
|
||||||
|
const detached = event.nativeEvent?.altKey === true
|
||||||
const snap = isGridSnapActive() ? snapToGridStep : (v: number) => v
|
const snap = isGridSnapActive() ? snapToGridStep : (v: number) => v
|
||||||
let x = snap(event.localPosition[0])
|
let x = snap(event.localPosition[0])
|
||||||
let z = snap(event.localPosition[2])
|
let z = snap(event.localPosition[2])
|
||||||
@@ -200,21 +218,29 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
} else {
|
} else {
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
}
|
}
|
||||||
|
const next: Vec3 = [x, lastPos[1], z]
|
||||||
|
|
||||||
const next: Vec3 = [x, originalPosition[1], z]
|
|
||||||
if (
|
if (
|
||||||
(isGridSnapActive() || isMagneticSnapActive()) &&
|
(isGridSnapActive() || isMagneticSnapActive()) &&
|
||||||
(next[0] !== lastPos[0] || next[2] !== lastPos[2])
|
(next[0] !== lastPos[0] || next[2] !== lastPos[2])
|
||||||
)
|
)
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
lastPos = next
|
lastPos = next
|
||||||
|
lastDetached = detached
|
||||||
hasMoved = true
|
hasMoved = true
|
||||||
setCursorPos(next)
|
setCursorPos(next)
|
||||||
|
// Detached: keep the followers at their origin (drop any live overrides
|
||||||
|
// from a prior non-detached frame). Otherwise preview the follow.
|
||||||
|
if (detached) connectivity?.clear()
|
||||||
|
else connectivity?.preview({ position: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
const commit = (event: GridEvent) => {
|
const commit = (event: GridEvent, fromDragRelease = false) => {
|
||||||
if (committed) return
|
if (committed) return
|
||||||
if (Date.now() - activatedAt < 150) {
|
// The 150ms debounce only guards click-to-place against the arming click
|
||||||
|
// double-firing; a press-drag release is a distinct pointerup gesture, so
|
||||||
|
// it skips the guard (a quick drag-flick still commits).
|
||||||
|
if (!fromDragRelease && Date.now() - activatedAt < 150) {
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -236,10 +262,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||||
selectId = created.id as AnyNodeId
|
selectId = created.id as AnyNodeId
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(nodeId, { position: lastPos } as Partial<AnyNode>)
|
// Fold connected-duct / sibling-run follow-updates into the SAME batch
|
||||||
useScene.getState().markDirty(nodeId)
|
// as the moved fitting so the whole joint is one undo step. Detached
|
||||||
|
// (Alt on the final frame): the joint is broken, so nothing follows.
|
||||||
|
const followUpdates = lastDetached
|
||||||
|
? []
|
||||||
|
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
|
||||||
|
const scene = useScene.getState()
|
||||||
|
scene.updateNodes([
|
||||||
|
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
|
||||||
|
...followUpdates,
|
||||||
|
...autoOffsetInvalidationUpdates(scene.nodes, nodeId),
|
||||||
|
])
|
||||||
|
scene.markDirty(nodeId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
// Followers are committed to the store — drop their live overrides so
|
||||||
|
// renderers read the canonical path/position.
|
||||||
|
connectivity?.clear()
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -251,6 +291,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
connectivity?.clear()
|
||||||
if (existedAtStart) {
|
if (existedAtStart) {
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
@@ -262,14 +303,39 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Press-drag-release: when the move was engaged by the drag gesture (the
|
||||||
|
// selection rig's move cross or a future floating drag), `placementDragMode`
|
||||||
|
// is set, so commit on pointer-up at the last previewed position instead of
|
||||||
|
// waiting for a second click — same contract as every other move tool.
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
// A press-release that never moved isn't a placement — back out cleanly
|
||||||
|
// (drop the ghost, re-select the fitting) instead of leaving the tool
|
||||||
|
// armed waiting for a click.
|
||||||
|
if (!hasMoved) {
|
||||||
|
onCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit(
|
||||||
|
{
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as GridEvent,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', onMove)
|
emitter.on('grid:move', onMove)
|
||||||
emitter.on('grid:click', commit)
|
emitter.on('grid:click', commit)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onMove)
|
emitter.off('grid:move', onMove)
|
||||||
emitter.off('grid:click', commit)
|
emitter.off('grid:click', commit)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
connectivity?.clear()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
if (existedAtStart) setMeshHidden(false)
|
if (existedAtStart) setMeshHidden(false)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
DuctFittingNode,
|
||||||
|
DuctSegmentNode,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
|
||||||
|
import { getDuctFittingPorts } from './ports'
|
||||||
|
|
||||||
|
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
|
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||||
|
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rectElbow() {
|
||||||
|
return DuctFittingNode.parse({
|
||||||
|
id: 'duct-fitting_resize' as AnyNodeId,
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
name: 'Resize elbow',
|
||||||
|
fittingType: 'elbow',
|
||||||
|
shape: 'rect',
|
||||||
|
width: 14,
|
||||||
|
height: 8,
|
||||||
|
diameter: equivalentDiameterIn(14, 8),
|
||||||
|
diameter2: equivalentDiameterIn(14, 8),
|
||||||
|
ductMaterial: 'sheet-metal',
|
||||||
|
system: 'supply',
|
||||||
|
position: [0, 0, 0],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
angle: 90,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function verticalRectRunFrom(point: Point, roll: number) {
|
||||||
|
return DuctSegmentNode.parse({
|
||||||
|
id: 'duct-segment_vertical' as AnyNodeId,
|
||||||
|
object: 'node',
|
||||||
|
parentId: null,
|
||||||
|
visible: true,
|
||||||
|
metadata: {},
|
||||||
|
name: 'Drawn vertical run',
|
||||||
|
path: [point, [point[0], point[1] + 3, point[2]]],
|
||||||
|
shape: 'rect',
|
||||||
|
width: 14,
|
||||||
|
height: 8,
|
||||||
|
diameter: equivalentDiameterIn(14, 8),
|
||||||
|
roll,
|
||||||
|
ductMaterial: 'sheet-metal',
|
||||||
|
insulationR: 0,
|
||||||
|
system: 'supply',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ductFittingParametrics', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
mock.module('@pascal-app/editor', () => ({
|
||||||
|
ActionButton: () => null,
|
||||||
|
}))
|
||||||
|
;({ ductFittingParametrics } = await import('./parametrics'))
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {},
|
||||||
|
rootNodeIds: [],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
readOnly: false,
|
||||||
|
} as never)
|
||||||
|
useScene.temporal.getState().clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resizing a fitting retrims connected ducts without changing their roll', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||||
|
const originalRoll = 0.37
|
||||||
|
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
|
||||||
|
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[duct.id]: duct as AnyNode,
|
||||||
|
},
|
||||||
|
rootNodeIds: [fitting.id, duct.id],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
readOnly: false,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const patch = { width: 20 }
|
||||||
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||||
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||||
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||||
|
const ductUpdate = updates.find((u) => u.id === duct.id)
|
||||||
|
|
||||||
|
expect(ductUpdate).toBeDefined()
|
||||||
|
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
|
||||||
|
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||||
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||||
|
const taggedDuct = DuctSegmentNode.parse({
|
||||||
|
...duct,
|
||||||
|
metadata: withAutoOffsetTag(duct.metadata, {
|
||||||
|
group: 'aoff_resize',
|
||||||
|
dy: 1,
|
||||||
|
minted: ['duct-fitting_minted' as AnyNodeId],
|
||||||
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||||
|
},
|
||||||
|
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
readOnly: false,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const patch = { width: 20 }
|
||||||
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||||
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||||
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||||
|
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
|
||||||
|
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
|
||||||
|
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
|
||||||
|
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
|
||||||
|
| Point[]
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
expect(basePath?.[0]).toEqual([...nextOutlet.position])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting an elbow re-extends mated runs back onto the junction', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const ports = getDuctFittingPorts(fitting)
|
||||||
|
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||||
|
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||||
|
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
|
||||||
|
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||||
|
const inletRun = DuctSegmentNode.parse({
|
||||||
|
...verticalRectRunFrom([...inlet.position] as Point, 0),
|
||||||
|
id: 'duct-segment_inlet' as AnyNodeId,
|
||||||
|
path: [
|
||||||
|
[...inlet.position] as Point,
|
||||||
|
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[outletRun.id]: outletRun as AnyNode,
|
||||||
|
[inletRun.id]: inletRun as AnyNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||||
|
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||||
|
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||||
|
|
||||||
|
// Both mated endpoints snap back to the junction (the original corner).
|
||||||
|
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||||
|
...fitting.position,
|
||||||
|
])
|
||||||
|
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||||
|
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
|
||||||
|
const outletRun = DuctSegmentNode.parse({
|
||||||
|
...verticalRectRunFrom(nearOutlet, 0),
|
||||||
|
id: 'duct-segment_outlet_gap' as AnyNodeId,
|
||||||
|
})
|
||||||
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[outletRun.id]: outletRun as AnyNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||||
|
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||||
|
|
||||||
|
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||||
|
...fitting.position,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||||
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||||
|
const taggedDuct = DuctSegmentNode.parse({
|
||||||
|
...duct,
|
||||||
|
metadata: withAutoOffsetTag(duct.metadata, {
|
||||||
|
group: 'aoff_deleted_elbow',
|
||||||
|
dy: 1,
|
||||||
|
minted: [fitting.id],
|
||||||
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||||
|
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||||
|
|
||||||
|
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting a tee leaves mated runs untouched', () => {
|
||||||
|
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
|
||||||
|
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
|
||||||
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||||
|
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||||
|
[tee.id]: tee as AnyNode,
|
||||||
|
[duct.id]: duct as AnyNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
|
||||||
|
const fitting = rectElbow()
|
||||||
|
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||||
|
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||||
|
const taggedDuct = DuctSegmentNode.parse({
|
||||||
|
...duct,
|
||||||
|
metadata: withAutoOffsetTag(duct.metadata, {
|
||||||
|
group: 'aoff_generated_fit',
|
||||||
|
dy: 1,
|
||||||
|
minted: [fitting.id],
|
||||||
|
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
useScene.setState({
|
||||||
|
nodes: {
|
||||||
|
[fitting.id]: fitting as AnyNode,
|
||||||
|
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||||
|
},
|
||||||
|
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||||
|
dirtyNodes: new Set(),
|
||||||
|
collections: {},
|
||||||
|
readOnly: false,
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
const patch = { width: 20 }
|
||||||
|
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||||
|
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||||
|
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||||
|
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||||
|
|
||||||
|
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,19 +7,41 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { Vector3 } from 'three'
|
import { Vector3 } from 'three'
|
||||||
import {
|
import {
|
||||||
ductPortDiameterIn,
|
autoOffsetInvalidationUpdates,
|
||||||
equivalentDiameterIn,
|
readAutoOffsetTag,
|
||||||
ovalEquivalentDiameterIn,
|
withAutoOffsetTag,
|
||||||
rollToContinueAcrossElbow,
|
} from '../shared/auto-offset-tag'
|
||||||
} from '../duct-segment/geometry'
|
import { DuctFittingSizeSwapEditor } from './inspector-editors'
|
||||||
import { getDuctFittingPorts } from './ports'
|
import { getDuctFittingPorts } from './ports'
|
||||||
import type { DuctFittingNode } from './schema'
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
/** Schema bounds for `diameter` / `diameter2`. */
|
/** Schema bounds for `diameter` / `diameter2`. */
|
||||||
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
|
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
|
||||||
|
|
||||||
|
const equivalentDiameterIn = (widthIn: number, heightIn: number): number =>
|
||||||
|
2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||||
|
|
||||||
|
const ovalEquivalentDiameterIn = (widthIn: number, heightIn: number): number => {
|
||||||
|
const minor = Math.min(widthIn, heightIn)
|
||||||
|
const major = Math.max(widthIn, heightIn)
|
||||||
|
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
|
||||||
|
return 2 * Math.sqrt(area / Math.PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ductPortDiameterIn = (node: DuctSegmentNode): number => {
|
||||||
|
if (node.shape === 'rect' && node.width && node.height) {
|
||||||
|
return equivalentDiameterIn(node.width, node.height)
|
||||||
|
}
|
||||||
|
if (node.shape === 'oval' && node.width && node.height) {
|
||||||
|
return ovalEquivalentDiameterIn(node.width, node.height)
|
||||||
|
}
|
||||||
|
return node.diameter
|
||||||
|
}
|
||||||
|
|
||||||
/** A duct endpoint sitting this close to a collar counts as mated. */
|
/** A duct endpoint sitting this close to a collar counts as mated. */
|
||||||
const MATE_TOL_M = 0.03
|
const MATE_TOL_M = 0.05
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||||
|
|
||||||
@@ -28,10 +50,13 @@ type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
|||||||
* port id. Auto-minted joints place duct ends exactly on the collar, so
|
* port id. Auto-minted joints place duct ends exactly on the collar, so
|
||||||
* a tight distance check is enough — no connectivity graph yet.
|
* a tight distance check is enough — no connectivity graph yet.
|
||||||
*/
|
*/
|
||||||
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
function matedDucts(
|
||||||
|
fitting: DuctFittingNode,
|
||||||
|
nodes: Record<AnyNodeId, AnyNode> = useScene.getState().nodes,
|
||||||
|
): Map<string, DuctMate> {
|
||||||
const mates = new Map<string, DuctMate>()
|
const mates = new Map<string, DuctMate>()
|
||||||
const ports = getDuctFittingPorts(fitting)
|
const ports = getDuctFittingPorts(fitting)
|
||||||
for (const node of Object.values(useScene.getState().nodes)) {
|
for (const node of Object.values(nodes)) {
|
||||||
if (node.type !== 'duct-segment') continue
|
if (node.type !== 'duct-segment') continue
|
||||||
const duct = node as DuctSegmentNode
|
const duct = node as DuctSegmentNode
|
||||||
for (const endIndex of [0, duct.path.length - 1]) {
|
for (const endIndex of [0, duct.path.length - 1]) {
|
||||||
@@ -51,6 +76,25 @@ function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
|||||||
return mates
|
return mates
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshedAutoOffsetMetadata(
|
||||||
|
duct: DuctSegmentNode,
|
||||||
|
endIndex: number,
|
||||||
|
target: Point,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const tag = readAutoOffsetTag(duct)
|
||||||
|
if (!tag) return null
|
||||||
|
let changed = false
|
||||||
|
const base = tag.base.map((patch) => {
|
||||||
|
if (patch.id !== duct.id || !Array.isArray(patch.data.path)) return patch
|
||||||
|
const path = patch.data.path.map((p) => (Array.isArray(p) ? [...p] : p))
|
||||||
|
if (!Array.isArray(path[endIndex])) return patch
|
||||||
|
path[endIndex] = [...target]
|
||||||
|
changed = true
|
||||||
|
return { ...patch, data: { ...patch.data, path } }
|
||||||
|
})
|
||||||
|
return changed ? withAutoOffsetTag(duct.metadata, { ...tag, base }) : null
|
||||||
|
}
|
||||||
|
|
||||||
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||||
// Switching the run legs round↔rect flips the whole fitting and sizes
|
// Switching the run legs round↔rect flips the whole fitting and sizes
|
||||||
// the new profile off the ducts actually mated to its collars, so the
|
// the new profile off the ducts actually mated to its collars, so the
|
||||||
@@ -127,34 +171,48 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
|||||||
path[mate.endIndex] = [...target.position]
|
path[mate.endIndex] = [...target.position]
|
||||||
data.path = path
|
data.path = path
|
||||||
}
|
}
|
||||||
// Steep rect / oval runs also re-derive their cross-section roll
|
const metadata = refreshedAutoOffsetMetadata(
|
||||||
// so a riser's profile stays continuous through the fitting (same
|
mate.duct,
|
||||||
// continuity the draw tool computes; runs flipped to rect after
|
mate.endIndex,
|
||||||
// drawing never got it). Horizontal runs are left alone — their
|
target.position as Point,
|
||||||
// roll-0 orientation is canonical and re-deriving it from a
|
|
||||||
// possibly-stale riser roll would corrupt it.
|
|
||||||
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
|
|
||||||
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
|
|
||||||
const source = getDuctFittingPorts(next).find(
|
|
||||||
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
|
|
||||||
)
|
)
|
||||||
if (away && source) {
|
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||||
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
|
|
||||||
if (newDir.lengthSq() >= 1e-10) {
|
|
||||||
newDir.normalize()
|
|
||||||
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
|
|
||||||
const srcMate = mates.get(source.id)
|
|
||||||
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
|
|
||||||
const srcDir = new Vector3(...source.direction)
|
|
||||||
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
|
||||||
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
|
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
|
||||||
}
|
}
|
||||||
return updates
|
return [...updates, ...autoOffsetInvalidationUpdates(useScene.getState().nodes, next.id)]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deleting an auto-inserted elbow restores the corner it replaced: both
|
||||||
|
// mated runs were pulled back one leg onto its collars, with the
|
||||||
|
// junction (the fitting's position) sitting exactly on the corner they
|
||||||
|
// originally met at. Re-extend each mated endpoint back to that junction
|
||||||
|
// so the L-shape returns to its pre-fitting length. Scoped to elbows —
|
||||||
|
// tees / crosses split a trunk into two separate nodes, which can't be
|
||||||
|
// re-joined by moving an endpoint.
|
||||||
|
onDelete: (fitting, nodes) => {
|
||||||
|
const invalidations = autoOffsetInvalidationUpdates(nodes, fitting.id)
|
||||||
|
if (fitting.fittingType !== 'elbow') return invalidations
|
||||||
|
const junction = new Vector3(...fitting.position)
|
||||||
|
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||||
|
for (const mate of matedDucts(fitting, nodes).values()) {
|
||||||
|
const end = mate.duct.path[mate.endIndex]
|
||||||
|
if (!end) continue
|
||||||
|
const dx = end[0] - junction.x
|
||||||
|
const dy = end[1] - junction.y
|
||||||
|
const dz = end[2] - junction.z
|
||||||
|
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||||
|
const path = mate.duct.path.map((p) => [...p] as Point)
|
||||||
|
path[mate.endIndex] = [junction.x, junction.y, junction.z]
|
||||||
|
const data: Partial<DuctSegmentNode> = { path }
|
||||||
|
const metadata = refreshedAutoOffsetMetadata(mate.duct, mate.endIndex, [
|
||||||
|
junction.x,
|
||||||
|
junction.y,
|
||||||
|
junction.z,
|
||||||
|
])
|
||||||
|
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||||
|
updates.push({ id: mate.duct.id, data })
|
||||||
|
}
|
||||||
|
return [...updates, ...invalidations]
|
||||||
},
|
},
|
||||||
groups: [
|
groups: [
|
||||||
{
|
{
|
||||||
@@ -170,7 +228,7 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
|||||||
key: 'angle',
|
key: 'angle',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
unit: '°',
|
unit: '°',
|
||||||
min: 15,
|
min: 0,
|
||||||
max: 90,
|
max: 90,
|
||||||
step: 15,
|
step: 15,
|
||||||
visibleIf: (n) => n.fittingType === 'elbow',
|
visibleIf: (n) => n.fittingType === 'elbow',
|
||||||
@@ -236,6 +294,13 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
|||||||
visibleIf: (n) =>
|
visibleIf: (n) =>
|
||||||
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'swapWidthHeight',
|
||||||
|
kind: 'custom',
|
||||||
|
component: DuctFittingSizeSwapEditor,
|
||||||
|
visibleIf: (n) =>
|
||||||
|
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'shape2',
|
key: 'shape2',
|
||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { NodePort } from '@pascal-app/core'
|
import type { NodePort } from '@pascal-app/core'
|
||||||
import { Euler, Vector3 } from 'three'
|
import { Euler, Vector3 } from 'three'
|
||||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
|
||||||
import type { DuctFittingNode } from './schema'
|
import type { DuctFittingNode } from './schema'
|
||||||
|
|
||||||
|
const INCHES_TO_METERS = 0.0254
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collar stub length in meters — how far each port sticks out from the
|
* Collar stub length in meters — how far each port sticks out from the
|
||||||
* fitting's junction center. Scales with the duct so big trunks get
|
* fitting's junction center. Scales with the duct so big trunks get
|
||||||
|
|||||||
@@ -1,27 +1,299 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
analyzePortConnectivity,
|
||||||
|
type Cursor,
|
||||||
|
type DuctFittingNode,
|
||||||
|
type PortConnectivity,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resolveConnectivityUpdates,
|
||||||
|
resumeSceneHistory,
|
||||||
|
sceneRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
ARROW_COLOR,
|
||||||
|
EDITOR_LAYER,
|
||||||
|
swallowNextClick,
|
||||||
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||||
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
BufferGeometry,
|
||||||
|
Euler,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
type Group,
|
||||||
|
LineSegments,
|
||||||
|
type Object3D,
|
||||||
|
OrthographicCamera,
|
||||||
|
Plane,
|
||||||
|
Quaternion,
|
||||||
|
Raycaster,
|
||||||
|
SphereGeometry,
|
||||||
|
Vector2,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||||
|
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
||||||
|
import {
|
||||||
|
AXIS_VECTORS,
|
||||||
|
cycleRotationAxis,
|
||||||
|
ROTATE_STEP_RAD,
|
||||||
|
type RotationAxis,
|
||||||
|
} from '../shared/fitting-rotation'
|
||||||
|
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
||||||
|
import { fittingLegLength } from './ports'
|
||||||
|
|
||||||
|
type Point = [number, number, number]
|
||||||
|
|
||||||
|
/** Stand-off (meters) from the fitting body to each arrow. */
|
||||||
|
const ARROW_GAP = 0.14
|
||||||
|
const RESIZE_HANDLE_GAP = 0.18
|
||||||
|
const RESIZE_STEP_IN = 1
|
||||||
|
const RESIZE_GUIDE_DASH = 0.07
|
||||||
|
const RESIZE_GUIDE_GAP = 0.045
|
||||||
|
const RESIZE_SPHERE_RADIUS = 0.065
|
||||||
|
const RESIZE_HIT_RADIUS = 0.13
|
||||||
|
|
||||||
|
const UP = new Vector3(0, 1, 0)
|
||||||
|
|
||||||
|
function snap(value: number, step: number): number {
|
||||||
|
if (step <= 0) return value
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough body radius (meters) — the larger of the fitting's two collar reaches,
|
||||||
|
* used to stand the handles clear of the geometry. */
|
||||||
|
function fittingExtentM(node: DuctFittingNode): number {
|
||||||
|
const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter
|
||||||
|
return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The transform a drag frame writes onto the fitting. */
|
||||||
|
type FittingTransform = { position?: Point; rotation?: Point }
|
||||||
|
type FittingDimension = 'width' | 'height'
|
||||||
|
|
||||||
|
function fittingParameterPatch(node: DuctFittingNode): Partial<DuctFittingNode> {
|
||||||
|
return {
|
||||||
|
fittingType: node.fittingType,
|
||||||
|
shape: node.shape,
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
shape2: node.shape2,
|
||||||
|
width2: node.width2,
|
||||||
|
height2: node.height2,
|
||||||
|
angle: node.angle,
|
||||||
|
branchAngle: node.branchAngle,
|
||||||
|
diameter: node.diameter,
|
||||||
|
diameter2: node.diameter2,
|
||||||
|
ductMaterial: node.ductMaterial,
|
||||||
|
system: node.system,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveFittingParameters(
|
||||||
|
node: DuctFittingNode,
|
||||||
|
data: Partial<DuctFittingNode>,
|
||||||
|
): Partial<AnyNode> {
|
||||||
|
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
function canResizeRunProfile(node: DuctFittingNode): boolean {
|
||||||
|
return (
|
||||||
|
node.fittingType === 'transition' || (node.fittingType !== 'reducer' && node.shape !== 'round')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensionBounds(dimension: FittingDimension): { min: number; max: number } {
|
||||||
|
return dimension === 'width' ? { min: 4, max: 60 } : { min: 3, max: 40 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closestAxisParameterToRay(
|
||||||
|
axisOrigin: Vector3,
|
||||||
|
axisDirection: Vector3,
|
||||||
|
ray: Raycaster['ray'],
|
||||||
|
) {
|
||||||
|
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||||
|
const b = axisDirection.dot(ray.direction)
|
||||||
|
const d = axisDirection.dot(originToRay)
|
||||||
|
const e = ray.direction.dot(originToRay)
|
||||||
|
const denominator = 1 - b * b
|
||||||
|
if (Math.abs(denominator) < 1e-6) return -d
|
||||||
|
const axisParameter = (b * e - d) / denominator
|
||||||
|
const rayParameter = e + b * axisParameter
|
||||||
|
return rayParameter < 0 ? -d : axisParameter
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||||
|
const line = useMemo(() => {
|
||||||
|
const a = new Vector3(from[0], from[1], from[2])
|
||||||
|
const b = new Vector3(to[0], to[1], to[2])
|
||||||
|
const span = b.clone().sub(a)
|
||||||
|
const length = span.length()
|
||||||
|
const points: number[] = []
|
||||||
|
if (length > 1e-4) {
|
||||||
|
const dir = span.clone().normalize()
|
||||||
|
let t = 0
|
||||||
|
while (t < length) {
|
||||||
|
const start = a.clone().addScaledVector(dir, t)
|
||||||
|
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||||
|
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||||
|
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const geometry = new BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||||
|
const material = new LineBasicNodeMaterial({
|
||||||
|
color: ARROW_COLOR,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
const next = new LineSegments(geometry, material)
|
||||||
|
next.frustumCulled = false
|
||||||
|
next.layers.set(EDITOR_LAYER)
|
||||||
|
next.renderOrder = 1002
|
||||||
|
next.raycast = () => {}
|
||||||
|
return next
|
||||||
|
}, [from, to])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
line.geometry.dispose()
|
||||||
|
;(line.material as LineBasicNodeMaterial).dispose()
|
||||||
|
},
|
||||||
|
[line],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <primitive object={line} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizeSphereHandle({
|
||||||
|
cursor,
|
||||||
|
onPointerDown,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
cursor: Cursor
|
||||||
|
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||||
|
position: Point
|
||||||
|
}) {
|
||||||
|
const { camera } = useThree()
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||||
|
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||||
|
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||||
|
const sphereMaterial = useMemo(
|
||||||
|
() =>
|
||||||
|
new MeshBasicNodeMaterial({
|
||||||
|
color: ARROW_COLOR,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.92,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const hitMaterial = useMemo(
|
||||||
|
() =>
|
||||||
|
new MeshBasicNodeMaterial({
|
||||||
|
color: ARROW_COLOR,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||||
|
}, [sphereMaterial, hovered])
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
hitGeometry.dispose()
|
||||||
|
sphereGeometry.dispose()
|
||||||
|
sphereMaterial.dispose()
|
||||||
|
hitMaterial.dispose()
|
||||||
|
},
|
||||||
|
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||||
|
)
|
||||||
|
|
||||||
|
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.nativeEvent.stopPropagation()
|
||||||
|
event.nativeEvent.stopImmediatePropagation()
|
||||||
|
swallowNextClick()
|
||||||
|
onPointerDown(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position} scale={zoom}>
|
||||||
|
<mesh
|
||||||
|
geometry={hitGeometry}
|
||||||
|
material={hitMaterial}
|
||||||
|
onPointerDown={consumePress}
|
||||||
|
onPointerEnter={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setHovered(true)
|
||||||
|
document.body.style.cursor = cursor
|
||||||
|
}}
|
||||||
|
onPointerLeave={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setHovered(false)
|
||||||
|
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selection-time rotation support for placed fittings, mounted by the
|
* Selection-time affordances for a placed duct fitting — the 3D twin of the
|
||||||
* editor's SelectionAffordanceManager (`def.affordanceTools.selection`).
|
* duct-segment selection rig. A CLICK-to-latch cube sits at the fitting center;
|
||||||
* The R/T rotation itself lives in `def.keyboardActions` (the editor's
|
* clicking it opens (click again to close) a cluster of:
|
||||||
* keyboard hook dispatches it); this contributes the piece that hook
|
*
|
||||||
* can't: **Alt cycles the active rotation axis** while a single fitting
|
* - **Six move arrows** (±X / ±Y / ±Z): translate the whole fitting along one
|
||||||
* is selected. The axis lives on `useEditor.rotationAxis`, which the
|
* world axis. Connected runs follow via port connectivity.
|
||||||
* floating action menu reads to show the axis pill above the selected
|
* - **Three rotation arcs** (X / Y / Z): spin the fitting about each world
|
||||||
* fitting — so this component renders nothing.
|
* axis. Connected runs re-aim via port follow.
|
||||||
|
* - **Two profile cubes** on the fitting's visible side/top faces: resize
|
||||||
|
* non-round fitting width and height without occupying the inside corner.
|
||||||
|
*
|
||||||
|
* The handle rig is PORTALED into the fitting group's PARENT — never the
|
||||||
|
* fitting group itself — because the selection outliner (`MergedOutlineNode`)
|
||||||
|
* traces every descendant mesh of the SELECTED node, so a hit-area cylinder
|
||||||
|
* parented under the fitting would be swept into its selection outline. Walls /
|
||||||
|
* doors / windows dodge it the same way. The fitting's local `position` is
|
||||||
|
* expressed in the parent's frame, so an identity group under the parent lets
|
||||||
|
* us place handles at absolute level-local coords with world-aligned axes.
|
||||||
|
*
|
||||||
|
* History does the single-undo dance: paused during the drag (live ticks are
|
||||||
|
* untracked), reverted on release, resumed, then the final transform re-applied
|
||||||
|
* as one tracked change so the whole joint is one undo step.
|
||||||
*/
|
*/
|
||||||
const DuctFittingSelectionAffordance = () => {
|
const DuctFittingSelectionAffordance = () => {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const hasSelectedFitting = useScene((s) => {
|
const fitting = useScene((s) => {
|
||||||
if (selectedIds.length !== 1) return false
|
if (selectedIds.length !== 1) return null
|
||||||
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting'
|
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||||
|
return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Alt cycles the active rotation axis for the R / T keyboard rotate while a
|
||||||
|
// single fitting is selected (the gizmo's three arcs cover every axis on
|
||||||
|
// their own; this only keeps the keyboard action meaningful).
|
||||||
|
const hasSelectedFitting = !!fitting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSelectedFitting) return
|
if (!hasSelectedFitting) return
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -31,13 +303,586 @@ const DuctFittingSelectionAffordance = () => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
cycleRotationAxis()
|
cycleRotationAxis()
|
||||||
}
|
}
|
||||||
// Bubble phase — when the placement tool is active its capture-phase
|
|
||||||
// handler stops propagation, so the two never double-cycle.
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
}, [hasSelectedFitting])
|
}, [hasSelectedFitting])
|
||||||
|
|
||||||
return null
|
// Portal target: the fitting's registered group. Resolved with a rAF retry
|
||||||
|
// because registration lands on the renderer's mount, a frame after select.
|
||||||
|
const fittingId = fitting?.id ?? null
|
||||||
|
const [target, setTarget] = useState<Object3D | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fittingId) {
|
||||||
|
setTarget(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let frameId = 0
|
||||||
|
const resolve = () => {
|
||||||
|
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
|
||||||
|
setTarget((cur) => (cur === next ? cur : next))
|
||||||
|
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
return () => window.cancelAnimationFrame(frameId)
|
||||||
|
}, [fittingId])
|
||||||
|
|
||||||
|
if (!fitting || !target) return null
|
||||||
|
const mount = target.parent ?? target
|
||||||
|
return createPortal(<FittingHandles fitting={fitting} target={target} />, mount, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => {
|
||||||
|
const { camera, gl } = useThree()
|
||||||
|
const [frame, setFrame] = useState<Group | null>(null)
|
||||||
|
// True while the cluster is latched open. Click the center cube to toggle.
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
// True while a move / rotate drag is live — the arrows hide (the window
|
||||||
|
// pointer handlers own the gesture), exactly like the duct-segment rig.
|
||||||
|
const [dragging, setDragging] = useState(false)
|
||||||
|
const [sideSign, setSideSign] = useState(1)
|
||||||
|
|
||||||
|
const makeRay = (clientX: number, clientY: number) => {
|
||||||
|
const rect = gl.domElement.getBoundingClientRect()
|
||||||
|
const ndc = new Vector2(
|
||||||
|
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||||
|
)
|
||||||
|
const raycaster = new Raycaster()
|
||||||
|
raycaster.setFromCamera(ndc, camera)
|
||||||
|
return raycaster.ray
|
||||||
|
}
|
||||||
|
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
||||||
|
const hit = new Vector3()
|
||||||
|
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
||||||
|
}
|
||||||
|
const sampleAxisParameter = (
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
axisOrigin: Vector3,
|
||||||
|
axisDirection: Vector3,
|
||||||
|
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||||
|
/** World hit on a vertical, camera-facing plane through `anchorWorld`,
|
||||||
|
* returned as a level-local Y (the frame is axis-aligned to the parent). */
|
||||||
|
const intersectVerticalY = (
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
anchorWorld: Vector3,
|
||||||
|
): number | null => {
|
||||||
|
if (!frame) return null
|
||||||
|
const forward = camera.getWorldDirection(new Vector3())
|
||||||
|
forward.y = 0
|
||||||
|
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
|
||||||
|
forward.normalize()
|
||||||
|
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
|
||||||
|
const hit = intersect(clientX, clientY, plane)
|
||||||
|
return hit ? frame.worldToLocal(hit.clone()).y : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toWorld = (p: Point): Vector3 =>
|
||||||
|
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
|
||||||
|
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||||
|
const originWorld = toWorld(origin)
|
||||||
|
const tipWorld = frame
|
||||||
|
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||||
|
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||||
|
return tipWorld.sub(originWorld).normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cursor's coordinate on one world axis, in the frame's local space. For Y
|
||||||
|
* it rides a camera-facing vertical plane; for X / Z it projects onto the
|
||||||
|
* horizontal plane through the fitting and reads back the local component. */
|
||||||
|
const sampleAxis = (
|
||||||
|
axis: RotationAxis,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
anchorWorld: Vector3,
|
||||||
|
): number | null => {
|
||||||
|
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
|
||||||
|
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
|
||||||
|
const hit = intersect(clientX, clientY, plane)
|
||||||
|
if (!hit || !frame) return null
|
||||||
|
const local = frame.worldToLocal(hit.clone())
|
||||||
|
return axis === 'x' ? local.x : local.z
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-updates for runs / fittings mated to this fitting, given a preview
|
||||||
|
// transform. Endpoints whose ports didn't move resolve to a zero delta.
|
||||||
|
const connectivityUpdates = (
|
||||||
|
connectivity: PortConnectivity | null,
|
||||||
|
transform: FittingTransform,
|
||||||
|
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||||
|
if (!connectivity) return []
|
||||||
|
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
return resolveConnectivityUpdates(connectivity, preview)
|
||||||
|
.filter((u) => nodes[u.id])
|
||||||
|
.map((u) => {
|
||||||
|
const node = nodes[u.id]
|
||||||
|
if (node?.type !== 'duct-fitting') return u
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
data: preserveFittingParameters(
|
||||||
|
node as DuctFittingNode,
|
||||||
|
u.data as Partial<DuctFittingNode>,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared lifecycle for the move / rotate drags. `makeCompute` is built at
|
||||||
|
* pointer-down so it can capture the grab anchor (cursor's start coord /
|
||||||
|
* bearing) and avoid a teleport. Each frame `compute` turns the cursor into
|
||||||
|
* the fitting's next transform; the fitting writes it and any mated runs
|
||||||
|
* follow via port connectivity. Lands as one undo step.
|
||||||
|
*/
|
||||||
|
const beginDrag =
|
||||||
|
(
|
||||||
|
cursor: Cursor,
|
||||||
|
makeCompute: (
|
||||||
|
e: ThreeEvent<PointerEvent>,
|
||||||
|
) => (event: PointerEvent) => FittingTransform | null,
|
||||||
|
) =>
|
||||||
|
(e: ThreeEvent<PointerEvent>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const initialPosition = [...fitting.position] as Point
|
||||||
|
const initialRotation = [...fitting.rotation] as Point
|
||||||
|
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
|
||||||
|
const compute = makeCompute(e)
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
setDragging(true)
|
||||||
|
document.body.style.cursor = cursor
|
||||||
|
let current: FittingTransform | null = null
|
||||||
|
|
||||||
|
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
|
||||||
|
{
|
||||||
|
id: fitting.id as AnyNodeId,
|
||||||
|
data: preserveFittingParameters(fitting, t as Partial<DuctFittingNode>),
|
||||||
|
},
|
||||||
|
...connectivityUpdates(connectivity, t),
|
||||||
|
]
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
const next = compute(event)
|
||||||
|
if (!next) return
|
||||||
|
current = next
|
||||||
|
useScene.getState().updateNodes(buildBatch(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onUp)
|
||||||
|
window.removeEventListener('pointercancel', onUp)
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
setDragging(false)
|
||||||
|
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
// Swallow the trailing synthetic click so it doesn't reach the
|
||||||
|
// background-click deselect handler (cleanup drops `inputDragging`
|
||||||
|
// synchronously here).
|
||||||
|
swallowNextClick()
|
||||||
|
cleanup()
|
||||||
|
// Single-undo dance: revert the fitting AND its followers to the
|
||||||
|
// pre-drag state while history is still paused, resume, then re-apply
|
||||||
|
// the final transform as one tracked change.
|
||||||
|
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||||
|
connectivity?.connections ?? []
|
||||||
|
).map((conn) => {
|
||||||
|
if (conn.kind !== 'rigid-node') {
|
||||||
|
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||||
|
}
|
||||||
|
const node = useScene.getState().nodes[conn.nodeId]
|
||||||
|
return {
|
||||||
|
id: conn.nodeId,
|
||||||
|
data:
|
||||||
|
node?.type === 'duct-fitting'
|
||||||
|
? preserveFittingParameters(node as DuctFittingNode, {
|
||||||
|
position: conn.startPosition as Point,
|
||||||
|
})
|
||||||
|
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
useScene.getState().updateNodes([
|
||||||
|
{
|
||||||
|
id: fitting.id as AnyNodeId,
|
||||||
|
data: preserveFittingParameters(fitting, {
|
||||||
|
position: initialPosition,
|
||||||
|
rotation: initialRotation,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
...reverts.filter((u) => useScene.getState().nodes[u.id]),
|
||||||
|
])
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
if (current) {
|
||||||
|
const scene = useScene.getState()
|
||||||
|
scene.updateNodes([
|
||||||
|
...buildBatch(current),
|
||||||
|
...autoOffsetInvalidationUpdates(scene.nodes, fitting.id as AnyNodeId),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onUp)
|
||||||
|
window.addEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move: translate the fitting along one world axis, anchored to the cursor's
|
||||||
|
// start coord so it doesn't jump on grab. Y is clamped at the floor; Shift
|
||||||
|
// bypasses grid snapping.
|
||||||
|
const moveCompute =
|
||||||
|
(axis: RotationAxis) =>
|
||||||
|
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||||
|
const anchorWorld = toWorld(fitting.position as Point)
|
||||||
|
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
|
||||||
|
const base = [...fitting.position] as Point
|
||||||
|
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||||
|
let lastDelta = Number.NaN
|
||||||
|
return (event: PointerEvent): FittingTransform | null => {
|
||||||
|
if (start === null) return null
|
||||||
|
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
|
||||||
|
if (s === null) return null
|
||||||
|
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||||
|
const delta = snap(s - start, step)
|
||||||
|
if (delta === lastDelta) return null
|
||||||
|
lastDelta = delta
|
||||||
|
if (step > 0) triggerSFX('sfx:grid-snap')
|
||||||
|
const next = [...base] as Point
|
||||||
|
next[axisIndex] = (
|
||||||
|
axis === 'y' ? Math.max(0, base[axisIndex] + delta) : base[axisIndex] + delta
|
||||||
|
) as number
|
||||||
|
return { position: next }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate: spin the fitting about one world axis. The cursor's bearing in the
|
||||||
|
// plane perpendicular to that axis (through the body center) drives the
|
||||||
|
// angle; world-frame premultiply so the axis means the screen X/Y/Z the user
|
||||||
|
// expects regardless of how the fitting is already turned.
|
||||||
|
const rotateCompute =
|
||||||
|
(axis: RotationAxis) =>
|
||||||
|
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||||
|
const normal = AXIS_VECTORS[axis].clone()
|
||||||
|
const center = toWorld(fitting.position as Point)
|
||||||
|
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
|
||||||
|
const u = ref
|
||||||
|
.clone()
|
||||||
|
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
|
||||||
|
.normalize()
|
||||||
|
const v = new Vector3().crossVectors(normal, u)
|
||||||
|
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
|
||||||
|
const bearing = (clientX: number, clientY: number): number | null => {
|
||||||
|
const hit = intersect(clientX, clientY, plane)
|
||||||
|
if (!hit) return null
|
||||||
|
const d = hit.sub(center)
|
||||||
|
return Math.atan2(d.dot(v), d.dot(u))
|
||||||
|
}
|
||||||
|
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
|
||||||
|
const startQuat = new Quaternion().setFromEuler(
|
||||||
|
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||||
|
)
|
||||||
|
let lastStep = Number.NaN
|
||||||
|
return (event: PointerEvent): FittingTransform | null => {
|
||||||
|
if (startBearing === null) return null
|
||||||
|
const b = bearing(event.clientX, event.clientY)
|
||||||
|
if (b === null) return null
|
||||||
|
// Snap the turn to 45° steps; Shift = smooth (no snap).
|
||||||
|
const raw = b - startBearing
|
||||||
|
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
||||||
|
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
|
||||||
|
// turns only — a smooth Shift-drag has no discrete steps to mark).
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||||
|
if (step !== lastStep) {
|
||||||
|
lastStep = step
|
||||||
|
triggerSFX('sfx:item-rotate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||||
|
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||||
|
return { rotation: [euler.x, euler.y, euler.z] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beginDimensionDrag =
|
||||||
|
(dimension: FittingDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||||
|
(e: ThreeEvent<PointerEvent>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const baseValue = fitting[dimension]
|
||||||
|
const initialPatch = { [dimension]: baseValue } as Partial<DuctFittingNode>
|
||||||
|
const centerWorld = toWorld(fitting.position as Point)
|
||||||
|
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||||
|
const start = sampleAxisParameter(
|
||||||
|
e.nativeEvent.clientX,
|
||||||
|
e.nativeEvent.clientY,
|
||||||
|
centerWorld,
|
||||||
|
axisWorld,
|
||||||
|
)
|
||||||
|
const { min, max } = dimensionBounds(dimension)
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
useViewer.getState().setInputDragging(true)
|
||||||
|
setDragging(true)
|
||||||
|
document.body.style.cursor = cursor
|
||||||
|
let current: Partial<DuctFittingNode> | null = null
|
||||||
|
let lastValue = Number.NaN
|
||||||
|
|
||||||
|
const apply = (patch: Partial<DuctFittingNode>) => {
|
||||||
|
useScene.getState().updateNodes([
|
||||||
|
{
|
||||||
|
id: fitting.id as AnyNodeId,
|
||||||
|
data: preserveFittingParameters(fitting, patch),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
const rawDeltaM =
|
||||||
|
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||||
|
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||||
|
const nextRaw = baseValue + deltaIn
|
||||||
|
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), min, max)
|
||||||
|
if (nextValue === lastValue) return
|
||||||
|
lastValue = nextValue
|
||||||
|
current = { [dimension]: nextValue } as Partial<DuctFittingNode>
|
||||||
|
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||||
|
apply(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onUp)
|
||||||
|
window.removeEventListener('pointercancel', onUp)
|
||||||
|
useViewer.getState().setInputDragging(false)
|
||||||
|
setDragging(false)
|
||||||
|
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
swallowNextClick()
|
||||||
|
cleanup()
|
||||||
|
apply(initialPatch)
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
if (current) apply(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onUp)
|
||||||
|
window.addEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||||
|
const p = fitting.position as Point
|
||||||
|
const base = extent + ARROW_GAP
|
||||||
|
const fittingRotation = useMemo(
|
||||||
|
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||||
|
[fitting.rotation],
|
||||||
|
)
|
||||||
|
const profileAxes = useMemo(() => {
|
||||||
|
const hingeAxis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||||
|
const sideAxis = new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize()
|
||||||
|
const hingeIsVertical = Math.abs(hingeAxis.y) >= Math.SQRT1_2
|
||||||
|
const hingeDimension: FittingDimension = hingeIsVertical ? 'height' : 'width'
|
||||||
|
const sideDimension: FittingDimension = hingeIsVertical ? 'width' : 'height'
|
||||||
|
const hingeEntry = { key: hingeDimension, axis: hingeAxis }
|
||||||
|
const sideEntry = { key: sideDimension, axis: sideAxis }
|
||||||
|
return Math.abs(hingeAxis.dot(UP)) >= Math.abs(sideAxis.dot(UP))
|
||||||
|
? { top: hingeEntry, side: sideEntry }
|
||||||
|
: { top: sideEntry, side: hingeEntry }
|
||||||
|
}, [fittingRotation])
|
||||||
|
const topAxis = useMemo(() => {
|
||||||
|
const axis = profileAxes.top.axis.clone()
|
||||||
|
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||||
|
}, [profileAxes])
|
||||||
|
const baseSideAxis = profileAxes.side.axis
|
||||||
|
const sideAxis = useMemo(
|
||||||
|
() => baseSideAxis.clone().multiplyScalar(sideSign),
|
||||||
|
[baseSideAxis, sideSign],
|
||||||
|
)
|
||||||
|
useFrame(() => {
|
||||||
|
if (!frame) return
|
||||||
|
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||||
|
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||||
|
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||||
|
const nextSign = baseSideAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||||
|
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||||
|
})
|
||||||
|
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||||
|
const resizeHandles: {
|
||||||
|
key: FittingDimension
|
||||||
|
axis: Vector3
|
||||||
|
cursor: Cursor
|
||||||
|
guideFrom: Point
|
||||||
|
guideTo: Point
|
||||||
|
position: Point
|
||||||
|
}[] = canResizeRunProfile(fitting)
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: profileAxes.top.key,
|
||||||
|
axis: topAxis,
|
||||||
|
cursor: 'ns-resize',
|
||||||
|
guideFrom: [
|
||||||
|
p[0] + topAxis.x * resizeHandleBase,
|
||||||
|
p[1] + topAxis.y * resizeHandleBase,
|
||||||
|
p[2] + topAxis.z * resizeHandleBase,
|
||||||
|
],
|
||||||
|
guideTo: [
|
||||||
|
p[0] + topAxis.x * Math.max(extent * 0.18, 0.04),
|
||||||
|
p[1] + topAxis.y * Math.max(extent * 0.18, 0.04),
|
||||||
|
p[2] + topAxis.z * Math.max(extent * 0.18, 0.04),
|
||||||
|
],
|
||||||
|
position: [
|
||||||
|
p[0] + topAxis.x * resizeHandleBase,
|
||||||
|
p[1] + topAxis.y * resizeHandleBase,
|
||||||
|
p[2] + topAxis.z * resizeHandleBase,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: profileAxes.side.key,
|
||||||
|
axis: sideAxis,
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
guideFrom: [
|
||||||
|
p[0] + sideAxis.x * resizeHandleBase,
|
||||||
|
p[1] + sideAxis.y * resizeHandleBase,
|
||||||
|
p[2] + sideAxis.z * resizeHandleBase,
|
||||||
|
],
|
||||||
|
guideTo: [
|
||||||
|
p[0] + sideAxis.x * Math.max(extent * 0.18, 0.04),
|
||||||
|
p[1] + sideAxis.y * Math.max(extent * 0.18, 0.04),
|
||||||
|
p[2] + sideAxis.z * Math.max(extent * 0.18, 0.04),
|
||||||
|
],
|
||||||
|
position: [
|
||||||
|
p[0] + sideAxis.x * resizeHandleBase,
|
||||||
|
p[1] + sideAxis.y * resizeHandleBase,
|
||||||
|
p[2] + sideAxis.z * resizeHandleBase,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Six whole-fitting move arrows, one per ± world axis.
|
||||||
|
const moveArrows: {
|
||||||
|
key: string
|
||||||
|
axis: RotationAxis
|
||||||
|
position: Point
|
||||||
|
rotationY: number
|
||||||
|
vertical?: 'up' | 'down'
|
||||||
|
cursor: Cursor
|
||||||
|
}[] = [
|
||||||
|
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
|
||||||
|
{
|
||||||
|
key: '-x',
|
||||||
|
axis: 'x',
|
||||||
|
position: [p[0] - base, p[1], p[2]],
|
||||||
|
rotationY: Math.PI,
|
||||||
|
cursor: 'grab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '+z',
|
||||||
|
axis: 'z',
|
||||||
|
position: [p[0], p[1], p[2] + base],
|
||||||
|
rotationY: -Math.PI / 2,
|
||||||
|
cursor: 'grab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '-z',
|
||||||
|
axis: 'z',
|
||||||
|
position: [p[0], p[1], p[2] - base],
|
||||||
|
rotationY: Math.PI / 2,
|
||||||
|
cursor: 'grab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '+y',
|
||||||
|
axis: 'y',
|
||||||
|
position: [p[0], p[1] + base, p[2]],
|
||||||
|
rotationY: 0,
|
||||||
|
vertical: 'up',
|
||||||
|
cursor: 'ns-resize',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '-y',
|
||||||
|
axis: 'y',
|
||||||
|
position: [p[0], p[1] - base, p[2]],
|
||||||
|
rotationY: 0,
|
||||||
|
vertical: 'down',
|
||||||
|
cursor: 'ns-resize',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Three rotation arcs, one per world axis. Each arc wraps its axis (the
|
||||||
|
// shared `curved-arrow` wraps world +Y by default; `setFromUnitVectors`
|
||||||
|
// re-aims it) and sits at a diagonal offset in the plane it spins, so the
|
||||||
|
// three don't pile onto the move arrows.
|
||||||
|
const d = base * Math.SQRT1_2
|
||||||
|
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
|
||||||
|
['x', 'y', 'z'] as RotationAxis[]
|
||||||
|
).map((axis) => {
|
||||||
|
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
|
||||||
|
// Spin the arc in place about its own axis so the grip sits where we want
|
||||||
|
// it without moving its position.
|
||||||
|
if (axis === 'z') {
|
||||||
|
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
|
||||||
|
} else if (axis === 'x') {
|
||||||
|
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
|
||||||
|
} else if (axis === 'y') {
|
||||||
|
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
|
||||||
|
}
|
||||||
|
const e = new Euler().setFromQuaternion(q)
|
||||||
|
const position: Point =
|
||||||
|
axis === 'x'
|
||||||
|
? [p[0], p[1] + d, p[2] + d]
|
||||||
|
: axis === 'y'
|
||||||
|
? [p[0] + d, p[1], p[2] + d]
|
||||||
|
: [p[0] + d, p[1] + d, p[2]]
|
||||||
|
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dragging) {
|
||||||
|
return <group ref={setFrame} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<group ref={setFrame}>
|
||||||
|
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||||
|
{!open &&
|
||||||
|
resizeHandles.map((handle) => (
|
||||||
|
<group key={handle.key}>
|
||||||
|
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||||
|
<ResizeSphereHandle
|
||||||
|
cursor={handle.cursor}
|
||||||
|
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||||
|
position={handle.position}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{moveArrows.map((a) => (
|
||||||
|
<MoveChevron
|
||||||
|
cursor={a.cursor}
|
||||||
|
key={a.key}
|
||||||
|
onPointerDown={beginDrag(
|
||||||
|
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||||
|
moveCompute(a.axis),
|
||||||
|
)}
|
||||||
|
position={a.position}
|
||||||
|
rotationY={a.rotationY}
|
||||||
|
vertical={a.vertical}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rotateArcs.map((arc) => (
|
||||||
|
<RotateArc
|
||||||
|
key={arc.key}
|
||||||
|
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||||
|
position={arc.position}
|
||||||
|
rotation={arc.rotation}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DuctFittingSelectionAffordance
|
export default DuctFittingSelectionAffordance
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
||||||
|
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||||
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
||||||
|
import { createSegmentMoveAffordance } from '../shared/path-segment-affordance'
|
||||||
import { buildDuctSegmentFloorplan } from './floorplan'
|
import { buildDuctSegmentFloorplan } from './floorplan'
|
||||||
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
|
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
|
||||||
import { ductSegmentParametrics } from './parametrics'
|
import { ductSegmentParametrics } from './parametrics'
|
||||||
@@ -75,6 +77,8 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
|||||||
selectable: { hitVolume: 'bbox' },
|
selectable: { hitVolume: 'bbox' },
|
||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
|
slots: () => ductBodySlots(),
|
||||||
|
paint: ductBodyPaint,
|
||||||
},
|
},
|
||||||
|
|
||||||
parametrics: ductSegmentParametrics,
|
parametrics: ductSegmentParametrics,
|
||||||
@@ -107,6 +111,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
|||||||
n.insulated,
|
n.insulated,
|
||||||
n.insulationR,
|
n.insulationR,
|
||||||
n.system,
|
n.system,
|
||||||
|
n.slots,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Open run ends as typed ports — directions point outward along the
|
// Open run ends as typed ports — directions point outward along the
|
||||||
@@ -151,6 +156,9 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
|||||||
// `endpoint-handle` per path vertex; this drags the matching point.
|
// `endpoint-handle` per path vertex; this drags the matching point.
|
||||||
floorplanAffordances: {
|
floorplanAffordances: {
|
||||||
'move-path-point': createPathPointMoveAffordance('duct-segment'),
|
'move-path-point': createPathPointMoveAffordance('duct-segment'),
|
||||||
|
// 2D twin of the 3D side-move arrows: slide a segment perpendicular to
|
||||||
|
// itself. (Length editing stays on the per-vertex hex handles.)
|
||||||
|
'move-segment': createSegmentMoveAffordance('duct-segment'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Selection-time path-point handles (drag to edit a committed run).
|
// Selection-time path-point handles (drag to edit a committed run).
|
||||||
@@ -168,7 +176,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
|||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
toolHints: [
|
toolHints: [
|
||||||
{ key: 'Click', label: 'Start segment' },
|
{ key: 'Click', label: 'Start segment' },
|
||||||
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
{ key: 'Click again', label: 'Place and continue' },
|
||||||
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||||
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||||
{ key: 'Q', label: 'Round / rect trunk' },
|
{ key: 'Q', label: 'Round / rect trunk' },
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { DuctSegmentNode } from './schema'
|
|||||||
const SUPPLY_CENTERLINE = '#d4825a'
|
const SUPPLY_CENTERLINE = '#d4825a'
|
||||||
const RETURN_CENTERLINE = '#5a8ad4'
|
const RETURN_CENTERLINE = '#5a8ad4'
|
||||||
const BODY_COLOR = '#9ca3af'
|
const BODY_COLOR = '#9ca3af'
|
||||||
|
/** Move-arrow stand-off past the duct body, in plan meters. */
|
||||||
|
const SIDE_ARROW_GAP = 0.27
|
||||||
|
/** Below this plan length a segment / end has no usable direction. */
|
||||||
|
const MIN_SEGMENT_LEN = 0.05
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floor-plan representation of a duct run: the path drawn at the duct's
|
* Floor-plan representation of a duct run: the path drawn at the duct's
|
||||||
@@ -96,6 +100,32 @@ export function buildDuctSegmentFloorplan(
|
|||||||
payload: { pointIndex: indexMap[k]! },
|
payload: { pointIndex: indexMap[k]! },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Side-move arrows: a front / back pair at each segment midpoint, sliding
|
||||||
|
// that segment perpendicular to itself. 2D twin of the 3D side-move
|
||||||
|
// arrows. The arrows stand one duct-radius + gap off the body; `angle`
|
||||||
|
// points each chevron outward along the segment normal.
|
||||||
|
const offset = diameterM / 2 + SIDE_ARROW_GAP
|
||||||
|
for (let k = 0; k < points.length - 1; k++) {
|
||||||
|
const a = points[k]!
|
||||||
|
const b = points[k + 1]!
|
||||||
|
const dx = b[0] - a[0]
|
||||||
|
const dz = b[1] - a[1]
|
||||||
|
const len = Math.hypot(dx, dz)
|
||||||
|
if (len < MIN_SEGMENT_LEN) continue
|
||||||
|
const normal: [number, number] = [-dz / len, dx / len]
|
||||||
|
const mid: FloorplanPoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||||
|
for (const side of [1, -1] as const) {
|
||||||
|
const n: [number, number] = [normal[0] * side, normal[1] * side]
|
||||||
|
children.push({
|
||||||
|
kind: 'move-arrow',
|
||||||
|
point: [mid[0] + n[0] * offset, mid[1] + n[1] * offset],
|
||||||
|
angle: Math.atan2(n[1], n[0]),
|
||||||
|
affordance: 'move-segment',
|
||||||
|
payload: { segmentIndex: indexMap[k]!, normal: n },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { kind: 'group', children }
|
return { kind: 'group', children }
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import type { GeometryContext } from '@pascal-app/core'
|
||||||
|
import {
|
||||||
|
type ColorPreset,
|
||||||
|
createSurfaceRoleMaterial,
|
||||||
|
type RenderShading,
|
||||||
|
resolveMaterialRef,
|
||||||
|
resolveSlotDefaultMaterial,
|
||||||
|
} from '@pascal-app/viewer'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
CatmullRomCurve3,
|
CatmullRomCurve3,
|
||||||
CylinderGeometry,
|
CylinderGeometry,
|
||||||
ExtrudeGeometry,
|
ExtrudeGeometry,
|
||||||
Group,
|
Group,
|
||||||
|
type Material,
|
||||||
Matrix4,
|
Matrix4,
|
||||||
Mesh,
|
Mesh,
|
||||||
MeshStandardMaterial,
|
MeshStandardMaterial,
|
||||||
@@ -13,6 +22,7 @@ import {
|
|||||||
TubeGeometry,
|
TubeGeometry,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
|
import { DUCT_BODY_SLOT_DEFAULT, DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||||
import type { DuctSegmentNode } from './schema'
|
import type { DuctSegmentNode } from './schema'
|
||||||
|
|
||||||
export const INCHES_TO_METERS = 0.0254
|
export const INCHES_TO_METERS = 0.0254
|
||||||
@@ -137,7 +147,7 @@ export function buildRectSection(
|
|||||||
end: Vector3,
|
end: Vector3,
|
||||||
widthM: number,
|
widthM: number,
|
||||||
heightM: number,
|
heightM: number,
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
name: string,
|
name: string,
|
||||||
roll = 0,
|
roll = 0,
|
||||||
): Mesh | null {
|
): Mesh | null {
|
||||||
@@ -200,7 +210,7 @@ export function buildOvalSection(
|
|||||||
end: Vector3,
|
end: Vector3,
|
||||||
widthM: number,
|
widthM: number,
|
||||||
heightM: number,
|
heightM: number,
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
name: string,
|
name: string,
|
||||||
roll = 0,
|
roll = 0,
|
||||||
): Mesh | null {
|
): Mesh | null {
|
||||||
@@ -226,7 +236,7 @@ export function buildSection(
|
|||||||
start: Vector3,
|
start: Vector3,
|
||||||
end: Vector3,
|
end: Vector3,
|
||||||
radius: number,
|
radius: number,
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
name: string,
|
name: string,
|
||||||
): Mesh | null {
|
): Mesh | null {
|
||||||
const dir = new Vector3().subVectors(end, start)
|
const dir = new Vector3().subVectors(end, start)
|
||||||
@@ -318,6 +328,7 @@ function helixRidgeFor(
|
|||||||
type DuctAppearance = {
|
type DuctAppearance = {
|
||||||
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
||||||
system: 'supply' | 'return'
|
system: 'supply' | 'return'
|
||||||
|
slots?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSystemTint(node: DuctAppearance): string {
|
function getSystemTint(node: DuctAppearance): string {
|
||||||
@@ -330,12 +341,25 @@ function getSystemTint(node: DuctAppearance): string {
|
|||||||
* metal. Shared with the fitting builder so connected runs and junctions
|
* metal. Shared with the fitting builder so connected runs and junctions
|
||||||
* look like one piece.
|
* look like one piece.
|
||||||
*/
|
*/
|
||||||
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
|
export function createDuctMaterial(
|
||||||
return new MeshStandardMaterial({
|
node: DuctAppearance,
|
||||||
color: '#ffffff',
|
sceneMaterials?: GeometryContext['materials'],
|
||||||
metalness: 0,
|
shading: RenderShading = 'rendered',
|
||||||
roughness: 0.7,
|
textures = true,
|
||||||
})
|
colorPreset: ColorPreset = 'clay',
|
||||||
|
sceneTheme?: string,
|
||||||
|
): Material {
|
||||||
|
if (!textures) {
|
||||||
|
return createSurfaceRoleMaterial('furnishing', colorPreset, undefined, sceneTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotRef = node.slots?.[DUCT_BODY_SLOT_ID]
|
||||||
|
if (slotRef) {
|
||||||
|
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
||||||
|
if (resolved) return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.7)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,7 +378,14 @@ export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial
|
|||||||
* identity since the schema has no position field — the path itself is
|
* identity since the schema has no position field — the path itself is
|
||||||
* absolute within the level).
|
* absolute within the level).
|
||||||
*/
|
*/
|
||||||
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
export function buildDuctSegmentGeometry(
|
||||||
|
node: DuctSegmentNode,
|
||||||
|
ctx?: GeometryContext,
|
||||||
|
shading: RenderShading = 'rendered',
|
||||||
|
textures = true,
|
||||||
|
colorPreset: ColorPreset = 'clay',
|
||||||
|
sceneTheme?: string,
|
||||||
|
): Group {
|
||||||
const group = new Group()
|
const group = new Group()
|
||||||
if (node.path.length < 2) return group
|
if (node.path.length < 2) return group
|
||||||
|
|
||||||
@@ -363,7 +394,14 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
|||||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||||
const widthM = node.width * INCHES_TO_METERS
|
const widthM = node.width * INCHES_TO_METERS
|
||||||
const heightM = node.height * INCHES_TO_METERS
|
const heightM = node.height * INCHES_TO_METERS
|
||||||
const ductMaterial = createDuctMaterial(node)
|
const ductMaterial = createDuctMaterial(
|
||||||
|
node,
|
||||||
|
ctx?.materials,
|
||||||
|
shading,
|
||||||
|
textures,
|
||||||
|
colorPreset,
|
||||||
|
sceneTheme,
|
||||||
|
)
|
||||||
|
|
||||||
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||||
|
|
||||||
@@ -371,9 +409,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
|||||||
half: number,
|
half: number,
|
||||||
rectW: number,
|
rectW: number,
|
||||||
rectH: number,
|
rectH: number,
|
||||||
material: MeshStandardMaterial,
|
material: Material,
|
||||||
namePrefix: string,
|
namePrefix: string,
|
||||||
endInsetM = 0,
|
endInsetM = 0,
|
||||||
|
paintableBody = false,
|
||||||
) => {
|
) => {
|
||||||
for (let i = 0; i < points.length - 1; i++) {
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
// Loop bounds + min(2) on the schema guarantee both points exist.
|
// Loop bounds + min(2) on the schema guarantee both points exist.
|
||||||
@@ -396,7 +435,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
|||||||
: isOval
|
: isOval
|
||||||
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||||
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
|
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
|
||||||
if (mesh) group.add(mesh)
|
if (mesh) {
|
||||||
|
if (paintableBody) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||||
|
group.add(mesh)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Joint caps at interior points only (skip first and last — they're
|
// Joint caps at interior points only (skip first and last — they're
|
||||||
// open ends; equipment / terminal / fitting collars cap them). Rect
|
// open ends; equipment / terminal / fitting collars cap them). Rect
|
||||||
@@ -410,11 +452,12 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
|||||||
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
||||||
joint.name = `${namePrefix}-joint-${i}`
|
joint.name = `${namePrefix}-joint-${i}`
|
||||||
joint.position.copy(points[i] as Vector3)
|
joint.position.copy(points[i] as Vector3)
|
||||||
|
if (paintableBody) joint.userData.slotId = DUCT_BODY_SLOT_ID
|
||||||
group.add(joint)
|
group.add(joint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRun(radius, widthM, heightM, ductMaterial, 'duct')
|
addRun(radius, widthM, heightM, ductMaterial, 'duct', 0, true)
|
||||||
|
|
||||||
// Construction body detail: spiral winds its lock seam, flex its wire
|
// Construction body detail: spiral winds its lock seam, flex its wire
|
||||||
// helix (tight pitch — reads as corrugation) over each round section.
|
// helix (tight pitch — reads as corrugation) over each round section.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AlignmentAnchor,
|
type AlignmentAnchor,
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
analyzePortConnectivity,
|
||||||
DuctSegmentNode,
|
DuctSegmentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
|
consumePlacementDragRelease,
|
||||||
DragBoundingBox,
|
DragBoundingBox,
|
||||||
EDITOR_LAYER,
|
EDITOR_LAYER,
|
||||||
isGridSnapActive,
|
isGridSnapActive,
|
||||||
@@ -29,6 +31,13 @@ import {
|
|||||||
collectGhostAlignmentCandidates,
|
collectGhostAlignmentCandidates,
|
||||||
resolveGhostAlignment,
|
resolveGhostAlignment,
|
||||||
} from '../shared/ghost-alignment'
|
} from '../shared/ghost-alignment'
|
||||||
|
import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost'
|
||||||
|
import { collectScenePorts, DUCT_PORT_SYSTEMS } from '../shared/ports'
|
||||||
|
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||||
|
import {
|
||||||
|
planRunTranslationOffsets,
|
||||||
|
type RunTranslationOffsetPlan,
|
||||||
|
} from '../shared/run-translation-offset'
|
||||||
import { rectSectionAxes } from './geometry'
|
import { rectSectionAxes } from './geometry'
|
||||||
|
|
||||||
type Vec3 = [number, number, number]
|
type Vec3 = [number, number, number]
|
||||||
@@ -108,6 +117,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
(node.metadata as Record<string, unknown>).isNew === true
|
(node.metadata as Record<string, unknown>).isNew === true
|
||||||
|
|
||||||
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
||||||
|
const [translationGhost, setTranslationGhost] = useState<RunTranslationOffsetPlan | null>(null)
|
||||||
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
||||||
const hasMovedRef = useRef(false)
|
const hasMovedRef = useRef(false)
|
||||||
const activatedAtRef = useRef<number>(Date.now())
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
@@ -140,6 +150,26 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
if (existedAtStart) setMeshHidden(true)
|
if (existedAtStart) setMeshHidden(true)
|
||||||
|
|
||||||
|
// Carry connected fittings (+ their other runs) as the whole run slides.
|
||||||
|
// Snapshot once at drag start; only existing runs are mated to anything.
|
||||||
|
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||||
|
? startRunMoveConnectivity(node)
|
||||||
|
: null
|
||||||
|
const portConnectivity = existedAtStart
|
||||||
|
? analyzePortConnectivity(node, useScene.getState().nodes)
|
||||||
|
: null
|
||||||
|
const scenePorts = existedAtStart
|
||||||
|
? collectScenePorts({ excludeNodeId: nodeId, systems: DUCT_PORT_SYSTEMS })
|
||||||
|
: []
|
||||||
|
const nodesById = useScene.getState().nodes
|
||||||
|
const profile = {
|
||||||
|
shape: duct.shape,
|
||||||
|
diameter: duct.diameter,
|
||||||
|
width: duct.width,
|
||||||
|
height: duct.height,
|
||||||
|
}
|
||||||
|
let lastTranslationPlan: RunTranslationOffsetPlan | null = null
|
||||||
|
|
||||||
const setPreview = (path: Vec3[]) => {
|
const setPreview = (path: Vec3[]) => {
|
||||||
previewPathRef.current = path
|
previewPathRef.current = path
|
||||||
setPreviewPath(path)
|
setPreviewPath(path)
|
||||||
@@ -179,12 +209,30 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
prevSnapRef.current = cur
|
prevSnapRef.current = cur
|
||||||
hasMovedRef.current = true
|
hasMovedRef.current = true
|
||||||
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
|
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
|
||||||
|
setPreview(nextPath)
|
||||||
|
lastTranslationPlan =
|
||||||
|
existedAtStart && portConnectivity
|
||||||
|
? planRunTranslationOffsets({
|
||||||
|
duct,
|
||||||
|
translatedPath: nextPath,
|
||||||
|
profile,
|
||||||
|
connections: portConnectivity.connections,
|
||||||
|
scenePorts,
|
||||||
|
nodesById,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (lastTranslationPlan) connectivity?.clear()
|
||||||
|
else connectivity?.preview({ path: nextPath })
|
||||||
|
setTranslationGhost(lastTranslationPlan)
|
||||||
}
|
}
|
||||||
|
|
||||||
const commit = (event: GridEvent) => {
|
const commit = (event: GridEvent, fromDragRelease = false) => {
|
||||||
if (committed) return
|
if (committed) return
|
||||||
if (Date.now() - activatedAtRef.current < 150) {
|
// The 150ms debounce only guards click-to-place against the arming click
|
||||||
|
// double-firing; a press-drag release is a distinct pointerup gesture, so
|
||||||
|
// it skips the guard (a quick drag-flick still commits).
|
||||||
|
if (!fromDragRelease && Date.now() - activatedAtRef.current < 150) {
|
||||||
event.nativeEvent?.stopPropagation?.()
|
event.nativeEvent?.stopPropagation?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -207,10 +255,44 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||||
selectId = created.id as AnyNodeId
|
selectId = created.id as AnyNodeId
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
|
const translationPlan =
|
||||||
|
portConnectivity &&
|
||||||
|
planRunTranslationOffsets({
|
||||||
|
duct,
|
||||||
|
translatedPath: finalPath,
|
||||||
|
profile,
|
||||||
|
connections: portConnectivity.connections,
|
||||||
|
scenePorts,
|
||||||
|
nodesById,
|
||||||
|
})
|
||||||
|
if (translationPlan) {
|
||||||
|
useScene.getState().applyNodeChanges({
|
||||||
|
create: [...translationPlan.fittings, ...translationPlan.connectors].map((created) => ({
|
||||||
|
node: created as AnyNode,
|
||||||
|
parentId: node.parentId as AnyNodeId,
|
||||||
|
})),
|
||||||
|
update: [
|
||||||
|
{ id: nodeId, data: { path: translationPlan.ductPath } as Partial<AnyNode> },
|
||||||
|
...translationPlan.updates,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fold connected-fitting / sibling-run follow-updates into the SAME
|
||||||
|
// batch as the moved run so the whole joint is one undo step.
|
||||||
|
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([
|
||||||
|
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
|
||||||
|
...followUpdates,
|
||||||
|
])
|
||||||
|
}
|
||||||
useScene.getState().markDirty(nodeId)
|
useScene.getState().markDirty(nodeId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
// Followers are committed to the store — drop their live overrides so
|
||||||
|
// renderers read the canonical path/position.
|
||||||
|
connectivity?.clear()
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -222,6 +304,8 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
connectivity?.clear()
|
||||||
|
setTranslationGhost(null)
|
||||||
if (existedAtStart) {
|
if (existedAtStart) {
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
@@ -233,14 +317,37 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Press-drag-release: when the move was engaged by the drag gesture (the
|
||||||
|
// selection rig's move cross), `placementDragMode` is set, so commit on
|
||||||
|
// pointer-up at the last previewed path instead of waiting for a second
|
||||||
|
// click — same contract as the fitting move tool.
|
||||||
|
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||||
|
if (!consumePlacementDragRelease(event)) return
|
||||||
|
if (!hasMovedRef.current) {
|
||||||
|
onCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
commit(
|
||||||
|
{
|
||||||
|
nativeEvent: event,
|
||||||
|
stopPropagation: () => event.stopPropagation(),
|
||||||
|
} as unknown as GridEvent,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', onMove)
|
emitter.on('grid:move', onMove)
|
||||||
emitter.on('grid:click', commit)
|
emitter.on('grid:click', commit)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onMove)
|
emitter.off('grid:move', onMove)
|
||||||
emitter.off('grid:click', commit)
|
emitter.off('grid:click', commit)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||||
|
connectivity?.clear()
|
||||||
|
setTranslationGhost(null)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
if (existedAtStart) setMeshHidden(false)
|
if (existedAtStart) setMeshHidden(false)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -263,6 +370,16 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
{segments.map((seg, i) => (
|
{segments.map((seg, i) => (
|
||||||
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
||||||
))}
|
))}
|
||||||
|
{translationGhost?.fittings.map((fitting) => (
|
||||||
|
<FittingGhost fitting={fitting} key={`translation-fitting-${fitting.id}`} tint="valid" />
|
||||||
|
))}
|
||||||
|
{translationGhost?.connectors.map((connector) => (
|
||||||
|
<DuctSegmentGhost
|
||||||
|
duct={connector}
|
||||||
|
key={`translation-connector-${connector.id}`}
|
||||||
|
tint="valid"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<DragBoundingBox
|
<DragBoundingBox
|
||||||
centerY={0}
|
centerY={0}
|
||||||
nodeId={node.id}
|
nodeId={node.id}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
|
type CeilingNode,
|
||||||
|
type DuctFittingNode,
|
||||||
DuctSegmentNode,
|
DuctSegmentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
getLevelHeight,
|
getCeilingAt,
|
||||||
sceneRegistry,
|
getCeilingHeightAt,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +24,17 @@ import {
|
|||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { type Group, Matrix4, Vector3 } from 'three'
|
import {
|
||||||
|
type BufferGeometry,
|
||||||
|
DoubleSide,
|
||||||
|
type Group,
|
||||||
|
Matrix4,
|
||||||
|
Path,
|
||||||
|
Shape,
|
||||||
|
ShapeGeometry,
|
||||||
|
Vector3,
|
||||||
|
} from 'three'
|
||||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||||
import {
|
import {
|
||||||
planCrossAtRunBody,
|
planCrossAtRunBody,
|
||||||
@@ -33,6 +44,7 @@ import {
|
|||||||
} from '../shared/auto-fitting'
|
} from '../shared/auto-fitting'
|
||||||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||||
|
import { FittingGhost } from '../shared/mep-ghost'
|
||||||
import {
|
import {
|
||||||
collectScenePorts,
|
collectScenePorts,
|
||||||
DUCT_PORT_SYSTEMS,
|
DUCT_PORT_SYSTEMS,
|
||||||
@@ -43,17 +55,17 @@ import {
|
|||||||
type ScenePort,
|
type ScenePort,
|
||||||
} from '../shared/ports'
|
} from '../shared/ports'
|
||||||
import { ductSegmentDefinition } from './definition'
|
import { ductSegmentDefinition } from './definition'
|
||||||
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
import { ductPortDiameterIn, rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-segment-at-a-time placement tool for round duct segments.
|
* Continuous placement tool for duct segments.
|
||||||
*
|
*
|
||||||
* Mouse-driven model:
|
* Mouse-driven model:
|
||||||
* - **First click** anchors the segment start (port snap joins onto an
|
* - **First click** anchors the segment start (port snap joins onto an
|
||||||
* existing run / fitting collar).
|
* existing run / fitting collar).
|
||||||
* - **Second click** commits a two-point duct immediately and re-arms
|
* - **Second click** commits a two-point duct immediately and keeps the
|
||||||
* the tool — no polyline accumulation, no finish gesture. Chain runs
|
* segment end anchored, so the next click continues the run like wall
|
||||||
* by clicking again near the end you just placed (port snap).
|
* drafting. No polyline accumulation, no finish gesture.
|
||||||
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||||||
* port at an angle (15–90°, vertical turns included), an elbow
|
* port at an angle (15–90°, vertical turns included), an elbow
|
||||||
* fitting is minted at the joint and the duct pulls back to its
|
* fitting is minted at the joint and the duct pulls back to its
|
||||||
@@ -73,9 +85,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
|||||||
* vertical mouse motion drives Y. Click commits the riser segment.
|
* vertical mouse motion drives Y. Click commits the riser segment.
|
||||||
* - **[ / ]** step the duct diameter through nominal US sizes; the
|
* - **[ / ]** step the duct diameter through nominal US sizes; the
|
||||||
* ghost preview and the committed node both use it.
|
* ghost preview and the committed node both use it.
|
||||||
* - **C** toggles ceiling-level placement: the start point lands at
|
* - **C** toggles ceiling-level placement: each point lands just below
|
||||||
* the level's ceiling height (duct top hugging the ceiling) instead
|
* the ceiling actually covering it (duct top hugging that ceiling)
|
||||||
* of the floor. Subsequent points inherit the start's Y as usual.
|
* instead of the floor, so a run tracks per-room ceiling heights.
|
||||||
|
* Points not under any ceiling fall back to the floor.
|
||||||
* - Esc clears an anchored start point.
|
* - Esc clears an anchored start point.
|
||||||
*/
|
*/
|
||||||
const PREVIEW_OPACITY = 0.55
|
const PREVIEW_OPACITY = 0.55
|
||||||
@@ -98,6 +111,11 @@ const ALT_PIXELS_PER_METER = 100
|
|||||||
const ALT_Y_MIN_M = -3
|
const ALT_Y_MIN_M = -3
|
||||||
const ALT_Y_MAX_M = 10
|
const ALT_Y_MAX_M = 10
|
||||||
|
|
||||||
|
/** green-500 — the project's bounding-box / placeable accent. The cursor
|
||||||
|
* ring + vertical line recolour to this while the point is snapped onto an
|
||||||
|
* existing run, so the coincidence reads with the familiar snap green. */
|
||||||
|
const SNAP_CURSOR_COLOR = '#22c55e'
|
||||||
|
|
||||||
function snap(value: number, step: number): number {
|
function snap(value: number, step: number): number {
|
||||||
if (step <= 0) return value
|
if (step <= 0) return value
|
||||||
return Math.round(value / step) * step
|
return Math.round(value / step) * step
|
||||||
@@ -167,6 +185,14 @@ function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | n
|
|||||||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function continuityRollForRun(
|
||||||
|
startPort: ScenePort | null,
|
||||||
|
endPort: ScenePort | null,
|
||||||
|
dir: Vector3,
|
||||||
|
): number {
|
||||||
|
return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nearest typed port — duct run ends, fitting collars, anything whose
|
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||||||
* kind registers `def.ports` — within snap range of `point` on the XZ
|
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||||||
@@ -263,6 +289,209 @@ function projectToAngleLock(
|
|||||||
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The full set of nodes a drawn segment produces. The drawn `ducts`
|
||||||
|
* (and any trunk `tails` from a tee / cross split) are previewed by the
|
||||||
|
* duct ghost already; `fittings` are the auto-inserted elbow / tee /
|
||||||
|
* cross nodes the ghost preview draws so the user sees them before the
|
||||||
|
* commit. Shared by `commitSegment` and the live preview so what you see
|
||||||
|
* is exactly what lands. */
|
||||||
|
type DuctDrawPlan = {
|
||||||
|
fittings: DuctFittingNode[]
|
||||||
|
ducts: DuctSegmentNode[]
|
||||||
|
tails: DuctSegmentNode[]
|
||||||
|
updates: { id: AnyNode['id']; data: Partial<AnyNode> }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const elbowPlanFor = (
|
||||||
|
port: ScenePort | null,
|
||||||
|
awayDir: [number, number, number],
|
||||||
|
profile: DraftProfile,
|
||||||
|
) => {
|
||||||
|
if (!port) return null
|
||||||
|
const owner = useScene.getState().nodes[port.nodeId]
|
||||||
|
if (owner?.type !== 'duct-segment') return null
|
||||||
|
const plan = planElbowAtPort(port, awayDir, profile)
|
||||||
|
if (!plan) return null
|
||||||
|
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||||
|
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||||
|
const index = port.id === 'start' ? 0 : path.length - 1
|
||||||
|
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||||
|
const remaining = Math.hypot(
|
||||||
|
plan.trimmedPortPoint[0] - neighbor[0],
|
||||||
|
plan.trimmedPortPoint[1] - neighbor[1],
|
||||||
|
plan.trimmedPortPoint[2] - neighbor[2],
|
||||||
|
)
|
||||||
|
// The trim must leave a real piece of the existing run AND not flip it.
|
||||||
|
const original = path[index]!
|
||||||
|
const originalLen = Math.hypot(
|
||||||
|
original[0] - neighbor[0],
|
||||||
|
original[1] - neighbor[1],
|
||||||
|
original[2] - neighbor[2],
|
||||||
|
)
|
||||||
|
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||||
|
path[index] = plan.trimmedPortPoint
|
||||||
|
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||||
|
if (!port) return null
|
||||||
|
const owner = useScene.getState().nodes[port.nodeId]
|
||||||
|
if (owner?.type !== 'duct-fitting') return null
|
||||||
|
return planElbowRealign(owner, port.id, awayDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure planner for a drawn duct segment: given its endpoints and what
|
||||||
|
* each end snapped onto (an open port, or a run body for a tee / cross
|
||||||
|
* tap), decide every node the commit creates / updates — auto-inserted
|
||||||
|
* elbows / tees / crosses, the drawn run (split in two when it crosses a
|
||||||
|
* trunk), trunk tails, and trim / realign updates. Reads the live scene
|
||||||
|
* graph but mutates nothing, so the live preview can call it each frame
|
||||||
|
* to ghost the fittings before the commit applies the identical plan.
|
||||||
|
*/
|
||||||
|
function planDuctDraw(
|
||||||
|
start: [number, number, number],
|
||||||
|
end: [number, number, number],
|
||||||
|
startPort: ScenePort | null,
|
||||||
|
startBody: RunBodyHit | null,
|
||||||
|
endPort: ScenePort | null,
|
||||||
|
endBody: RunBodyHit | null,
|
||||||
|
profile: DraftProfile,
|
||||||
|
): DuctDrawPlan | null {
|
||||||
|
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||||
|
if (length < 1e-4) return null
|
||||||
|
const dir: [number, number, number] = [
|
||||||
|
(end[0] - start[0]) / length,
|
||||||
|
(end[1] - start[1]) / length,
|
||||||
|
(end[2] - start[2]) / length,
|
||||||
|
]
|
||||||
|
|
||||||
|
const startPlan = elbowPlanFor(startPort, dir, profile)
|
||||||
|
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile)
|
||||||
|
const startRealign = startPlan ? null : realignPlanFor(startPort, dir)
|
||||||
|
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||||
|
const trunkBody = startPlan ? null : startBody
|
||||||
|
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||||
|
const teePlan =
|
||||||
|
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||||
|
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile)
|
||||||
|
: null
|
||||||
|
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||||
|
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||||
|
const endTeePlan =
|
||||||
|
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||||
|
? planTeeAtRunBody(endTrunkOwner, endTrunkBody, [-dir[0], -dir[1], -dir[2]], profile)
|
||||||
|
: null
|
||||||
|
let ductStart =
|
||||||
|
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||||
|
let ductEnd = endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||||
|
const remaining = Math.hypot(
|
||||||
|
ductEnd[0] - ductStart[0],
|
||||||
|
ductEnd[1] - ductStart[1],
|
||||||
|
ductEnd[2] - ductStart[2],
|
||||||
|
)
|
||||||
|
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||||
|
let tee = teePlan
|
||||||
|
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||||
|
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||||
|
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||||
|
|
||||||
|
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||||
|
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||||
|
const crossTappedElsewhere =
|
||||||
|
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||||
|
let cross =
|
||||||
|
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||||
|
? planCrossAtRunBody(crossOwner, crossHit, dir, profile)
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (remaining <= 0.08) {
|
||||||
|
plans = []
|
||||||
|
tee = null
|
||||||
|
endTee = null
|
||||||
|
realigns = []
|
||||||
|
cross = null
|
||||||
|
ductStart = start
|
||||||
|
ductEnd = end
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rect / oval continuity: roll the new run's cross-section so its
|
||||||
|
// profile stays continuous with whatever either end joined.
|
||||||
|
let roll = 0
|
||||||
|
if (profile.shape !== 'round') {
|
||||||
|
const newDir = new Vector3(...dir)
|
||||||
|
roll = continuityRollForRun(startPort, endPort, newDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults = ductSegmentDefinition.defaults()
|
||||||
|
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||||
|
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||||
|
DuctSegmentNode.parse({
|
||||||
|
...defaults,
|
||||||
|
...toolDefaults,
|
||||||
|
name: profile.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||||
|
path: [from, to],
|
||||||
|
shape: profile.shape,
|
||||||
|
diameter: profile.diameter,
|
||||||
|
width: profile.width,
|
||||||
|
height: profile.height,
|
||||||
|
roll,
|
||||||
|
})
|
||||||
|
const ducts = cross
|
||||||
|
? [
|
||||||
|
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||||
|
? makeDuct(ductStart, cross.branchCollarNear)
|
||||||
|
: null,
|
||||||
|
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||||
|
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||||
|
: null,
|
||||||
|
].filter((d) => d !== null)
|
||||||
|
: [makeDuct(ductStart, ductEnd)]
|
||||||
|
|
||||||
|
const fittings: DuctFittingNode[] = [
|
||||||
|
...plans.map((p) => p.fitting),
|
||||||
|
...(tee ? [tee.fitting] : []),
|
||||||
|
...(endTee ? [endTee.fitting] : []),
|
||||||
|
...(cross ? [cross.fitting] : []),
|
||||||
|
]
|
||||||
|
const tails: DuctSegmentNode[] = [
|
||||||
|
...(tee ? [tee.trunkTail] : []),
|
||||||
|
...(endTee ? [endTee.trunkTail] : []),
|
||||||
|
...(cross ? [cross.trunkTail] : []),
|
||||||
|
]
|
||||||
|
const updates: { id: AnyNode['id']; data: Partial<AnyNode> }[] = [
|
||||||
|
...plans.map((p) => p.trim),
|
||||||
|
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||||
|
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||||
|
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||||
|
...realigns.map((p) => p.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||||
|
]
|
||||||
|
|
||||||
|
return { fittings, ducts, tails, updates }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ductEndPort(duct: DuctSegmentNode, id: 'start' | 'end'): ScenePort | null {
|
||||||
|
if (duct.path.length < 2) return null
|
||||||
|
const index = id === 'start' ? 0 : duct.path.length - 1
|
||||||
|
const neighborIndex = id === 'start' ? 1 : duct.path.length - 2
|
||||||
|
const position = duct.path[index]!
|
||||||
|
const neighbor = duct.path[neighborIndex]!
|
||||||
|
const dx = position[0] - neighbor[0]
|
||||||
|
const dy = position[1] - neighbor[1]
|
||||||
|
const dz = position[2] - neighbor[2]
|
||||||
|
const len = Math.hypot(dx, dy, dz)
|
||||||
|
const direction: [number, number, number] =
|
||||||
|
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
nodeId: duct.id,
|
||||||
|
position,
|
||||||
|
direction,
|
||||||
|
diameter: ductPortDiameterIn(duct),
|
||||||
|
system: duct.system,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const DuctSegmentTool = () => {
|
const DuctSegmentTool = () => {
|
||||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||||
const unit = useViewer((s) => s.unit)
|
const unit = useViewer((s) => s.unit)
|
||||||
@@ -289,12 +518,24 @@ const DuctSegmentTool = () => {
|
|||||||
// Ceiling mode (toggle with C): the first point lands at the level's
|
// Ceiling mode (toggle with C): the first point lands at the level's
|
||||||
// ceiling height (duct top hugging the ceiling) instead of the floor.
|
// ceiling height (duct top hugging the ceiling) instead of the floor.
|
||||||
const [ceilingMode, setCeilingMode] = useState(false)
|
const [ceilingMode, setCeilingMode] = useState(false)
|
||||||
// When the cursor is within snap range of an existing duct's endpoint we
|
// The shared coordinate when the cursor is within snap range of an existing
|
||||||
// surface a brighter indicator and commit at the endpoint's exact coords.
|
// duct (null = free placement). Drives the green cursor highlight so the
|
||||||
|
// user sees the next click will join an existing run, not freeform-place.
|
||||||
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
|
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
|
||||||
|
// In ceiling mode, the ceiling the cursor is currently under — rendered as
|
||||||
|
// a translucent overlay so the duct reads as hung against a real surface
|
||||||
|
// rather than a dot floating in space. Null when off-ceiling.
|
||||||
|
const [hoverCeiling, setHoverCeiling] = useState<CeilingNode | null>(null)
|
||||||
// True while Alt is held with a last point on the draft — drives the
|
// True while Alt is held with a last point on the draft — drives the
|
||||||
// vertical-cylinder ghost and the cursor HUD label.
|
// vertical-cylinder ghost and the cursor HUD label.
|
||||||
const [altActive, setAltActive] = useState(false)
|
const [altActive, setAltActive] = useState(false)
|
||||||
|
// What the in-flight cursor end currently snaps onto (port end, or a
|
||||||
|
// run body for a tee / cross tap). Drives the auto-fitting GHOST so the
|
||||||
|
// user sees the elbow / tee / cross the next click will mint.
|
||||||
|
const [endSnap, setEndSnap] = useState<{ port: ScenePort | null; body: RunBodyHit | null }>({
|
||||||
|
port: null,
|
||||||
|
body: null,
|
||||||
|
})
|
||||||
// Mirror into refs so emitter callbacks (closing over the first render's
|
// Mirror into refs so emitter callbacks (closing over the first render's
|
||||||
// setState) read the latest values without re-subscribing.
|
// setState) read the latest values without re-subscribing.
|
||||||
const draftRef = useRef(draftPoints)
|
const draftRef = useRef(draftPoints)
|
||||||
@@ -321,246 +562,63 @@ const DuctSegmentTool = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLevelId) return
|
if (!activeLevelId) return
|
||||||
|
|
||||||
/**
|
// Continuous chain: first click anchors the start, each following
|
||||||
* Auto-elbow gate: only joints onto another RUN's open end get a
|
// click commits one two-point duct and uses that duct's far end as
|
||||||
* fitting minted. Ports on fittings / equipment / terminals are
|
// the next anchor. No selection switch or finish gesture.
|
||||||
* already proper connections — a duct mates straight onto those.
|
|
||||||
*
|
|
||||||
* The elbow's junction sits ON the drawn corner, so the existing run
|
|
||||||
* must trim back one leg to make room (`trim` update). Plans that
|
|
||||||
* would trim the run to (or past) nothing are dropped — that corner
|
|
||||||
* stays a plain butt joint. Guards against the snapped node having
|
|
||||||
* been deleted between clicks.
|
|
||||||
*/
|
|
||||||
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
|
||||||
if (!port) return null
|
|
||||||
const owner = useScene.getState().nodes[port.nodeId]
|
|
||||||
if (owner?.type !== 'duct-segment') return null
|
|
||||||
const plan = planElbowAtPort(port, awayDir, profileRef.current)
|
|
||||||
if (!plan) return null
|
|
||||||
|
|
||||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
|
||||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
|
||||||
const index = port.id === 'start' ? 0 : path.length - 1
|
|
||||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
|
||||||
const remaining = Math.hypot(
|
|
||||||
plan.trimmedPortPoint[0] - neighbor[0],
|
|
||||||
plan.trimmedPortPoint[1] - neighbor[1],
|
|
||||||
plan.trimmedPortPoint[2] - neighbor[2],
|
|
||||||
)
|
|
||||||
// The trim must leave a real piece of the existing run AND not flip
|
|
||||||
// it (trimmed point past the neighbor) — otherwise skip the fitting.
|
|
||||||
const original = path[index]!
|
|
||||||
const originalLen = Math.hypot(
|
|
||||||
original[0] - neighbor[0],
|
|
||||||
original[1] - neighbor[1],
|
|
||||||
original[2] - neighbor[2],
|
|
||||||
)
|
|
||||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
|
||||||
path[index] = plan.trimmedPortPoint
|
|
||||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Realign gate: the snapped port belongs to an existing ELBOW's open
|
|
||||||
* collar — re-aim that elbow (junction + mated collar fixed, free
|
|
||||||
* collar swings to the drawn direction). Null when the owner isn't
|
|
||||||
* an elbow or the required turn leaves the 15–90° range.
|
|
||||||
*/
|
|
||||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
|
||||||
if (!port) return null
|
|
||||||
const owner = useScene.getState().nodes[port.nodeId]
|
|
||||||
if (owner?.type !== 'duct-fitting') return null
|
|
||||||
return planElbowRealign(owner, port.id, awayDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// One segment per gesture: first click anchors the start, second
|
|
||||||
// click commits a two-point duct immediately. No selection switch —
|
|
||||||
// the tool stays armed so the next click starts the next segment
|
|
||||||
// (port snap joins it onto the end just committed).
|
|
||||||
//
|
//
|
||||||
// When an end of the segment snapped onto another run's open port at
|
// All the auto-fitting decisions (elbow / tee / cross) live in the
|
||||||
// an angle, an elbow fitting is minted at that joint and the duct is
|
// shared `planDuctDraw` so the live ghost previews exactly what this
|
||||||
// pulled back to the elbow's outlet collar — corners get real
|
// commit applies.
|
||||||
// fittings instead of butt joints.
|
|
||||||
const commitSegment = (
|
const commitSegment = (
|
||||||
start: [number, number, number],
|
start: [number, number, number],
|
||||||
end: [number, number, number],
|
end: [number, number, number],
|
||||||
endPort: ScenePort | null = null,
|
endPort: ScenePort | null = null,
|
||||||
endBody: RunBodyHit | null = null,
|
endBody: RunBodyHit | null = null,
|
||||||
) => {
|
) => {
|
||||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
const plan = planDuctDraw(
|
||||||
if (length < 1e-4) return
|
start,
|
||||||
const dir: [number, number, number] = [
|
end,
|
||||||
(end[0] - start[0]) / length,
|
startPortRef.current,
|
||||||
(end[1] - start[1]) / length,
|
startBodyRef.current,
|
||||||
(end[2] - start[2]) / length,
|
endPort,
|
||||||
]
|
endBody,
|
||||||
|
|
||||||
const startPlan = elbowPlanFor(startPortRef.current, dir)
|
|
||||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
|
||||||
// Existing-fitting joints: re-aim the elbow whose collar was hit so
|
|
||||||
// it faces the drawn run instead of leaving a mismatched butt joint.
|
|
||||||
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
|
|
||||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
|
||||||
// Tee tap: the start snapped onto a run's BODY (not an end port) —
|
|
||||||
// split the trunk and branch from the tee's collar.
|
|
||||||
const trunkBody = startPlan ? null : startBodyRef.current
|
|
||||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
|
||||||
const teePlan =
|
|
||||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
|
||||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
|
|
||||||
: null
|
|
||||||
// End tee tap: the END landed on a run's BODY — split that trunk and
|
|
||||||
// the new duct ends at the tee's branch collar. The branch leaves
|
|
||||||
// toward the drawn run (back along -dir, since dir points start→end).
|
|
||||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
|
||||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
|
||||||
const endTeePlan =
|
|
||||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
|
||||||
? planTeeAtRunBody(
|
|
||||||
endTrunkOwner,
|
|
||||||
endTrunkBody,
|
|
||||||
[-dir[0], -dir[1], -dir[2]],
|
|
||||||
profileRef.current,
|
profileRef.current,
|
||||||
)
|
)
|
||||||
: null
|
if (!plan) return
|
||||||
let ductStart =
|
|
||||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
|
||||||
let ductEnd =
|
|
||||||
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
|
||||||
// The collar pull-back must leave a real piece of duct between the
|
|
||||||
// fittings; if not, fall back to the plain joint.
|
|
||||||
const remaining = Math.hypot(
|
|
||||||
ductEnd[0] - ductStart[0],
|
|
||||||
ductEnd[1] - ductStart[1],
|
|
||||||
ductEnd[2] - ductStart[2],
|
|
||||||
)
|
|
||||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
|
||||||
let tee = teePlan
|
|
||||||
// Both ends tapping the SAME trunk would split one polyline twice in
|
|
||||||
// a single change (conflicting updates + double tail) — drop the end
|
|
||||||
// tee in that rare case and let the end butt-join instead.
|
|
||||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
|
||||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
|
||||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
|
||||||
|
|
||||||
// Cross tap: the drawn run passes straight THROUGH a trunk's body
|
|
||||||
// (interior crossing, not an end touch). Split that trunk and the
|
|
||||||
// drawn duct into two halves meeting the cross's opposed branch
|
|
||||||
// collars. Skip a run already tapped by a start / end tee so one
|
|
||||||
// polyline isn't split twice in a single change.
|
|
||||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
|
||||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
|
||||||
const crossTappedElsewhere =
|
|
||||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
|
||||||
let cross =
|
|
||||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
|
||||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
|
|
||||||
: null
|
|
||||||
|
|
||||||
if (remaining <= 0.08) {
|
|
||||||
plans = []
|
|
||||||
tee = null
|
|
||||||
endTee = null
|
|
||||||
realigns = []
|
|
||||||
cross = null
|
|
||||||
ductStart = start
|
|
||||||
ductEnd = end
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rect / oval continuity: roll the new run's cross-section so its
|
|
||||||
// profile stays continuous with whatever either end joined — run
|
|
||||||
// end or fitting collar, turn or straight continuation (see
|
|
||||||
// `continuityRollFrom`). The start joint wins if both ends join.
|
|
||||||
let roll = 0
|
|
||||||
if (profileRef.current.shape !== 'round') {
|
|
||||||
const newDir = new Vector3(...dir)
|
|
||||||
roll =
|
|
||||||
continuityRollFrom(startPortRef.current, newDir) ??
|
|
||||||
continuityRollFrom(endPort, newDir) ??
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = ductSegmentDefinition.defaults()
|
|
||||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
|
||||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
|
||||||
DuctSegmentNode.parse({
|
|
||||||
...defaults,
|
|
||||||
...toolDefaults,
|
|
||||||
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
|
|
||||||
path: [from, to],
|
|
||||||
shape: profileRef.current.shape,
|
|
||||||
diameter: profileRef.current.diameter,
|
|
||||||
width: profileRef.current.width,
|
|
||||||
height: profileRef.current.height,
|
|
||||||
roll,
|
|
||||||
})
|
|
||||||
// A cross splits the drawn run into two halves that meet its opposed
|
|
||||||
// branch collars; otherwise it's one duct end-to-end. Degenerate
|
|
||||||
// halves (the crossing too near an end) are dropped.
|
|
||||||
const ducts = cross
|
|
||||||
? [
|
|
||||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
|
||||||
? makeDuct(ductStart, cross.branchCollarNear)
|
|
||||||
: null,
|
|
||||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
|
||||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
|
||||||
: null,
|
|
||||||
].filter((d) => d !== null)
|
|
||||||
: [makeDuct(ductStart, ductEnd)]
|
|
||||||
// One atomic change: trim / split the joined runs, create the
|
// One atomic change: trim / split the joined runs, create the
|
||||||
// fittings + the new duct. Single undo step.
|
// fittings + the new duct. Single undo step.
|
||||||
useScene.getState().applyNodeChanges({
|
useScene.getState().applyNodeChanges({
|
||||||
create: [
|
create: [
|
||||||
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
|
...plan.fittings.map((node) => ({ node, parentId: activeLevelId })),
|
||||||
...(tee
|
...plan.tails.map((node) => ({ node, parentId: activeLevelId })),
|
||||||
? [
|
...plan.ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||||
{ node: tee.fitting, parentId: activeLevelId },
|
|
||||||
{ node: tee.trunkTail, parentId: activeLevelId },
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(endTee
|
|
||||||
? [
|
|
||||||
{ node: endTee.fitting, parentId: activeLevelId },
|
|
||||||
{ node: endTee.trunkTail, parentId: activeLevelId },
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(cross
|
|
||||||
? [
|
|
||||||
{ node: cross.fitting, parentId: activeLevelId },
|
|
||||||
{ node: cross.trunkTail, parentId: activeLevelId },
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...ducts.map((node) => ({ node, parentId: activeLevelId })),
|
|
||||||
],
|
|
||||||
update: [
|
|
||||||
...plans.map((plan) => plan.trim),
|
|
||||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
|
||||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
|
||||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
|
||||||
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
|
||||||
],
|
],
|
||||||
|
update: plan.updates,
|
||||||
})
|
})
|
||||||
|
const nextDuct = plan.ducts.at(-1)
|
||||||
|
const nextStart = nextDuct ? nextDuct.path[nextDuct.path.length - 1]! : end
|
||||||
|
const nextPort = nextDuct ? ductEndPort(nextDuct, 'end') : endPort
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
setDraftPoints([])
|
setDraftPoints([nextStart])
|
||||||
setSnapTarget(null)
|
setSnapTarget(null)
|
||||||
startPortRef.current = null
|
setEndSnap({ port: null, body: null })
|
||||||
startBodyRef.current = null
|
startPortRef.current = nextPort
|
||||||
|
startBodyRef.current = nextPort ? null : endBody
|
||||||
altAnchorRef.current = null
|
altAnchorRef.current = null
|
||||||
setAltActive(false)
|
setAltActive(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base Y for a fresh run's first point: floor (0) by default, or just
|
// Y for a point at level-local `[x, z]`. Floor (0) when ceiling mode is
|
||||||
// below the level's ceiling in ceiling mode so the duct's top hugs the
|
// off. In ceiling mode, query the ceiling actually covering that point
|
||||||
// ceiling (centerline = ceiling height − radius).
|
// and hang the duct just below it (centerline = ceiling underside −
|
||||||
const resolveBaseY = (): number => {
|
// half the duct's vertical dimension) so its top hugs the ceiling. Each
|
||||||
|
// point follows its own ceiling, so a run stepping into a room with a
|
||||||
|
// different ceiling height tracks that change. Points not under any
|
||||||
|
// ceiling fall back to the floor.
|
||||||
|
const resolveCeilingY = (x: number, z: number): number => {
|
||||||
if (!ceilingModeRef.current) return 0
|
if (!ceilingModeRef.current) return 0
|
||||||
const ceiling = getLevelHeight(
|
const ceiling = getCeilingHeightAt(activeLevelId, useScene.getState().nodes, x, z)
|
||||||
activeLevelId,
|
if (ceiling === null) return 0
|
||||||
useScene.getState().nodes,
|
|
||||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
|
||||||
)
|
|
||||||
const p = profileRef.current
|
const p = profileRef.current
|
||||||
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
||||||
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
||||||
@@ -578,11 +636,11 @@ const DuctSegmentTool = () => {
|
|||||||
// every snapping mode except `off` (the raw-cursor bypass).
|
// every snapping mode except `off` (the raw-cursor bypass).
|
||||||
const snapEnabled = isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()
|
const snapEnabled = isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()
|
||||||
const last = draftRef.current.at(-1)
|
const last = draftRef.current.at(-1)
|
||||||
// First point of the run: grid-snapped placement at the base Y (floor,
|
// First point of the run: grid-snapped placement. Y follows the
|
||||||
// or ceiling height in ceiling mode). Endpoint snap can still join an
|
// ceiling under the cursor in ceiling mode (floor otherwise).
|
||||||
// existing run.
|
// Endpoint snap can still join an existing run.
|
||||||
if (!last) {
|
if (!last) {
|
||||||
const baseY = resolveBaseY()
|
const baseY = resolveCeilingY(event.localPosition[0], event.localPosition[2])
|
||||||
const raw: [number, number, number] = [
|
const raw: [number, number, number] = [
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
baseY,
|
baseY,
|
||||||
@@ -605,15 +663,20 @@ const DuctSegmentTool = () => {
|
|||||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||||
}
|
}
|
||||||
|
const sx = snap(raw[0], step)
|
||||||
|
const sz = snap(raw[2], step)
|
||||||
return {
|
return {
|
||||||
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
|
point: [sx, resolveCeilingY(sx, sz), sz],
|
||||||
snapped: null,
|
snapped: null,
|
||||||
port: null,
|
port: null,
|
||||||
body: null,
|
body: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Subsequent points: angle-locked to 45° from `last` in `angles` mode.
|
// Subsequent points: angle-locked to 45° from `last` in `angles` mode.
|
||||||
// Y stays at `last[1]` — depth changes come from Alt-vertical risers.
|
// Y inherits `last[1]` for the angle/probe math; the free placement below
|
||||||
|
// re-resolves it from the ceiling under the point in ceiling mode, so a run
|
||||||
|
// stepping into a room with a different ceiling height tracks that change.
|
||||||
|
// Depth changes otherwise come from Alt-vertical risers.
|
||||||
const rawXZ: [number, number, number] = [
|
const rawXZ: [number, number, number] = [
|
||||||
event.localPosition[0],
|
event.localPosition[0],
|
||||||
last[1],
|
last[1],
|
||||||
@@ -643,8 +706,11 @@ const DuctSegmentTool = () => {
|
|||||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||||
}
|
}
|
||||||
|
const fx = snap(angled[0], step)
|
||||||
|
const fz = snap(angled[2], step)
|
||||||
|
const fy = ceilingModeRef.current ? resolveCeilingY(fx, fz) : angled[1]
|
||||||
return {
|
return {
|
||||||
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
|
point: [fx, fy, fz],
|
||||||
snapped: null,
|
snapped: null,
|
||||||
port: null,
|
port: null,
|
||||||
body: null,
|
body: null,
|
||||||
@@ -685,6 +751,17 @@ const DuctSegmentTool = () => {
|
|||||||
return { ...r, point }
|
return { ...r, point }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ceiling the cursor is under (ceiling mode only) — drives the
|
||||||
|
// translucent surface overlay so the in-flight point reads as hung
|
||||||
|
// against a real ceiling. Cleared when off-ceiling or out of mode.
|
||||||
|
const updateHoverCeiling = (x: number, z: number) => {
|
||||||
|
if (!ceilingModeRef.current) {
|
||||||
|
setHoverCeiling(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setHoverCeiling(getCeilingAt(activeLevelId, useScene.getState().nodes, x, z))
|
||||||
|
}
|
||||||
|
|
||||||
const onMove = (event: GridEvent) => {
|
const onMove = (event: GridEvent) => {
|
||||||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||||||
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
||||||
@@ -695,12 +772,16 @@ const DuctSegmentTool = () => {
|
|||||||
clearDrawAlignment()
|
clearDrawAlignment()
|
||||||
setCursorPos(point)
|
setCursorPos(point)
|
||||||
setSnapTarget(null)
|
setSnapTarget(null)
|
||||||
|
setEndSnap({ port: null, body: null })
|
||||||
|
updateHoverCeiling(point[0], point[2])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { point, snapped } = resolveAlignedPoint(event)
|
const { point, snapped, port, body } = resolveAlignedPoint(event)
|
||||||
setCursorPos(point)
|
setCursorPos(point)
|
||||||
setSnapTarget(snapped)
|
setSnapTarget(snapped)
|
||||||
|
setEndSnap({ port, body: port ? null : body })
|
||||||
|
updateHoverCeiling(point[0], point[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClick = (event: GridEvent) => {
|
const onClick = (event: GridEvent) => {
|
||||||
@@ -787,12 +868,14 @@ const DuctSegmentTool = () => {
|
|||||||
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
|
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
} else if (e.key === 'c' || e.key === 'C') {
|
} else if (e.key === 'c' || e.key === 'C') {
|
||||||
// Toggle ceiling mode. Only the first point reads the base Y, so
|
// Toggle ceiling mode: points hang from the ceiling above them
|
||||||
// toggling mid-run is a no-op until the next fresh segment — flip
|
// (duct top hugging the ceiling) instead of sitting on the floor.
|
||||||
// it only while unanchored to keep the behaviour predictable.
|
// Only flip while unanchored — already-placed points keep their Y,
|
||||||
|
// so a mid-run toggle would split a run across two height regimes.
|
||||||
if (draftRef.current.length > 0) return
|
if (draftRef.current.length > 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setCeilingMode((m) => !m)
|
setCeilingMode((m) => !m)
|
||||||
|
setHoverCeiling(null)
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -811,6 +894,8 @@ const DuctSegmentTool = () => {
|
|||||||
setDraftPoints([])
|
setDraftPoints([])
|
||||||
setCursorPos(null)
|
setCursorPos(null)
|
||||||
setSnapTarget(null)
|
setSnapTarget(null)
|
||||||
|
setEndSnap({ port: null, body: null })
|
||||||
|
setHoverCeiling(null)
|
||||||
startPortRef.current = null
|
startPortRef.current = null
|
||||||
startBodyRef.current = null
|
startBodyRef.current = null
|
||||||
}
|
}
|
||||||
@@ -842,6 +927,22 @@ const DuctSegmentTool = () => {
|
|||||||
previewSegments.push({ a: last, b: cursorPos })
|
previewSegments.push({ a: last, b: cursorPos })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
|
||||||
|
// will mint, by running the SAME planner the commit uses against the
|
||||||
|
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
|
||||||
|
const ghostFittings =
|
||||||
|
last && cursorPos && !altActive
|
||||||
|
? (planDuctDraw(
|
||||||
|
last,
|
||||||
|
cursorPos,
|
||||||
|
startPortRef.current,
|
||||||
|
startBodyRef.current,
|
||||||
|
endSnap.port,
|
||||||
|
endSnap.body,
|
||||||
|
profile,
|
||||||
|
)?.fittings ?? [])
|
||||||
|
: []
|
||||||
|
|
||||||
// Wall-style dimension pill above the cursor: absolute world coords before
|
// Wall-style dimension pill above the cursor: absolute world coords before
|
||||||
// the first point, signed per-axis deltas from the last placed point while
|
// the first point, signed per-axis deltas from the last placed point while
|
||||||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||||||
@@ -872,15 +973,50 @@ const DuctSegmentTool = () => {
|
|||||||
: 'z'
|
: 'z'
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// When the in-flight point hangs above the floor (ceiling mode, or an
|
||||||
|
// Alt riser), the cursor marker itself rides AT the point (where the
|
||||||
|
// mouse is aiming and the next click commits), and a plumb line drops
|
||||||
|
// straight down to a faint ground ring on the floor below — so the plan
|
||||||
|
// position stays legible from any angle. A floor-level point keeps the
|
||||||
|
// standard fixed-height cursor look.
|
||||||
|
const cursorElevation = cursorPos ? cursorPos[1] : 0
|
||||||
|
const isElevated = cursorElevation > 0.001
|
||||||
|
const cursorGround: [number, number, number] | null = cursorPos
|
||||||
|
? [cursorPos[0], 0, cursorPos[2]]
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LevelOffsetGroup>
|
<LevelOffsetGroup>
|
||||||
|
{/* Ceiling-mode surface highlight — the ceiling the cursor is under,
|
||||||
|
tinted at its own elevation so the duct reads as hung against a
|
||||||
|
real surface instead of a point floating in space. */}
|
||||||
|
{ceilingMode && hoverCeiling && <CeilingHighlight ceiling={hoverCeiling} />}
|
||||||
{/* Cursor marker — the same ground ring + vertical line + tool-icon
|
{/* Cursor marker — the same ground ring + vertical line + tool-icon
|
||||||
badge walls and items show while drawing (icon resolved from the
|
badge walls and items show while drawing (icon resolved from the
|
||||||
active `duct-segment` structure-tools entry). The dimension pill
|
active `duct-segment` structure-tools entry). The dimension pill
|
||||||
rides just above the cursor. */}
|
rides just above the cursor. */}
|
||||||
{cursorPos && (
|
{cursorPos && cursorGround && (
|
||||||
<>
|
<>
|
||||||
<CursorSphere position={cursorPos} ref={cursorRef} />
|
{/* In ceiling mode (or any elevated point) the ground ring sits on
|
||||||
|
the floor below the cursor and the line rises to the placement
|
||||||
|
point, with the bright dot + tool badge at its tip — exactly
|
||||||
|
where the next click commits. At floor level it's the standard
|
||||||
|
fixed-height cursor. */}
|
||||||
|
{isElevated ? (
|
||||||
|
<CursorSphere
|
||||||
|
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||||
|
dotAtTip
|
||||||
|
height={cursorElevation}
|
||||||
|
position={cursorGround}
|
||||||
|
ref={cursorRef}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CursorSphere
|
||||||
|
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||||
|
position={cursorPos}
|
||||||
|
ref={cursorRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pillParts && (
|
{pillParts && (
|
||||||
<group position={cursorPos}>
|
<group position={cursorPos}>
|
||||||
<Html
|
<Html
|
||||||
@@ -889,28 +1025,19 @@ const DuctSegmentTool = () => {
|
|||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
zIndexRange={[100, 0]}
|
zIndexRange={[100, 0]}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
|
||||||
{ceilingMode && !last && (
|
{ceilingMode && !last && (
|
||||||
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
||||||
Ceiling · C to toggle
|
Ceiling · C to toggle
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||||
</div>
|
</div>
|
||||||
</Html>
|
</Html>
|
||||||
</group>
|
</group>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Endpoint-snap halo — brighter ring around the target endpoint
|
|
||||||
while the cursor is within snap range, so the user sees that the
|
|
||||||
next click will join an existing duct rather than freeform-place. */}
|
|
||||||
{snapTarget && (
|
|
||||||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
|
||||||
<sphereGeometry args={[0.12, 24, 16]} />
|
|
||||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
|
||||||
</mesh>
|
|
||||||
)}
|
|
||||||
{/* Committed point pips */}
|
{/* Committed point pips */}
|
||||||
{draftPoints.map((p, i) => (
|
{draftPoints.map((p, i) => (
|
||||||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||||||
@@ -923,25 +1050,112 @@ const DuctSegmentTool = () => {
|
|||||||
<PreviewSegment
|
<PreviewSegment
|
||||||
a={seg.a}
|
a={seg.a}
|
||||||
b={seg.b}
|
b={seg.b}
|
||||||
|
endPort={endSnap.port}
|
||||||
key={`seg-${i}`}
|
key={`seg-${i}`}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
startPort={startPortRef.current}
|
startPort={startPortRef.current}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
|
||||||
|
{ghostFittings.map((fitting) => (
|
||||||
|
<FittingGhost fitting={fitting} key={fitting.id} />
|
||||||
|
))}
|
||||||
</LevelOffsetGroup>
|
</LevelOffsetGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in
|
||||||
|
* level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer /
|
||||||
|
* move-tool convention (Z negated, then rotated onto the floor plane).
|
||||||
|
*/
|
||||||
|
function buildCeilingShape(
|
||||||
|
polygon: Array<[number, number]>,
|
||||||
|
holes: Array<Array<[number, number]>>,
|
||||||
|
): BufferGeometry | null {
|
||||||
|
if (polygon.length < 3) return null
|
||||||
|
const shape = new Shape()
|
||||||
|
const first = polygon[0]!
|
||||||
|
shape.moveTo(first[0], -first[1])
|
||||||
|
for (let i = 1; i < polygon.length; i++) {
|
||||||
|
const pt = polygon[i]!
|
||||||
|
shape.lineTo(pt[0], -pt[1])
|
||||||
|
}
|
||||||
|
shape.closePath()
|
||||||
|
for (const holePolygon of holes) {
|
||||||
|
if (holePolygon.length < 3) continue
|
||||||
|
const hole = new Path()
|
||||||
|
const hf = holePolygon[0]!
|
||||||
|
hole.moveTo(hf[0], -hf[1])
|
||||||
|
for (let i = 1; i < holePolygon.length; i++) {
|
||||||
|
const pt = holePolygon[i]!
|
||||||
|
hole.lineTo(pt[0], -pt[1])
|
||||||
|
}
|
||||||
|
hole.closePath()
|
||||||
|
shape.holes.push(hole)
|
||||||
|
}
|
||||||
|
const geometry = new ShapeGeometry(shape)
|
||||||
|
geometry.rotateX(-Math.PI / 2)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translucent overlay of the ceiling the cursor is under, drawn at the
|
||||||
|
* ceiling's own height. Gives the in-flight duct point a real surface to
|
||||||
|
* read against, so "hung against the ceiling" is visible from any angle
|
||||||
|
* instead of being a dot floating in space.
|
||||||
|
*/
|
||||||
|
function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) {
|
||||||
|
const geometry = useMemo(
|
||||||
|
() => buildCeilingShape(ceiling.polygon, ceiling.holes),
|
||||||
|
[ceiling.polygon, ceiling.holes],
|
||||||
|
)
|
||||||
|
const outline = useMemo(() => {
|
||||||
|
if (ceiling.polygon.length < 2) return null
|
||||||
|
const pts = ceiling.polygon.map(([x, z]) => new Vector3(x, 0, z))
|
||||||
|
const f = ceiling.polygon[0]!
|
||||||
|
pts.push(new Vector3(f[0], 0, f[1]))
|
||||||
|
return pts
|
||||||
|
}, [ceiling.polygon])
|
||||||
|
if (!geometry) return null
|
||||||
|
const y = ceiling.height ?? 2.5
|
||||||
|
return (
|
||||||
|
<group position={[0, y, 0]}>
|
||||||
|
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#818cf8"
|
||||||
|
depthWrite={false}
|
||||||
|
opacity={0.15}
|
||||||
|
side={DoubleSide}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{outline && (
|
||||||
|
<line>
|
||||||
|
<bufferGeometry
|
||||||
|
ref={(g) => {
|
||||||
|
if (g) g.setFromPoints(outline)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<lineBasicMaterial color="#818cf8" opacity={0.6} transparent />
|
||||||
|
</line>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PreviewSegment({
|
function PreviewSegment({
|
||||||
a,
|
a,
|
||||||
b,
|
b,
|
||||||
profile,
|
profile,
|
||||||
startPort,
|
startPort,
|
||||||
|
endPort,
|
||||||
}: {
|
}: {
|
||||||
a: [number, number, number]
|
a: [number, number, number]
|
||||||
b: [number, number, number]
|
b: [number, number, number]
|
||||||
profile: DraftProfile
|
profile: DraftProfile
|
||||||
startPort: ScenePort | null
|
startPort: ScenePort | null
|
||||||
|
endPort: ScenePort | null
|
||||||
}) {
|
}) {
|
||||||
const start = new Vector3(...a)
|
const start = new Vector3(...a)
|
||||||
const end = new Vector3(...b)
|
const end = new Vector3(...b)
|
||||||
@@ -963,7 +1177,7 @@ function PreviewSegment({
|
|||||||
if (!m) return
|
if (!m) return
|
||||||
// Same basis AND roll as the commit will use, so the ghost
|
// Same basis AND roll as the commit will use, so the ghost
|
||||||
// shows the orientation that actually lands.
|
// shows the orientation that actually lands.
|
||||||
const roll = continuityRollFrom(startPort, dir) ?? 0
|
const roll = continuityRollForRun(startPort, endPort, dir)
|
||||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type EyebrowVentNode,
|
type EyebrowVentNode,
|
||||||
emitter,
|
emitter,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -21,8 +22,14 @@ import {
|
|||||||
createRelativeRoofDrag,
|
createRelativeRoofDrag,
|
||||||
type RelativeRoofDragTarget,
|
type RelativeRoofDragTarget,
|
||||||
roofSegmentLocalToBuildingLocal,
|
roofSegmentLocalToBuildingLocal,
|
||||||
|
snapRelativeRoofDragTarget,
|
||||||
} from '../shared/relative-roof-drag'
|
} from '../shared/relative-roof-drag'
|
||||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfaceNodePlacementGuides,
|
||||||
|
snapRoofSurfaceNodeTarget,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import EyebrowVentPreview from './preview'
|
import EyebrowVentPreview from './preview'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,10 +78,21 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
lastSnap = null
|
lastSnap = null
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(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 updatePreview = (event: RoofEvent) => {
|
||||||
const target = roofDrag.resolve(event)
|
const target = resolveSnappedTarget(event)
|
||||||
if (!target) {
|
if (!target) {
|
||||||
clearTarget()
|
clearTarget()
|
||||||
return
|
return
|
||||||
@@ -101,12 +119,18 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
target.localZ,
|
target.localZ,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
publishRoofSurfaceNodePlacementGuides({
|
||||||
|
roof: event.node as RoofNode,
|
||||||
|
segment: target.segment,
|
||||||
|
center: [target.localX, target.localY, target.localZ],
|
||||||
|
node,
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onRoofClick = (event: RoofEvent) => {
|
const onRoofClick = (event: RoofEvent) => {
|
||||||
if (committed) return
|
if (committed) return
|
||||||
const target = lastTarget ?? roofDrag.resolve(event)
|
const target = lastTarget ?? resolveSnappedTarget(event)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
committed = true
|
committed = true
|
||||||
const targetSegmentId = target.segment.id as AnyNodeId
|
const targetSegmentId = target.segment.id as AnyNodeId
|
||||||
@@ -147,6 +171,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -165,6 +190,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -184,6 +210,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +240,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
|
|||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node])
|
}, [exitMoveMode, node])
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ import * as THREE from 'three'
|
|||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfacePlacementGuides,
|
||||||
|
roofSurfaceFootprintFromNode,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { eyebrowVentDefinition } from './definition'
|
import { eyebrowVentDefinition } from './definition'
|
||||||
import EyebrowVentPreview from './preview'
|
import EyebrowVentPreview from './preview'
|
||||||
|
|
||||||
@@ -80,6 +85,15 @@ const EyebrowVentTool = () => {
|
|||||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||||
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
|
||||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
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()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +118,7 @@ const EyebrowVentTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [vent.id] })
|
setSelection({ selectedIds: [vent.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,8 +130,9 @@ const EyebrowVentTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -126,6 +142,7 @@ const EyebrowVentTool = () => {
|
|||||||
onInvalidTarget={() => {
|
onInvalidTarget={() => {
|
||||||
setPreviewPos(null)
|
setPreviewPos(null)
|
||||||
setPreviewSurfaceQuat(null)
|
setPreviewSurfaceQuat(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
{activeBuildingId && previewPos && previewSurfaceQuat && (
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ export const FenceTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (!previewRef.current) return
|
||||||
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
|
if (buildingState.current === 1 && event.nativeEvent.detail >= 2) {
|
||||||
stopDrafting()
|
stopDrafting()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import {
|
|||||||
useEditor,
|
useEditor,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
|
import { createRelativeRoofDrag, snapRelativeRoofDragTarget } from '../shared/relative-roof-drag'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfaceNodePlacementGuides,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||||
import GutterPreview from './preview'
|
import GutterPreview from './preview'
|
||||||
|
|
||||||
@@ -83,11 +87,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastSnap = null
|
lastSnap = null
|
||||||
setTarget(null)
|
setTarget(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
|
||||||
const target = roofDrag.resolve(event)
|
const rawTarget = roofDrag.resolve(event)
|
||||||
if (!target) return null
|
if (!rawTarget) return null
|
||||||
|
const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
|
||||||
return {
|
return {
|
||||||
segment: target.segment,
|
segment: target.segment,
|
||||||
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
|
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
|
||||||
@@ -131,6 +137,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
},
|
},
|
||||||
snap,
|
snap,
|
||||||
})
|
})
|
||||||
|
publishRoofSurfaceNodePlacementGuides({
|
||||||
|
roof,
|
||||||
|
segment: target.segment,
|
||||||
|
center: [snap.eaveX, snap.eaveY, snap.eaveZ],
|
||||||
|
node: { ...node, rotation: snap.rotation },
|
||||||
|
mode: 'linear-edge',
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +191,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -196,6 +210,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -215,6 +230,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +260,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
|||||||
|
|
||||||
const obj = sceneRegistry.nodes.get(node.id)
|
const obj = sceneRegistry.nodes.get(node.id)
|
||||||
if (obj) obj.visible = true
|
if (obj) obj.visible = true
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}, [exitMoveMode, node])
|
}, [exitMoveMode, node])
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||||
|
import {
|
||||||
|
clearRoofSurfacePlacementGuides,
|
||||||
|
publishRoofSurfacePlacementGuides,
|
||||||
|
roofSurfaceFootprintFromNode,
|
||||||
|
} from '../shared/roof-surface-placement-guides'
|
||||||
import { gutterDefinition } from './definition'
|
import { gutterDefinition } from './definition'
|
||||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||||
import GutterPreview from './preview'
|
import GutterPreview from './preview'
|
||||||
@@ -100,6 +105,13 @@ const GutterTool = () => {
|
|||||||
},
|
},
|
||||||
snap,
|
snap,
|
||||||
})
|
})
|
||||||
|
publishRoofSurfacePlacementGuides({
|
||||||
|
roof,
|
||||||
|
segment: hit.segment,
|
||||||
|
center: [snap.eaveX, snap.eaveY, snap.eaveZ],
|
||||||
|
footprint: roofSurfaceFootprintFromNode({ ...previewNode, rotation: snap.rotation }),
|
||||||
|
mode: 'linear-edge',
|
||||||
|
})
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +141,7 @@ const GutterTool = () => {
|
|||||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||||
setSelection({ selectedIds: [gutter.id] })
|
setSelection({ selectedIds: [gutter.id] })
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +153,19 @@ const GutterTool = () => {
|
|||||||
emitter.off('roof:move', updatePreview)
|
emitter.off('roof:move', updatePreview)
|
||||||
emitter.off('roof:enter', updatePreview)
|
emitter.off('roof:enter', updatePreview)
|
||||||
emitter.off('roof:click', onClick)
|
emitter.off('roof:click', onClick)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
}
|
}
|
||||||
}, [activeBuildingId, setSelection])
|
}, [activeBuildingId, setSelection, previewNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoofAttachmentFallbackPreview
|
<RoofAttachmentFallbackPreview
|
||||||
activeBuildingId={activeBuildingId}
|
activeBuildingId={activeBuildingId}
|
||||||
ghost={<GutterPreview node={previewNode} invalid />}
|
ghost={<GutterPreview node={previewNode} invalid />}
|
||||||
onInvalidTarget={() => setTarget(null)}
|
onInvalidTarget={() => {
|
||||||
|
setTarget(null)
|
||||||
|
clearRoofSurfacePlacementGuides()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{activeBuildingId && target && (
|
{activeBuildingId && target && (
|
||||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type Interactive,
|
type Interactive,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
isSlotMaterialName,
|
isSlotMaterialName,
|
||||||
|
itemClipRegistry,
|
||||||
LIBRARY_MATERIAL_REF_PREFIX,
|
LIBRARY_MATERIAL_REF_PREFIX,
|
||||||
type LightEffect,
|
type LightEffect,
|
||||||
SCENE_MATERIAL_REF_PREFIX,
|
SCENE_MATERIAL_REF_PREFIX,
|
||||||
@@ -379,7 +380,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
mesh.castShadow = !hasGlass
|
mesh.castShadow = !hasGlass
|
||||||
mesh.receiveShadow = !hasGlass
|
mesh.receiveShadow = !hasGlass
|
||||||
}
|
}
|
||||||
}, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials])
|
}, [shading, textures, colorPreset, node.slots, sceneMaterials])
|
||||||
|
|
||||||
const interactive = interactiveRef.current
|
const interactive = interactiveRef.current
|
||||||
const animEffect =
|
const animEffect =
|
||||||
@@ -387,6 +388,20 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
const lightEffects =
|
const lightEffects =
|
||||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
||||||
|
|
||||||
|
// Expose this item's ambient clip (e.g. a fan's spin) to the GLB bake. The
|
||||||
|
// catalog GLB owns the clip; it isn't in the scene graph, so the export can't
|
||||||
|
// find it without this registry. The bake retargets it onto the baked subtree.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animEffect) return
|
||||||
|
const clipName = animEffect.clips.on ?? animEffect.clips.loop
|
||||||
|
const clip = clipName ? animations.find((c) => c.name === clipName) : undefined
|
||||||
|
if (!clip) return
|
||||||
|
itemClipRegistry.set(node.id, { clip, loop: true })
|
||||||
|
return () => {
|
||||||
|
itemClipRegistry.delete(node.id)
|
||||||
|
}
|
||||||
|
}, [node.id, animEffect, animations])
|
||||||
|
|
||||||
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
// useGLTF caches scenes, and Clone shares child geometry/material references.
|
||||||
// Undo can unmount one item while another clone of the same asset still needs them.
|
// Undo can unmount one item while another clone of the same asset still needs them.
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test'
|
|
||||||
import { planLinesetConnect } from './connect'
|
|
||||||
import type { LinesetNode } from './schema'
|
|
||||||
|
|
||||||
type Point = [number, number, number]
|
|
||||||
|
|
||||||
/** Minimal stand-in — the planner only reads `id` and `path`. */
|
|
||||||
function line(id: string, path: Point[]): LinesetNode {
|
|
||||||
return { id, path } as unknown as LinesetNode
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('planLinesetConnect', () => {
|
|
||||||
test('no shared endpoint → create', () => {
|
|
||||||
const plan = planLinesetConnect(
|
|
||||||
[
|
|
||||||
line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
[5, 0, 0],
|
|
||||||
[6, 0, 0],
|
|
||||||
)
|
|
||||||
expect(plan).toEqual({
|
|
||||||
kind: 'create',
|
|
||||||
path: [
|
|
||||||
[5, 0, 0],
|
|
||||||
[6, 0, 0],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('new start meets run end → extend, old end becomes interior', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
|
|
||||||
expect(plan).toEqual({
|
|
||||||
kind: 'extend',
|
|
||||||
id: 'a',
|
|
||||||
path: [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
[1, 0, 2],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('new start meets run start → extend, run reversed so join is interior', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
|
|
||||||
expect(plan).toEqual({
|
|
||||||
kind: 'extend',
|
|
||||||
id: 'a',
|
|
||||||
path: [
|
|
||||||
[1, 0, 0],
|
|
||||||
[0, 0, 0],
|
|
||||||
[0, 0, 2],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('new end meets a run → extend, new segment leads', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[1, 0, 0],
|
|
||||||
[2, 0, 0],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
|
|
||||||
expect(plan).toEqual({
|
|
||||||
kind: 'extend',
|
|
||||||
id: 'a',
|
|
||||||
path: [
|
|
||||||
[1, 0, 3],
|
|
||||||
[1, 0, 0],
|
|
||||||
[2, 0, 0],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('both ends meet distinct runs → bridge, second run absorbed', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
])
|
|
||||||
const b = line('b', [
|
|
||||||
[1, 0, 5],
|
|
||||||
[2, 0, 5],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
|
|
||||||
expect(plan).toEqual({
|
|
||||||
kind: 'bridge',
|
|
||||||
id: 'a',
|
|
||||||
deleteId: 'b',
|
|
||||||
path: [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
[1, 0, 5],
|
|
||||||
[2, 0, 5],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test('both ends meet the SAME run → not a bridge (extends at start)', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
|
|
||||||
expect(plan.kind).toBe('extend')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('float drift within tolerance still coincides', () => {
|
|
||||||
const a = line('a', [
|
|
||||||
[0, 0, 0],
|
|
||||||
[1, 0, 0],
|
|
||||||
])
|
|
||||||
const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
|
|
||||||
expect(plan.kind).toBe('extend')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { LinesetNode } from './schema'
|
|
||||||
|
|
||||||
type Point = [number, number, number]
|
|
||||||
type LinesetId = LinesetNode['id']
|
|
||||||
|
|
||||||
/** Coincidence tolerance (meters) for folding endpoints into one run. The
|
|
||||||
* draw tool snaps onto an existing run's endpoint exactly, so this only
|
|
||||||
* needs to absorb float drift, not user aim. */
|
|
||||||
const COINCIDENT_EPS_M = 1e-3
|
|
||||||
|
|
||||||
function samePoint(a: Point, b: Point): boolean {
|
|
||||||
return (
|
|
||||||
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
|
|
||||||
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
|
|
||||||
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which terminal of `line` coincides with `p`, if either. */
|
|
||||||
function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
|
|
||||||
const path = line.path as Point[]
|
|
||||||
if (samePoint(path[0]!, p)) return 'start'
|
|
||||||
if (samePoint(path[path.length - 1]!, p)) return 'end'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** First lineset whose start or end coincides with `p`. */
|
|
||||||
function findConnection(
|
|
||||||
existing: LinesetNode[],
|
|
||||||
p: Point,
|
|
||||||
): { line: LinesetNode; side: 'start' | 'end' } | null {
|
|
||||||
for (const line of existing) {
|
|
||||||
if (line.path.length < 2) continue
|
|
||||||
const side = matchEnd(line, p)
|
|
||||||
if (side) return { line, side }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path re-ordered so the connecting terminal is its LAST point. */
|
|
||||||
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
|
|
||||||
return side === 'end' ? path : [...path].reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path re-ordered so the connecting terminal is its FIRST point. */
|
|
||||||
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
|
|
||||||
return side === 'start' ? path : [...path].reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outcome of committing a new `start`→`end` segment against the existing
|
|
||||||
* lineset runs on the same level:
|
|
||||||
* - `create` — no shared endpoint; place a fresh standalone run.
|
|
||||||
* - `extend` — one end lands on run `id`; grow that run's path so the old
|
|
||||||
* terminal becomes an interior point (the geometry miters it).
|
|
||||||
* - `bridge` — both ends land on two *different* runs; weld them plus the
|
|
||||||
* new segment into one path on `id` and delete the absorbed `deleteId`.
|
|
||||||
*/
|
|
||||||
export type LinesetConnectPlan =
|
|
||||||
| { kind: 'create'; path: Point[] }
|
|
||||||
| { kind: 'extend'; id: LinesetId; path: Point[] }
|
|
||||||
| { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide how a freshly drawn `start`→`end` segment folds into existing
|
|
||||||
* lineset runs that share an endpoint coordinate. Pure: returns a plan, the
|
|
||||||
* caller mutates the scene. Coords are level-local, so `existing` must be
|
|
||||||
* pre-filtered to the segment's level.
|
|
||||||
*/
|
|
||||||
export function planLinesetConnect(
|
|
||||||
existing: LinesetNode[],
|
|
||||||
start: Point,
|
|
||||||
end: Point,
|
|
||||||
): LinesetConnectPlan {
|
|
||||||
const atStart = findConnection(existing, start)
|
|
||||||
const atEnd = findConnection(existing, end)
|
|
||||||
|
|
||||||
// Both ends meet distinct runs → weld the three into one path.
|
|
||||||
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
|
|
||||||
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
|
||||||
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
|
||||||
return {
|
|
||||||
kind: 'bridge',
|
|
||||||
id: atStart.line.id,
|
|
||||||
path: [...left, ...right],
|
|
||||||
deleteId: atEnd.line.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (atStart) {
|
|
||||||
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
|
||||||
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
|
|
||||||
}
|
|
||||||
if (atEnd) {
|
|
||||||
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
|
||||||
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
|
|
||||||
}
|
|
||||||
return { kind: 'create', path: [start, end] }
|
|
||||||
}
|
|
||||||
@@ -46,8 +46,12 @@ function buildRun(
|
|||||||
*
|
*
|
||||||
* One line per node — what the ghost previews is exactly what commits. To run
|
* One line per node — what the ghost previews is exactly what commits. To run
|
||||||
* the suction line beside the liquid line, draw them as two separate linesets
|
* the suction line beside the liquid line, draw them as two separate linesets
|
||||||
* rather than rendering both together off one path. Joint spheres cap interior
|
* rather than rendering both together off one path.
|
||||||
* corners so turns read as continuous pipe.
|
*
|
||||||
|
* Each line is a standalone two-point node (no fitting system, unlike ducts),
|
||||||
|
* so a sphere caps BOTH endpoints. On a free end it just rounds the cap; where
|
||||||
|
* two segments share a coordinate the coincident spheres fill the miter gap, so
|
||||||
|
* the turn reads as continuous pipe.
|
||||||
*
|
*
|
||||||
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
|
* Children are level-local meters; `<ParametricNodeRenderer>` owns the
|
||||||
* node transform (identity today — the path is absolute within the level).
|
* node transform (identity today — the path is absolute within the level).
|
||||||
@@ -87,8 +91,10 @@ export function buildLinesetGeometry(node: LinesetNode): Group {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Joint caps at interior corners so turns read as continuous pipe.
|
// Spherical caps at every point. Interior corners read as continuous pipe;
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
// endpoint caps round the open ends and, where two separate segments share a
|
||||||
|
// coordinate, the coincident spheres fill the miter so the turn looks welded.
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
|
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
|
||||||
joint.name = `lineset-copper-joint-${i}`
|
joint.name = `lineset-copper-joint-${i}`
|
||||||
joint.position.copy(points[i] as Vector3)
|
joint.position.copy(points[i] as Vector3)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
|
|
||||||
export { linesetDefinition } from './definition'
|
export { linesetDefinition } from './definition'
|
||||||
export { buildLinesetGeometry } from './geometry'
|
export { buildLinesetGeometry } from './geometry'
|
||||||
export { LinesetNode } from './schema'
|
export { LinesetNode } from './schema'
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
collectGhostAlignmentCandidates,
|
collectGhostAlignmentCandidates,
|
||||||
resolveGhostAlignment,
|
resolveGhostAlignment,
|
||||||
} from '../shared/ghost-alignment'
|
} from '../shared/ghost-alignment'
|
||||||
|
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||||
|
|
||||||
type Vec3 = [number, number, number]
|
type Vec3 = [number, number, number]
|
||||||
|
|
||||||
@@ -139,6 +140,12 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
if (existedAtStart) setMeshHidden(true)
|
if (existedAtStart) setMeshHidden(true)
|
||||||
|
|
||||||
|
// Carry connected fittings (+ their other runs) as the whole run slides.
|
||||||
|
// Snapshot once at drag start; only existing runs are mated to anything.
|
||||||
|
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||||
|
? startRunMoveConnectivity(node)
|
||||||
|
: null
|
||||||
|
|
||||||
const setPreview = (path: Vec3[]) => {
|
const setPreview = (path: Vec3[]) => {
|
||||||
previewPathRef.current = path
|
previewPathRef.current = path
|
||||||
setPreviewPath(path)
|
setPreviewPath(path)
|
||||||
@@ -178,7 +185,9 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
prevSnapRef.current = cur
|
prevSnapRef.current = cur
|
||||||
hasMovedRef.current = true
|
hasMovedRef.current = true
|
||||||
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
|
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
|
||||||
|
setPreview(nextPath)
|
||||||
|
connectivity?.preview({ path: nextPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
const commit = (event: GridEvent) => {
|
const commit = (event: GridEvent) => {
|
||||||
@@ -206,10 +215,21 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
||||||
selectId = created.id as AnyNodeId
|
selectId = created.id as AnyNodeId
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().updateNode(nodeId, { path: finalPath } as Partial<AnyNode>)
|
// Fold connected-fitting / sibling-run follow-updates into the SAME
|
||||||
|
// batch as the moved run so the whole joint is one undo step.
|
||||||
|
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
|
||||||
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNodes([
|
||||||
|
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
|
||||||
|
...followUpdates,
|
||||||
|
])
|
||||||
useScene.getState().markDirty(nodeId)
|
useScene.getState().markDirty(nodeId)
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
// Followers are committed to the store — drop their live overrides so
|
||||||
|
// renderers read the canonical path/position.
|
||||||
|
connectivity?.clear()
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
|
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
@@ -221,6 +241,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
connectivity?.clear()
|
||||||
if (existedAtStart) {
|
if (existedAtStart) {
|
||||||
setMeshHidden(false)
|
setMeshHidden(false)
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
@@ -240,6 +261,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|||||||
emitter.off('grid:move', onMove)
|
emitter.off('grid:move', onMove)
|
||||||
emitter.off('grid:click', commit)
|
emitter.off('grid:click', commit)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
connectivity?.clear()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
if (existedAtStart) setMeshHidden(false)
|
if (existedAtStart) setMeshHidden(false)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|||||||
@@ -1,282 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
|
||||||
type AnyNodeId,
|
|
||||||
type LinesetNode,
|
|
||||||
pauseSceneHistory,
|
|
||||||
resumeSceneHistory,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
|
||||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
|
||||||
|
|
||||||
const HANDLE_RADIUS = 0.08
|
export default createRefrigerantLineSelectionAffordance('lineset')
|
||||||
const PORT_SNAP_RADIUS_M = 0.4
|
|
||||||
|
|
||||||
const UP = new Vector3(0, 1, 0)
|
|
||||||
|
|
||||||
function snap(value: number, step: number): number {
|
|
||||||
if (step <= 0) return value
|
|
||||||
return Math.round(value / step) * step
|
|
||||||
}
|
|
||||||
|
|
||||||
type Point = [number, number, number]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection-time editing for committed lineset runs: one draggable handle
|
|
||||||
* per path point. Mirrors the duct-segment path-handle system, but dragged
|
|
||||||
* run endpoints snap onto refrigerant ports only.
|
|
||||||
*
|
|
||||||
* Handles are PORTALED into the lineset's registered scene group so they
|
|
||||||
* share its exact frame. Drag raycasts run in world space and convert hits
|
|
||||||
* back into the group's local frame before writing the path.
|
|
||||||
*/
|
|
||||||
const LinesetSelectionAffordance = () => {
|
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
||||||
const lineset = useScene((s) => {
|
|
||||||
if (selectedIds.length !== 1) return null
|
|
||||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
|
||||||
return node?.type === 'lineset' ? (node as LinesetNode) : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const linesetId = lineset?.id ?? null
|
|
||||||
const [target, setTarget] = useState<Object3D | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!linesetId) {
|
|
||||||
setTarget(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let frameId = 0
|
|
||||||
const resolve = () => {
|
|
||||||
const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
|
|
||||||
setTarget((cur) => (cur === next ? cur : next))
|
|
||||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
return () => window.cancelAnimationFrame(frameId)
|
|
||||||
}, [linesetId])
|
|
||||||
|
|
||||||
if (!lineset || !target) return null
|
|
||||||
return createPortal(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
|
|
||||||
const { camera, gl } = useThree()
|
|
||||||
const unit = useViewer((s) => s.unit)
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
|
||||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
|
||||||
const dragRef = useRef<{
|
|
||||||
index: number
|
|
||||||
initialPath: Point[]
|
|
||||||
current: Point
|
|
||||||
cleanup: () => void
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const makeRay = (clientX: number, clientY: number) => {
|
|
||||||
const rect = gl.domElement.getBoundingClientRect()
|
|
||||||
const ndc = new Vector2(
|
|
||||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
||||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
||||||
)
|
|
||||||
const raycaster = new Raycaster()
|
|
||||||
raycaster.setFromCamera(ndc, camera)
|
|
||||||
return raycaster.ray
|
|
||||||
}
|
|
||||||
|
|
||||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
|
||||||
const hit = new Vector3()
|
|
||||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectOntoAxis = (
|
|
||||||
clientX: number,
|
|
||||||
clientY: number,
|
|
||||||
anchorWorld: Vector3,
|
|
||||||
axisWorld: Vector3,
|
|
||||||
): number | null => {
|
|
||||||
const ray = makeRay(clientX, clientY)
|
|
||||||
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
|
|
||||||
const b = ray.direction.dot(axisWorld)
|
|
||||||
const denom = 1 - b * b
|
|
||||||
if (Math.abs(denom) < 1e-6) return null
|
|
||||||
const d0 = ray.direction.dot(w0)
|
|
||||||
const e0 = axisWorld.dot(w0)
|
|
||||||
return (e0 - b * d0) / denom
|
|
||||||
}
|
|
||||||
|
|
||||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
|
||||||
const toLocal = (world: Vector3): Point => {
|
|
||||||
const local = target.worldToLocal(world.clone())
|
|
||||||
return [local.x, local.y, local.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const initialPath = lineset.path.map((p) => [...p] as Point)
|
|
||||||
const startPoint = initialPath[index]!
|
|
||||||
pauseSceneHistory(useScene)
|
|
||||||
useViewer.getState().setInputDragging(true)
|
|
||||||
document.body.style.cursor = 'grabbing'
|
|
||||||
setDraggingIndex(index)
|
|
||||||
|
|
||||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
|
||||||
|
|
||||||
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
|
|
||||||
const axisLocal = new Vector3(
|
|
||||||
startPoint[0] - neighbor[0],
|
|
||||||
startPoint[1] - neighbor[1],
|
|
||||||
startPoint[2] - neighbor[2],
|
|
||||||
)
|
|
||||||
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
|
|
||||||
axisLocal.normalize()
|
|
||||||
const anchorWorldStart = toWorld(startPoint)
|
|
||||||
const axisWorld = toWorld([
|
|
||||||
startPoint[0] + axisLocal.x,
|
|
||||||
startPoint[1] + axisLocal.y,
|
|
||||||
startPoint[2] + axisLocal.z,
|
|
||||||
])
|
|
||||||
.sub(anchorWorldStart)
|
|
||||||
.normalize()
|
|
||||||
|
|
||||||
const onMove = (event: PointerEvent) => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag) return
|
|
||||||
const current = drag.current
|
|
||||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
|
||||||
let next: Point | null = null
|
|
||||||
if (event.altKey) {
|
|
||||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
|
|
||||||
const hit = intersect(event.clientX, event.clientY, plane)
|
|
||||||
if (hit) {
|
|
||||||
const local = toLocal(hit)
|
|
||||||
next = [snap(local[0], step), current[1], snap(local[2], step)]
|
|
||||||
if (isEndpoint) {
|
|
||||||
const port = findNearestPortXZ(
|
|
||||||
[local[0], current[1], local[2]],
|
|
||||||
collectScenePorts({ excludeNodeId: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
|
|
||||||
PORT_SNAP_RADIUS_M,
|
|
||||||
)
|
|
||||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
|
|
||||||
if (t !== null) {
|
|
||||||
const dist = snap(t, step)
|
|
||||||
next = [
|
|
||||||
startPoint[0] + axisLocal.x * dist,
|
|
||||||
Math.max(0, startPoint[1] + axisLocal.y * dist),
|
|
||||||
startPoint[2] + axisLocal.z * dist,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!next) return
|
|
||||||
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
|
|
||||||
drag.current = next
|
|
||||||
const path = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
|
||||||
useScene.getState().updateNode(lineset.id, { path })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUp = () => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag) return
|
|
||||||
drag.cleanup()
|
|
||||||
dragRef.current = null
|
|
||||||
setDraggingIndex(null)
|
|
||||||
const finalPath = drag.initialPath.map((p, i) =>
|
|
||||||
i === drag.index ? drag.current : p,
|
|
||||||
) as Point[]
|
|
||||||
useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
|
|
||||||
resumeSceneHistory(useScene)
|
|
||||||
const moved = finalPath[drag.index]!.some(
|
|
||||||
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
|
||||||
)
|
|
||||||
if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
window.removeEventListener('pointermove', onMove)
|
|
||||||
window.removeEventListener('pointerup', onUp)
|
|
||||||
window.removeEventListener('pointercancel', onUp)
|
|
||||||
useViewer.getState().setInputDragging(false)
|
|
||||||
document.body.style.cursor = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
dragRef.current = { index, initialPath, current: startPoint, cleanup }
|
|
||||||
window.addEventListener('pointermove', onMove)
|
|
||||||
window.addEventListener('pointerup', onUp)
|
|
||||||
window.addEventListener('pointercancel', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{lineset.path.map((p, i) => {
|
|
||||||
const active = draggingIndex === i
|
|
||||||
const hovered = hoverIndex === i
|
|
||||||
return (
|
|
||||||
<mesh
|
|
||||||
key={`lineset-handle-${i}`}
|
|
||||||
layers={EDITOR_LAYER}
|
|
||||||
onPointerDown={onHandleDown(i)}
|
|
||||||
onPointerEnter={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setHoverIndex(i)
|
|
||||||
if (draggingIndex === null) document.body.style.cursor = 'grab'
|
|
||||||
}}
|
|
||||||
onPointerLeave={() => {
|
|
||||||
setHoverIndex((prev) => (prev === i ? null : prev))
|
|
||||||
if (draggingIndex === null) document.body.style.cursor = ''
|
|
||||||
}}
|
|
||||||
position={p as Point}
|
|
||||||
>
|
|
||||||
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color={active || hovered ? '#a5b4fc' : '#818cf8'}
|
|
||||||
depthTest={false}
|
|
||||||
opacity={active ? 1 : 0.85}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{draggingIndex !== null &&
|
|
||||||
lineset.path[draggingIndex] &&
|
|
||||||
(() => {
|
|
||||||
const point = lineset.path[draggingIndex]!
|
|
||||||
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
|
||||||
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
|
||||||
const axes = ['x', 'y', 'z'] as const
|
|
||||||
const primary = axes.reduce((best, axis, i) =>
|
|
||||||
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Html
|
|
||||||
center
|
|
||||||
position={[point[0], point[1] + 0.35, point[2]]}
|
|
||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
||||||
zIndexRange={[100, 0]}
|
|
||||||
>
|
|
||||||
<DimensionPill
|
|
||||||
parts={axes.map((axis, i) => ({
|
|
||||||
key: axis,
|
|
||||||
prefix: axis.toUpperCase(),
|
|
||||||
value: deltas[i]!,
|
|
||||||
signed: true,
|
|
||||||
}))}
|
|
||||||
primary={primary}
|
|
||||||
unit={unit}
|
|
||||||
/>
|
|
||||||
</Html>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LinesetSelectionAffordance
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
|
import { emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
|
||||||
import {
|
import {
|
||||||
CursorSphere,
|
CursorSphere,
|
||||||
DimensionPill,
|
DimensionPill,
|
||||||
@@ -19,18 +19,18 @@ import { type Group, Vector3 } from 'three'
|
|||||||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||||||
import { planLinesetConnect } from './connect'
|
|
||||||
import { linesetDefinition } from './definition'
|
import { linesetDefinition } from './definition'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-segment-at-a-time placement tool for refrigerant linesets — the
|
* Continuous placement tool for refrigerant linesets — the refrigerant-loop
|
||||||
* refrigerant-loop sibling of the duct-segment tool.
|
* sibling of the duct-segment tool.
|
||||||
*
|
*
|
||||||
* Mouse-driven model:
|
* Mouse-driven model:
|
||||||
* - **First click** anchors the run start. Within range of a refrigerant
|
* - **First click** anchors the run start. Within range of a refrigerant
|
||||||
* service port (a condenser / coil valve, or another lineset's end) it
|
* service port (a condenser / coil valve, or another lineset's end) it
|
||||||
* snaps onto the port so a run mates flush.
|
* snaps onto the port so a run mates flush.
|
||||||
* - **Second click** commits a two-point lineset and re-arms the tool.
|
* - **Second click** commits a two-point lineset and keeps its far end
|
||||||
|
* anchored, so the next click continues the run like wall / duct drafting.
|
||||||
* - The in-flight end follows the active snapping mode: `angles` locks it to
|
* - The in-flight end follows the active snapping mode: `angles` locks it to
|
||||||
* the nearest 45° step in XZ from the start (Y stays at the start's
|
* the nearest 45° step in XZ from the start (Y stays at the start's
|
||||||
* height); `grid`/`lines`/`off` leave it free. Shift cycles the mode.
|
* height); `grid`/`lines`/`off` leave it free. Shift cycles the mode.
|
||||||
@@ -108,32 +108,18 @@ const LinesetTool = () => {
|
|||||||
Math.abs(start[2] - end[2]) < 1e-4
|
Math.abs(start[2] - end[2]) < 1e-4
|
||||||
if (sameSpot) return
|
if (sameSpot) return
|
||||||
|
|
||||||
// Fold into any existing run that shares this segment's endpoint, so
|
// Each drawn segment is its own standalone two-point lineset node — the
|
||||||
// two runs meeting at a coordinate become one mitered path instead of
|
// refrigerant-loop sibling of duct-segment. Independent nodes mean each
|
||||||
// overlapping nodes. Only same-level runs are candidates — lineset
|
// segment selects and deletes on its own, rather than folding into one
|
||||||
// paths are level-local.
|
// mitered polyline run.
|
||||||
const scene = useScene.getState()
|
|
||||||
const existing = Object.values(scene.nodes).filter(
|
|
||||||
(n): n is LinesetNode =>
|
|
||||||
n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
|
|
||||||
)
|
|
||||||
const plan = planLinesetConnect(existing, start, end)
|
|
||||||
|
|
||||||
if (plan.kind === 'create') {
|
|
||||||
const lineset = LinesetNode.parse({
|
const lineset = LinesetNode.parse({
|
||||||
...linesetDefinition.defaults(),
|
...linesetDefinition.defaults(),
|
||||||
name: 'Lineset',
|
name: 'Lineset',
|
||||||
path: plan.path,
|
path: [start, end],
|
||||||
})
|
})
|
||||||
scene.createNode(lineset, activeLevelId)
|
useScene.getState().createNode(lineset, activeLevelId)
|
||||||
} else if (plan.kind === 'extend') {
|
|
||||||
scene.updateNode(plan.id, { path: plan.path })
|
|
||||||
} else {
|
|
||||||
scene.updateNode(plan.id, { path: plan.path })
|
|
||||||
scene.deleteNode(plan.deleteId)
|
|
||||||
}
|
|
||||||
triggerSFX('sfx:item-place')
|
triggerSFX('sfx:item-place')
|
||||||
setDraftPoints([])
|
setDraftPoints([end])
|
||||||
setSnapTarget(null)
|
setSnapTarget(null)
|
||||||
altAnchorRef.current = null
|
altAnchorRef.current = null
|
||||||
setAltActive(false)
|
setAltActive(false)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { LiquidLineNode } from './schema'
|
|
||||||
|
|
||||||
type Point = [number, number, number]
|
|
||||||
type LiquidLineId = LiquidLineNode['id']
|
|
||||||
|
|
||||||
/** Coincidence tolerance (meters) for folding endpoints into one run. The
|
|
||||||
* draw tool snaps onto an existing run's endpoint exactly, so this only
|
|
||||||
* needs to absorb float drift, not user aim. */
|
|
||||||
const COINCIDENT_EPS_M = 1e-3
|
|
||||||
|
|
||||||
function samePoint(a: Point, b: Point): boolean {
|
|
||||||
return (
|
|
||||||
Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
|
|
||||||
Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
|
|
||||||
Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which terminal of `line` coincides with `p`, if either. */
|
|
||||||
function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
|
|
||||||
const path = line.path as Point[]
|
|
||||||
if (samePoint(path[0]!, p)) return 'start'
|
|
||||||
if (samePoint(path[path.length - 1]!, p)) return 'end'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** First liquid line whose start or end coincides with `p`. */
|
|
||||||
function findConnection(
|
|
||||||
existing: LiquidLineNode[],
|
|
||||||
p: Point,
|
|
||||||
): { line: LiquidLineNode; side: 'start' | 'end' } | null {
|
|
||||||
for (const line of existing) {
|
|
||||||
if (line.path.length < 2) continue
|
|
||||||
const side = matchEnd(line, p)
|
|
||||||
if (side) return { line, side }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path re-ordered so the connecting terminal is its LAST point. */
|
|
||||||
function endLast(path: Point[], side: 'start' | 'end'): Point[] {
|
|
||||||
return side === 'end' ? path : [...path].reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Path re-ordered so the connecting terminal is its FIRST point. */
|
|
||||||
function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
|
|
||||||
return side === 'start' ? path : [...path].reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outcome of committing a new `start`→`end` segment against the existing
|
|
||||||
* liquid-line runs on the same level:
|
|
||||||
* - `create` — no shared endpoint; place a fresh standalone run.
|
|
||||||
* - `extend` — one end lands on run `id`; grow that run's path so the old
|
|
||||||
* terminal becomes an interior point (the geometry miters it).
|
|
||||||
* - `bridge` — both ends land on two *different* runs; weld them plus the
|
|
||||||
* new segment into one path on `id` and delete the absorbed `deleteId`.
|
|
||||||
*/
|
|
||||||
export type LiquidLineConnectPlan =
|
|
||||||
| { kind: 'create'; path: Point[] }
|
|
||||||
| { kind: 'extend'; id: LiquidLineId; path: Point[] }
|
|
||||||
| { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide how a freshly drawn `start`→`end` segment folds into existing
|
|
||||||
* liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
|
|
||||||
* the caller mutates the scene. Coords are level-local, so `existing` must be
|
|
||||||
* pre-filtered to the segment's level.
|
|
||||||
*/
|
|
||||||
export function planLiquidLineConnect(
|
|
||||||
existing: LiquidLineNode[],
|
|
||||||
start: Point,
|
|
||||||
end: Point,
|
|
||||||
): LiquidLineConnectPlan {
|
|
||||||
const atStart = findConnection(existing, start)
|
|
||||||
const atEnd = findConnection(existing, end)
|
|
||||||
|
|
||||||
// Both ends meet distinct runs → weld the three into one path.
|
|
||||||
if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
|
|
||||||
const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
|
||||||
const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
|
||||||
return {
|
|
||||||
kind: 'bridge',
|
|
||||||
id: atStart.line.id,
|
|
||||||
path: [...left, ...right],
|
|
||||||
deleteId: atEnd.line.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (atStart) {
|
|
||||||
const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
|
|
||||||
return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
|
|
||||||
}
|
|
||||||
if (atEnd) {
|
|
||||||
const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
|
|
||||||
return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
|
|
||||||
}
|
|
||||||
return { kind: 'create', path: [start, end] }
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,12 @@ function buildRun(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
|
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
|
||||||
* cylinder following the node path centerline, with joint spheres capping
|
* cylinder following the node path centerline.
|
||||||
* interior corners so turns read as continuous pipe.
|
*
|
||||||
|
* Each line is a standalone two-point node (no fitting system), so a sphere caps
|
||||||
|
* BOTH endpoints. On a free end it rounds the cap; where two segments share a
|
||||||
|
* coordinate the coincident spheres fill the miter gap, so the turn reads as
|
||||||
|
* continuous pipe.
|
||||||
*
|
*
|
||||||
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
|
* Children are level-local meters; `<ParametricNodeRenderer>` owns the node
|
||||||
* transform (identity today — the path is absolute within the level).
|
* transform (identity today — the path is absolute within the level).
|
||||||
@@ -55,7 +59,10 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
|
|||||||
if (run) group.add(run)
|
if (run) group.add(run)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < points.length - 1; i++) {
|
// Spherical caps at every point: interior corners read as continuous pipe,
|
||||||
|
// and endpoint caps round the open ends so two separate segments sharing a
|
||||||
|
// coordinate fill the miter and look welded.
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
|
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
|
||||||
joint.name = `liquid-line-joint-${i}`
|
joint.name = `liquid-line-joint-${i}`
|
||||||
joint.position.copy(points[i] as Vector3)
|
joint.position.copy(points[i] as Vector3)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
|
|
||||||
export { liquidLineDefinition } from './definition'
|
export { liquidLineDefinition } from './definition'
|
||||||
export { buildLiquidLineGeometry } from './geometry'
|
export { buildLiquidLineGeometry } from './geometry'
|
||||||
export { useLiquidLineToolOptions } from './options'
|
export { useLiquidLineToolOptions } from './options'
|
||||||
|
|||||||
@@ -1,282 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
|
||||||
type AnyNodeId,
|
|
||||||
type LiquidLineNode,
|
|
||||||
pauseSceneHistory,
|
|
||||||
resumeSceneHistory,
|
|
||||||
sceneRegistry,
|
|
||||||
useScene,
|
|
||||||
} from '@pascal-app/core'
|
|
||||||
import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
|
||||||
import { Html } from '@react-three/drei'
|
|
||||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
|
|
||||||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
|
||||||
|
|
||||||
const HANDLE_RADIUS = 0.07
|
export default createRefrigerantLineSelectionAffordance('liquid-line')
|
||||||
const PORT_SNAP_RADIUS_M = 0.4
|
|
||||||
|
|
||||||
const UP = new Vector3(0, 1, 0)
|
|
||||||
|
|
||||||
function snap(value: number, step: number): number {
|
|
||||||
if (step <= 0) return value
|
|
||||||
return Math.round(value / step) * step
|
|
||||||
}
|
|
||||||
|
|
||||||
type Point = [number, number, number]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selection-time editing for committed liquid-line runs: one draggable handle
|
|
||||||
* per path point. Mirrors the lineset path-handle system; dragged run
|
|
||||||
* endpoints snap onto refrigerant ports only.
|
|
||||||
*
|
|
||||||
* Handles are PORTALED into the line's registered scene group so they share
|
|
||||||
* its exact frame. Drag raycasts run in world space and convert hits back into
|
|
||||||
* the group's local frame before writing the path.
|
|
||||||
*/
|
|
||||||
const LiquidLineSelectionAffordance = () => {
|
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
||||||
const line = useScene((s) => {
|
|
||||||
if (selectedIds.length !== 1) return null
|
|
||||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
|
||||||
return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const lineId = line?.id ?? null
|
|
||||||
const [target, setTarget] = useState<Object3D | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lineId) {
|
|
||||||
setTarget(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let frameId = 0
|
|
||||||
const resolve = () => {
|
|
||||||
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
|
|
||||||
setTarget((cur) => (cur === next ? cur : next))
|
|
||||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
return () => window.cancelAnimationFrame(frameId)
|
|
||||||
}, [lineId])
|
|
||||||
|
|
||||||
if (!line || !target) return null
|
|
||||||
return createPortal(<LiquidLinePointHandles line={line} target={target} />, target, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
|
|
||||||
const { camera, gl } = useThree()
|
|
||||||
const unit = useViewer((s) => s.unit)
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
|
||||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
|
|
||||||
const dragRef = useRef<{
|
|
||||||
index: number
|
|
||||||
initialPath: Point[]
|
|
||||||
current: Point
|
|
||||||
cleanup: () => void
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
const makeRay = (clientX: number, clientY: number) => {
|
|
||||||
const rect = gl.domElement.getBoundingClientRect()
|
|
||||||
const ndc = new Vector2(
|
|
||||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
|
||||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
|
||||||
)
|
|
||||||
const raycaster = new Raycaster()
|
|
||||||
raycaster.setFromCamera(ndc, camera)
|
|
||||||
return raycaster.ray
|
|
||||||
}
|
|
||||||
|
|
||||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
|
||||||
const hit = new Vector3()
|
|
||||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectOntoAxis = (
|
|
||||||
clientX: number,
|
|
||||||
clientY: number,
|
|
||||||
anchorWorld: Vector3,
|
|
||||||
axisWorld: Vector3,
|
|
||||||
): number | null => {
|
|
||||||
const ray = makeRay(clientX, clientY)
|
|
||||||
const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
|
|
||||||
const b = ray.direction.dot(axisWorld)
|
|
||||||
const denom = 1 - b * b
|
|
||||||
if (Math.abs(denom) < 1e-6) return null
|
|
||||||
const d0 = ray.direction.dot(w0)
|
|
||||||
const e0 = axisWorld.dot(w0)
|
|
||||||
return (e0 - b * d0) / denom
|
|
||||||
}
|
|
||||||
|
|
||||||
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
|
|
||||||
const toLocal = (world: Vector3): Point => {
|
|
||||||
const local = target.worldToLocal(world.clone())
|
|
||||||
return [local.x, local.y, local.z]
|
|
||||||
}
|
|
||||||
|
|
||||||
const onHandleDown = (index: number) => (e: ThreeEvent<PointerEvent>) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
const initialPath = line.path.map((p) => [...p] as Point)
|
|
||||||
const startPoint = initialPath[index]!
|
|
||||||
pauseSceneHistory(useScene)
|
|
||||||
useViewer.getState().setInputDragging(true)
|
|
||||||
document.body.style.cursor = 'grabbing'
|
|
||||||
setDraggingIndex(index)
|
|
||||||
|
|
||||||
const isEndpoint = index === 0 || index === initialPath.length - 1
|
|
||||||
|
|
||||||
const neighbor = initialPath[index === 0 ? 1 : index - 1]!
|
|
||||||
const axisLocal = new Vector3(
|
|
||||||
startPoint[0] - neighbor[0],
|
|
||||||
startPoint[1] - neighbor[1],
|
|
||||||
startPoint[2] - neighbor[2],
|
|
||||||
)
|
|
||||||
if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
|
|
||||||
axisLocal.normalize()
|
|
||||||
const anchorWorldStart = toWorld(startPoint)
|
|
||||||
const axisWorld = toWorld([
|
|
||||||
startPoint[0] + axisLocal.x,
|
|
||||||
startPoint[1] + axisLocal.y,
|
|
||||||
startPoint[2] + axisLocal.z,
|
|
||||||
])
|
|
||||||
.sub(anchorWorldStart)
|
|
||||||
.normalize()
|
|
||||||
|
|
||||||
const onMove = (event: PointerEvent) => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag) return
|
|
||||||
const current = drag.current
|
|
||||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
|
||||||
let next: Point | null = null
|
|
||||||
if (event.altKey) {
|
|
||||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
|
|
||||||
const hit = intersect(event.clientX, event.clientY, plane)
|
|
||||||
if (hit) {
|
|
||||||
const local = toLocal(hit)
|
|
||||||
next = [snap(local[0], step), current[1], snap(local[2], step)]
|
|
||||||
if (isEndpoint) {
|
|
||||||
const port = findNearestPortXZ(
|
|
||||||
[local[0], current[1], local[2]],
|
|
||||||
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
|
|
||||||
PORT_SNAP_RADIUS_M,
|
|
||||||
)
|
|
||||||
if (port) next = [port.position[0], port.position[1], port.position[2]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
|
|
||||||
if (t !== null) {
|
|
||||||
const dist = snap(t, step)
|
|
||||||
next = [
|
|
||||||
startPoint[0] + axisLocal.x * dist,
|
|
||||||
Math.max(0, startPoint[1] + axisLocal.y * dist),
|
|
||||||
startPoint[2] + axisLocal.z * dist,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!next) return
|
|
||||||
if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
|
|
||||||
drag.current = next
|
|
||||||
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
|
|
||||||
useScene.getState().updateNode(line.id, { path })
|
|
||||||
}
|
|
||||||
|
|
||||||
const onUp = () => {
|
|
||||||
const drag = dragRef.current
|
|
||||||
if (!drag) return
|
|
||||||
drag.cleanup()
|
|
||||||
dragRef.current = null
|
|
||||||
setDraggingIndex(null)
|
|
||||||
const finalPath = drag.initialPath.map((p, i) =>
|
|
||||||
i === drag.index ? drag.current : p,
|
|
||||||
) as Point[]
|
|
||||||
useScene.getState().updateNode(line.id, { path: drag.initialPath })
|
|
||||||
resumeSceneHistory(useScene)
|
|
||||||
const moved = finalPath[drag.index]!.some(
|
|
||||||
(v, axis) => v !== drag.initialPath[drag.index]![axis],
|
|
||||||
)
|
|
||||||
if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
|
|
||||||
}
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
window.removeEventListener('pointermove', onMove)
|
|
||||||
window.removeEventListener('pointerup', onUp)
|
|
||||||
window.removeEventListener('pointercancel', onUp)
|
|
||||||
useViewer.getState().setInputDragging(false)
|
|
||||||
document.body.style.cursor = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
dragRef.current = { index, initialPath, current: startPoint, cleanup }
|
|
||||||
window.addEventListener('pointermove', onMove)
|
|
||||||
window.addEventListener('pointerup', onUp)
|
|
||||||
window.addEventListener('pointercancel', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group>
|
|
||||||
{line.path.map((p, i) => {
|
|
||||||
const active = draggingIndex === i
|
|
||||||
const hovered = hoverIndex === i
|
|
||||||
return (
|
|
||||||
<mesh
|
|
||||||
key={`liquid-line-handle-${i}`}
|
|
||||||
layers={EDITOR_LAYER}
|
|
||||||
onPointerDown={onHandleDown(i)}
|
|
||||||
onPointerEnter={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setHoverIndex(i)
|
|
||||||
if (draggingIndex === null) document.body.style.cursor = 'grab'
|
|
||||||
}}
|
|
||||||
onPointerLeave={() => {
|
|
||||||
setHoverIndex((prev) => (prev === i ? null : prev))
|
|
||||||
if (draggingIndex === null) document.body.style.cursor = ''
|
|
||||||
}}
|
|
||||||
position={p as Point}
|
|
||||||
>
|
|
||||||
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
|
|
||||||
<meshBasicMaterial
|
|
||||||
color={active || hovered ? '#a5b4fc' : '#818cf8'}
|
|
||||||
depthTest={false}
|
|
||||||
opacity={active ? 1 : 0.85}
|
|
||||||
transparent
|
|
||||||
/>
|
|
||||||
</mesh>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{draggingIndex !== null &&
|
|
||||||
line.path[draggingIndex] &&
|
|
||||||
(() => {
|
|
||||||
const point = line.path[draggingIndex]!
|
|
||||||
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
|
|
||||||
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
|
|
||||||
const axes = ['x', 'y', 'z'] as const
|
|
||||||
const primary = axes.reduce((best, axis, i) =>
|
|
||||||
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Html
|
|
||||||
center
|
|
||||||
position={[point[0], point[1] + 0.35, point[2]]}
|
|
||||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
|
||||||
zIndexRange={[100, 0]}
|
|
||||||
>
|
|
||||||
<DimensionPill
|
|
||||||
parts={axes.map((axis, i) => ({
|
|
||||||
key: axis,
|
|
||||||
prefix: axis.toUpperCase(),
|
|
||||||
value: deltas[i]!,
|
|
||||||
signed: true,
|
|
||||||
}))}
|
|
||||||
primary={primary}
|
|
||||||
unit={unit}
|
|
||||||
/>
|
|
||||||
</Html>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</group>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LiquidLineSelectionAffordance
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user