diff --git a/AGENTS.md b/AGENTS.md index d55de298..9ddb52fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ Read the relevant page in `wiki/architecture/` **before** writing code. The page - Adding a node type → `node-schemas.md`, `renderers.md`, `systems.md` - Adding a tool → `tools.md`, `spatial-queries.md`, `events.md` +- Adding / changing a placement or move interaction → `tools.md` ("2D ↔ 3D behavioral parity": applicable behaviors must exist in both views; port the change to the sibling 2D/3D file in the same PR) - Adding a system → `systems.md`, `scene-registry.md` - Anything in `packages/viewer` → `viewer-isolation.md`, `layers.md` - Anything touching selection → `selection-managers.md`, `scene-registry.md`, `events.md` diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index a20826c0..e4ccf7ed 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -29,7 +29,7 @@ const SIDEBAR_TABS = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/scene.png" + src="/icons/scene.webp" width={32} /> ), @@ -45,7 +45,7 @@ const SIDEBAR_TABS = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/build.png" + src="/icons/build.webp" width={32} /> ), @@ -61,7 +61,7 @@ const SIDEBAR_TABS = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/couch.png" + src="/icons/couch.webp" width={32} /> ), @@ -77,7 +77,7 @@ const SIDEBAR_TABS = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/settings.png" + src="/icons/settings.webp" width={32} /> ), diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 90407613..99fb55d9 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -2,6 +2,7 @@ import { nodeRegistry } from '@pascal-app/core' import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor' +import { useLiquidLineToolOptions } from '@pascal-app/nodes' import Image from 'next/image' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { @@ -30,39 +31,82 @@ type BuildToolKind = | 'shelf' | 'spawn' +/** + * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" + * group tile — its own sub-grid, like Roof's "Features". + */ +type MepToolKind = + | 'duct-segment' + | 'duct-fitting' + | 'duct-terminal' + | 'hvac-equipment' + | 'lineset' + | 'liquid-line' + | 'pipe-segment' + | 'pipe-fitting' + | 'pipe-trap' + type BuildType = { - /** Selection id — equals `kind` for tool types, `'painting'` for paint mode. */ + /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */ id: string label: string + /** Raster asset tile (legacy Build sidebar artwork). */ iconSrc: string - /** Present for structure-tool types (absent for the paint mode). */ + /** Present for structure-tool types (absent for paint mode and the MEP group). */ kind?: BuildToolKind /** Non-placement special mode. */ mode?: 'material-paint' } +type MepItem = { + /** Selection id — equals `kind`. */ + id: string + label: string + iconSrc: string + kind: MepToolKind +} + // Same icons + ordering as the community Build sidebar, minus presets. const BUILD_TYPES: BuildType[] = [ - { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.png', kind: 'wall' }, - { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.png', kind: 'fence' }, - { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.png', kind: 'slab' }, - { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.png', kind: 'ceiling' }, - { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.png', kind: 'roof' }, - { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.png', kind: 'stair' }, - { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.png', kind: 'elevator' }, - { id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' }, - { id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' }, - { id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' }, - { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' }, - { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.png', kind: 'spawn' }, - { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' }, + { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, + { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, + { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, + { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, + { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, + { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, + { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, + { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, + { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, + { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, + { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). + { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, + { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, +] + +// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP +// tools had in the community Build sidebar. +const MEP_ITEMS: MepItem[] = [ + { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, + { + id: 'duct-terminal', + label: 'Register', + iconSrc: '/icons/registers.webp', + kind: 'duct-terminal', + }, + { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, + { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, + { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, + { id: 'pipe-trap', label: 'Trap', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-trap' }, ] /** * Activate a raw structure draw/cursor tool. Mirrors the editor's own * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). */ -function activateBuildTool(kind: BuildToolKind): void { +function activateBuildTool(kind: BuildToolKind | MepToolKind): void { const ed = useEditor.getState() ed.setPhase('structure') ed.setStructureLayer('elements') @@ -82,7 +126,7 @@ function activatePaintMode(): void { type RoofFeature = { kind: string; label: string; iconSrc: string } -const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.png' +const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' /** * Roof accessories surfaced under the Roof tile (a "Features" group). Unlike @@ -111,10 +155,31 @@ function activateRoofFeatureTool(kind: string): void { export function BuildTab() { const activeTool = useEditor((s) => s.tool) const mode = useEditor((s) => s.mode) - // Which build tile's panel is showing. Roof is the only tile with a panel - // (its Features group); others arm a tool and show nothing below. + const follow = useLiquidLineToolOptions((s) => s.follow) + const toggleFollow = useLiquidLineToolOptions((s) => s.toggleFollow) + // Which build tile's panel is showing. Roof (Features) and MEP (its tool + // sub-grid) are the tiles with a panel; others arm a tool and show nothing + // below. const [selectedTypeId, setSelectedTypeId] = useState(null) + // The fitting / follow tools are armed from a segment's panel, not a grid + // tile — keep the segment tile lit so the panel (and the way back) stays + // visible. + const ductContext = + mode === 'build' && (activeTool === 'duct-segment' || activeTool === 'duct-fitting') + const pipeContext = + mode === 'build' && (activeTool === 'pipe-segment' || activeTool === 'pipe-fitting') + const liquidLineContext = mode === 'build' && activeTool === 'liquid-line' + + const isMepItemActive = (item: MepItem) => + item.kind === 'duct-segment' + ? ductContext + : item.kind === 'pipe-segment' + ? pipeContext + : item.kind === 'liquid-line' + ? liquidLineContext + : mode === 'build' && activeTool === item.kind + // Read at render time (not module scope): the registry is populated by the // app bootstrap, so enumerating earlier would race it and see no kinds. const roofFeatures = useMemo(() => { @@ -141,6 +206,10 @@ export function BuildTab() { const handleTypeClick = useCallback((type: BuildType) => { if (type.mode === 'material-paint') { activatePaintMode() + } else if (type.id === 'mep') { + // MEP is a group tile: arm its first tool so a usable tool is active + // (and we leave any prior paint mode), then reveal the MEP sub-grid. + activateBuildTool('duct-segment') } else if (type.kind) { activateBuildTool(type.kind) } @@ -250,6 +319,137 @@ export function BuildTab() { + ) : selectedTypeId === 'mep' ? ( +
+
MEP
+ +
+ {MEP_ITEMS.map((item) => { + const active = isMepItemActive(item) + return ( + + + + + + {item.label} + + + ) + })} +
+
+ + {ductContext ? ( +
+ Duct + +
+ ) : null} + + {pipeContext ? ( +
+ DWV Pipe + +
+ ) : null} + + {liquidLineContext ? ( +
+ Liquid Line + + + {follow + ? 'Click a lineset to lay the line beside it.' + : 'Trace a line alongside an existing lineset (F).'} + +
+ ) : null} +
) : null} ) diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 0a602be1..34acd2e2 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -42,7 +42,7 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/scene.png" + src="/icons/scene.webp" width={32} /> ), @@ -58,7 +58,7 @@ const SIDEBAR_TABS: (SidebarTab & { component: React.ComponentType })[] = [ alt="" className="h-8 w-8 object-contain" height={32} - src="/icons/build.png" + src="/icons/build.webp" width={32} /> ), diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 49638840..225f7237 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -86,7 +86,7 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ alt="" className="h-3.5 w-3.5 object-contain" height={14} - src="/icons/building.png" + src="/icons/building.webp" width={14} /> ), @@ -99,7 +99,7 @@ const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [ alt="" className="h-3.5 w-3.5 object-contain" height={14} - src="/icons/blueprint.png" + src="/icons/blueprint.webp" width={14} /> ), @@ -121,9 +121,9 @@ const levelModeLabels: Record = { const wallModeOrder = ['cutaway', 'up', 'down'] as const const wallModeConfig: Record = { - up: { icon: '/icons/room.png', label: 'Full height' }, - cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' }, - down: { icon: '/icons/walllow.png', label: 'Low' }, + up: { icon: '/icons/room.webp', label: 'Full height' }, + cutaway: { icon: '/icons/wallcut.webp', label: 'Cutaway' }, + down: { icon: '/icons/walllow.webp', label: 'Low' }, } const SHADING_OPTIONS = [ diff --git a/apps/editor/public/icons/HVAC.webp b/apps/editor/public/icons/HVAC.webp new file mode 100644 index 00000000..38514409 Binary files /dev/null and b/apps/editor/public/icons/HVAC.webp differ diff --git a/apps/editor/public/icons/appliance.png b/apps/editor/public/icons/appliance.png deleted file mode 100644 index c2bb0bae..00000000 Binary files a/apps/editor/public/icons/appliance.png and /dev/null differ diff --git a/apps/editor/public/icons/appliance.webp b/apps/editor/public/icons/appliance.webp new file mode 100644 index 00000000..7d729ebc Binary files /dev/null and b/apps/editor/public/icons/appliance.webp differ diff --git a/apps/editor/public/icons/bathroom.png b/apps/editor/public/icons/bathroom.png deleted file mode 100644 index fafec7cc..00000000 Binary files a/apps/editor/public/icons/bathroom.png and /dev/null differ diff --git a/apps/editor/public/icons/bathroom.webp b/apps/editor/public/icons/bathroom.webp new file mode 100644 index 00000000..e913c3dc Binary files /dev/null and b/apps/editor/public/icons/bathroom.webp differ diff --git a/apps/editor/public/icons/blueprint.png b/apps/editor/public/icons/blueprint.png deleted file mode 100644 index 6df91b1e..00000000 Binary files a/apps/editor/public/icons/blueprint.png and /dev/null differ diff --git a/apps/editor/public/icons/blueprint.webp b/apps/editor/public/icons/blueprint.webp new file mode 100644 index 00000000..ee0f60db Binary files /dev/null and b/apps/editor/public/icons/blueprint.webp differ diff --git a/apps/editor/public/icons/build.png b/apps/editor/public/icons/build.png deleted file mode 100644 index cfaead98..00000000 Binary files a/apps/editor/public/icons/build.png and /dev/null differ diff --git a/apps/editor/public/icons/build.webp b/apps/editor/public/icons/build.webp new file mode 100644 index 00000000..0f57eb3f Binary files /dev/null and b/apps/editor/public/icons/build.webp differ diff --git a/apps/editor/public/icons/building.png b/apps/editor/public/icons/building.png deleted file mode 100644 index 1d377680..00000000 Binary files a/apps/editor/public/icons/building.png and /dev/null differ diff --git a/apps/editor/public/icons/building.webp b/apps/editor/public/icons/building.webp new file mode 100644 index 00000000..6aea0ab1 Binary files /dev/null and b/apps/editor/public/icons/building.webp differ diff --git a/apps/editor/public/icons/ceiling.png b/apps/editor/public/icons/ceiling.png deleted file mode 100644 index 9e04d639..00000000 Binary files a/apps/editor/public/icons/ceiling.png and /dev/null differ diff --git a/apps/editor/public/icons/ceiling.webp b/apps/editor/public/icons/ceiling.webp new file mode 100644 index 00000000..3abfa4cf Binary files /dev/null and b/apps/editor/public/icons/ceiling.webp differ diff --git a/apps/editor/public/icons/collection.png b/apps/editor/public/icons/collection.png deleted file mode 100644 index 32b7ccd0..00000000 Binary files a/apps/editor/public/icons/collection.png and /dev/null differ diff --git a/apps/editor/public/icons/collection.webp b/apps/editor/public/icons/collection.webp new file mode 100644 index 00000000..af45600c Binary files /dev/null and b/apps/editor/public/icons/collection.webp differ diff --git a/apps/editor/public/icons/column.png b/apps/editor/public/icons/column.png deleted file mode 100644 index 0e337b7f..00000000 Binary files a/apps/editor/public/icons/column.png and /dev/null differ diff --git a/apps/editor/public/icons/column.webp b/apps/editor/public/icons/column.webp new file mode 100644 index 00000000..6259cc30 Binary files /dev/null and b/apps/editor/public/icons/column.webp differ diff --git a/apps/editor/public/icons/column1.png b/apps/editor/public/icons/column1.png deleted file mode 100644 index 8e44f6d8..00000000 Binary files a/apps/editor/public/icons/column1.png and /dev/null differ diff --git a/apps/editor/public/icons/column1.webp b/apps/editor/public/icons/column1.webp new file mode 100644 index 00000000..27db894e Binary files /dev/null and b/apps/editor/public/icons/column1.webp differ diff --git a/apps/editor/public/icons/couch.png b/apps/editor/public/icons/couch.png deleted file mode 100644 index 6509e005..00000000 Binary files a/apps/editor/public/icons/couch.png and /dev/null differ diff --git a/apps/editor/public/icons/couch.webp b/apps/editor/public/icons/couch.webp new file mode 100644 index 00000000..1f7a8e34 Binary files /dev/null and b/apps/editor/public/icons/couch.webp differ diff --git a/apps/editor/public/icons/cube.png b/apps/editor/public/icons/cube.png deleted file mode 100644 index c279d41b..00000000 Binary files a/apps/editor/public/icons/cube.png and /dev/null differ diff --git a/apps/editor/public/icons/cube.webp b/apps/editor/public/icons/cube.webp new file mode 100644 index 00000000..b9922c13 Binary files /dev/null and b/apps/editor/public/icons/cube.webp differ diff --git a/apps/editor/public/icons/custom-room.png b/apps/editor/public/icons/custom-room.png deleted file mode 100644 index 14d40459..00000000 Binary files a/apps/editor/public/icons/custom-room.png and /dev/null differ diff --git a/apps/editor/public/icons/custom-room.webp b/apps/editor/public/icons/custom-room.webp new file mode 100644 index 00000000..97bc509f Binary files /dev/null and b/apps/editor/public/icons/custom-room.webp differ diff --git a/apps/editor/public/icons/door.png b/apps/editor/public/icons/door.png deleted file mode 100644 index 8b045246..00000000 Binary files a/apps/editor/public/icons/door.png and /dev/null differ diff --git a/apps/editor/public/icons/door.webp b/apps/editor/public/icons/door.webp new file mode 100644 index 00000000..7d6980fe Binary files /dev/null and b/apps/editor/public/icons/door.webp differ diff --git a/apps/editor/public/icons/duct-fitting.webp b/apps/editor/public/icons/duct-fitting.webp new file mode 100644 index 00000000..43a76fc6 Binary files /dev/null and b/apps/editor/public/icons/duct-fitting.webp differ diff --git a/apps/editor/public/icons/duct.webp b/apps/editor/public/icons/duct.webp new file mode 100644 index 00000000..0d0e2579 Binary files /dev/null and b/apps/editor/public/icons/duct.webp differ diff --git a/apps/editor/public/icons/dwv-pipes.webp b/apps/editor/public/icons/dwv-pipes.webp new file mode 100644 index 00000000..85e3d60f Binary files /dev/null and b/apps/editor/public/icons/dwv-pipes.webp differ diff --git a/apps/editor/public/icons/elevator.png b/apps/editor/public/icons/elevator.png deleted file mode 100644 index d0278d62..00000000 Binary files a/apps/editor/public/icons/elevator.png and /dev/null differ diff --git a/apps/editor/public/icons/elevator.webp b/apps/editor/public/icons/elevator.webp new file mode 100644 index 00000000..51cf8e14 Binary files /dev/null and b/apps/editor/public/icons/elevator.webp differ diff --git a/apps/editor/public/icons/environment.png b/apps/editor/public/icons/environment.png deleted file mode 100644 index 2bc71b11..00000000 Binary files a/apps/editor/public/icons/environment.png and /dev/null differ diff --git a/apps/editor/public/icons/environment.webp b/apps/editor/public/icons/environment.webp new file mode 100644 index 00000000..b67f8d32 Binary files /dev/null and b/apps/editor/public/icons/environment.webp differ diff --git a/apps/editor/public/icons/fence.png b/apps/editor/public/icons/fence.png deleted file mode 100644 index 47f677f5..00000000 Binary files a/apps/editor/public/icons/fence.png and /dev/null differ diff --git a/apps/editor/public/icons/fence.webp b/apps/editor/public/icons/fence.webp new file mode 100644 index 00000000..ba63a1df Binary files /dev/null and b/apps/editor/public/icons/fence.webp differ diff --git a/apps/editor/public/icons/floor.png b/apps/editor/public/icons/floor.png deleted file mode 100644 index f2ce9f49..00000000 Binary files a/apps/editor/public/icons/floor.png and /dev/null differ diff --git a/apps/editor/public/icons/floor.webp b/apps/editor/public/icons/floor.webp new file mode 100644 index 00000000..3796b1c8 Binary files /dev/null and b/apps/editor/public/icons/floor.webp differ diff --git a/apps/editor/public/icons/floorplan.png b/apps/editor/public/icons/floorplan.png deleted file mode 100644 index 456dce36..00000000 Binary files a/apps/editor/public/icons/floorplan.png and /dev/null differ diff --git a/apps/editor/public/icons/floorplan.webp b/apps/editor/public/icons/floorplan.webp new file mode 100644 index 00000000..4343774b Binary files /dev/null and b/apps/editor/public/icons/floorplan.webp differ diff --git a/apps/editor/public/icons/item.png b/apps/editor/public/icons/item.png deleted file mode 100644 index ae3fdc25..00000000 Binary files a/apps/editor/public/icons/item.png and /dev/null differ diff --git a/apps/editor/public/icons/item.webp b/apps/editor/public/icons/item.webp new file mode 100644 index 00000000..972ad4f4 Binary files /dev/null and b/apps/editor/public/icons/item.webp differ diff --git a/apps/editor/public/icons/kitchen.png b/apps/editor/public/icons/kitchen.png deleted file mode 100644 index 1bffefe4..00000000 Binary files a/apps/editor/public/icons/kitchen.png and /dev/null differ diff --git a/apps/editor/public/icons/kitchen.webp b/apps/editor/public/icons/kitchen.webp new file mode 100644 index 00000000..8b2084a8 Binary files /dev/null and b/apps/editor/public/icons/kitchen.webp differ diff --git a/apps/editor/public/icons/level.png b/apps/editor/public/icons/level.png deleted file mode 100644 index 4b931fd0..00000000 Binary files a/apps/editor/public/icons/level.png and /dev/null differ diff --git a/apps/editor/public/icons/level.webp b/apps/editor/public/icons/level.webp new file mode 100644 index 00000000..6537ebd9 Binary files /dev/null and b/apps/editor/public/icons/level.webp differ diff --git a/apps/editor/public/icons/lineset.webp b/apps/editor/public/icons/lineset.webp new file mode 100644 index 00000000..fe1084f9 Binary files /dev/null and b/apps/editor/public/icons/lineset.webp differ diff --git a/apps/editor/public/icons/mesh.png b/apps/editor/public/icons/mesh.png deleted file mode 100644 index ecd7a21e..00000000 Binary files a/apps/editor/public/icons/mesh.png and /dev/null differ diff --git a/apps/editor/public/icons/mesh.webp b/apps/editor/public/icons/mesh.webp new file mode 100644 index 00000000..0f5ee297 Binary files /dev/null and b/apps/editor/public/icons/mesh.webp differ diff --git a/apps/editor/public/icons/orbit.png b/apps/editor/public/icons/orbit.png deleted file mode 100644 index d47d9d8c..00000000 Binary files a/apps/editor/public/icons/orbit.png and /dev/null differ diff --git a/apps/editor/public/icons/orbit.webp b/apps/editor/public/icons/orbit.webp new file mode 100644 index 00000000..60e428d7 Binary files /dev/null and b/apps/editor/public/icons/orbit.webp differ diff --git a/apps/editor/public/icons/paint.png b/apps/editor/public/icons/paint.png deleted file mode 100644 index d66167bb..00000000 Binary files a/apps/editor/public/icons/paint.png and /dev/null differ diff --git a/apps/editor/public/icons/paint.webp b/apps/editor/public/icons/paint.webp new file mode 100644 index 00000000..1c5a83cc Binary files /dev/null and b/apps/editor/public/icons/paint.webp differ diff --git a/apps/editor/public/icons/pan.png b/apps/editor/public/icons/pan.png deleted file mode 100644 index 8cf276a9..00000000 Binary files a/apps/editor/public/icons/pan.png and /dev/null differ diff --git a/apps/editor/public/icons/pan.webp b/apps/editor/public/icons/pan.webp new file mode 100644 index 00000000..4b5b5809 Binary files /dev/null and b/apps/editor/public/icons/pan.webp differ diff --git a/apps/editor/public/icons/registers.webp b/apps/editor/public/icons/registers.webp new file mode 100644 index 00000000..b36f090c Binary files /dev/null and b/apps/editor/public/icons/registers.webp differ diff --git a/apps/editor/public/icons/roof.png b/apps/editor/public/icons/roof.png deleted file mode 100644 index 253c1d76..00000000 Binary files a/apps/editor/public/icons/roof.png and /dev/null differ diff --git a/apps/editor/public/icons/roof.webp b/apps/editor/public/icons/roof.webp new file mode 100644 index 00000000..2d40e171 Binary files /dev/null and b/apps/editor/public/icons/roof.webp differ diff --git a/apps/editor/public/icons/room.png b/apps/editor/public/icons/room.png deleted file mode 100644 index 85a179b4..00000000 Binary files a/apps/editor/public/icons/room.png and /dev/null differ diff --git a/apps/editor/public/icons/room.webp b/apps/editor/public/icons/room.webp new file mode 100644 index 00000000..d43ed6e8 Binary files /dev/null and b/apps/editor/public/icons/room.webp differ diff --git a/apps/editor/public/icons/rotate.png b/apps/editor/public/icons/rotate.png deleted file mode 100644 index 4b840d2f..00000000 Binary files a/apps/editor/public/icons/rotate.png and /dev/null differ diff --git a/apps/editor/public/icons/rotate.webp b/apps/editor/public/icons/rotate.webp new file mode 100644 index 00000000..2e9f0037 Binary files /dev/null and b/apps/editor/public/icons/rotate.webp differ diff --git a/apps/editor/public/icons/scene.png b/apps/editor/public/icons/scene.png deleted file mode 100644 index 99036b8c..00000000 Binary files a/apps/editor/public/icons/scene.png and /dev/null differ diff --git a/apps/editor/public/icons/scene.webp b/apps/editor/public/icons/scene.webp new file mode 100644 index 00000000..8a5cdd78 Binary files /dev/null and b/apps/editor/public/icons/scene.webp differ diff --git a/apps/editor/public/icons/select.png b/apps/editor/public/icons/select.png deleted file mode 100644 index 9abd1db3..00000000 Binary files a/apps/editor/public/icons/select.png and /dev/null differ diff --git a/apps/editor/public/icons/select.webp b/apps/editor/public/icons/select.webp new file mode 100644 index 00000000..85a59f7f Binary files /dev/null and b/apps/editor/public/icons/select.webp differ diff --git a/apps/editor/public/icons/settings.png b/apps/editor/public/icons/settings.png deleted file mode 100644 index 14cb0c68..00000000 Binary files a/apps/editor/public/icons/settings.png and /dev/null differ diff --git a/apps/editor/public/icons/settings.webp b/apps/editor/public/icons/settings.webp new file mode 100644 index 00000000..66c7765e Binary files /dev/null and b/apps/editor/public/icons/settings.webp differ diff --git a/apps/editor/public/icons/shelf.png b/apps/editor/public/icons/shelf.png deleted file mode 100644 index 84845573..00000000 Binary files a/apps/editor/public/icons/shelf.png and /dev/null differ diff --git a/apps/editor/public/icons/shelf.webp b/apps/editor/public/icons/shelf.webp new file mode 100644 index 00000000..0f7a0a64 Binary files /dev/null and b/apps/editor/public/icons/shelf.webp differ diff --git a/apps/editor/public/icons/site-flag.png b/apps/editor/public/icons/site-flag.png deleted file mode 100644 index 593e7192..00000000 Binary files a/apps/editor/public/icons/site-flag.png and /dev/null differ diff --git a/apps/editor/public/icons/site-flag.webp b/apps/editor/public/icons/site-flag.webp new file mode 100644 index 00000000..fbb25ca2 Binary files /dev/null and b/apps/editor/public/icons/site-flag.webp differ diff --git a/apps/editor/public/icons/site.png b/apps/editor/public/icons/site.png deleted file mode 100644 index 0d37688a..00000000 Binary files a/apps/editor/public/icons/site.png and /dev/null differ diff --git a/apps/editor/public/icons/site.webp b/apps/editor/public/icons/site.webp new file mode 100644 index 00000000..46f038a1 Binary files /dev/null and b/apps/editor/public/icons/site.webp differ diff --git a/apps/editor/public/icons/spawn-point.png b/apps/editor/public/icons/spawn-point.png deleted file mode 100644 index 0c2b05d3..00000000 Binary files a/apps/editor/public/icons/spawn-point.png and /dev/null differ diff --git a/apps/editor/public/icons/spawn-point.webp b/apps/editor/public/icons/spawn-point.webp new file mode 100644 index 00000000..0b34c36f Binary files /dev/null and b/apps/editor/public/icons/spawn-point.webp differ diff --git a/apps/editor/public/icons/stairs.png b/apps/editor/public/icons/stairs.png deleted file mode 100644 index 6c9f1a65..00000000 Binary files a/apps/editor/public/icons/stairs.png and /dev/null differ diff --git a/apps/editor/public/icons/stairs.webp b/apps/editor/public/icons/stairs.webp new file mode 100644 index 00000000..efd133b8 Binary files /dev/null and b/apps/editor/public/icons/stairs.webp differ diff --git a/apps/editor/public/icons/topview.png b/apps/editor/public/icons/topview.png deleted file mode 100644 index 35c96248..00000000 Binary files a/apps/editor/public/icons/topview.png and /dev/null differ diff --git a/apps/editor/public/icons/topview.webp b/apps/editor/public/icons/topview.webp new file mode 100644 index 00000000..91920a20 Binary files /dev/null and b/apps/editor/public/icons/topview.webp differ diff --git a/apps/editor/public/icons/tree.png b/apps/editor/public/icons/tree.png deleted file mode 100644 index ae3fdc25..00000000 Binary files a/apps/editor/public/icons/tree.png and /dev/null differ diff --git a/apps/editor/public/icons/tree.webp b/apps/editor/public/icons/tree.webp new file mode 100644 index 00000000..972ad4f4 Binary files /dev/null and b/apps/editor/public/icons/tree.webp differ diff --git a/apps/editor/public/icons/wall.png b/apps/editor/public/icons/wall.png deleted file mode 100644 index 465e3e9d..00000000 Binary files a/apps/editor/public/icons/wall.png and /dev/null differ diff --git a/apps/editor/public/icons/wall.webp b/apps/editor/public/icons/wall.webp new file mode 100644 index 00000000..78649e52 Binary files /dev/null and b/apps/editor/public/icons/wall.webp differ diff --git a/apps/editor/public/icons/wallcut.png b/apps/editor/public/icons/wallcut.png deleted file mode 100644 index bedcf614..00000000 Binary files a/apps/editor/public/icons/wallcut.png and /dev/null differ diff --git a/apps/editor/public/icons/wallcut.webp b/apps/editor/public/icons/wallcut.webp new file mode 100644 index 00000000..cd089d5d Binary files /dev/null and b/apps/editor/public/icons/wallcut.webp differ diff --git a/apps/editor/public/icons/walllow.png b/apps/editor/public/icons/walllow.png deleted file mode 100644 index 7ec71abd..00000000 Binary files a/apps/editor/public/icons/walllow.png and /dev/null differ diff --git a/apps/editor/public/icons/walllow.webp b/apps/editor/public/icons/walllow.webp new file mode 100644 index 00000000..427eafa2 Binary files /dev/null and b/apps/editor/public/icons/walllow.webp differ diff --git a/apps/editor/public/icons/window.png b/apps/editor/public/icons/window.png deleted file mode 100644 index ec62d0a7..00000000 Binary files a/apps/editor/public/icons/window.png and /dev/null differ diff --git a/apps/editor/public/icons/window.webp b/apps/editor/public/icons/window.webp new file mode 100644 index 00000000..4fc1c060 Binary files /dev/null and b/apps/editor/public/icons/window.webp differ diff --git a/apps/editor/public/icons/zone.png b/apps/editor/public/icons/zone.png deleted file mode 100644 index 32b7ccd0..00000000 Binary files a/apps/editor/public/icons/zone.png and /dev/null differ diff --git a/apps/editor/public/icons/zone.webp b/apps/editor/public/icons/zone.webp new file mode 100644 index 00000000..af45600c Binary files /dev/null and b/apps/editor/public/icons/zone.webp differ diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx index 6e9c2f30..829a36d5 100644 --- a/apps/ifc-converter/components/IfcConverter.tsx +++ b/apps/ifc-converter/components/IfcConverter.tsx @@ -115,6 +115,7 @@ export default function IfcConverter() { return results }, [pascalData, searchQuery]) + // biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount to load the initial file from the URL. useEffect(() => { const params = new URLSearchParams(window.location.search) const requested = params.get('file') @@ -201,6 +202,7 @@ export default function IfcConverter() { } } + // biome-ignore lint/correctness/useExhaustiveDependencies: stable drop handler; handleFile only calls setState setters, so a mount-time capture stays correct. const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragging(false) @@ -234,7 +236,7 @@ export default function IfcConverter() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = fileName.replace('.ifc', '') + '_pascal.json' + a.download = `${fileName.replace('.ifc', '')}_pascal.json` a.click() URL.revokeObjectURL(url) } diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 99bc7e83..6374cbb4 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -11,13 +11,22 @@ import type { DoorNode, DormerNode, DownspoutNode, + DuctFittingNode, + DuctSegmentNode, + DuctTerminalNode, ElevatorNode, EyebrowVentNode, FenceNode, GuideNode, GutterNode, + HvacEquipmentNode, ItemNode, LevelNode, + LinesetNode, + LiquidLineNode, + PipeFittingNode, + PipeSegmentNode, + PipeTrapNode, RidgeVentNode, RoofNode, RoofSegmentNode, @@ -107,6 +116,15 @@ export type SolarPanelEvent = NodeEvent export type SkylightEvent = NodeEvent export type DormerEvent = NodeEvent export type DownspoutEvent = NodeEvent +export type DuctSegmentEvent = NodeEvent +export type DuctFittingEvent = NodeEvent +export type DuctTerminalEvent = NodeEvent +export type HvacEquipmentEvent = NodeEvent +export type PipeSegmentEvent = NodeEvent +export type PipeFittingEvent = NodeEvent +export type PipeTrapEvent = NodeEvent +export type LinesetEvent = NodeEvent +export type LiquidLineEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -261,6 +279,15 @@ type EditorEvents = GridEvents & NodeEvents<'skylight', SkylightEvent> & NodeEvents<'dormer', DormerEvent> & NodeEvents<'downspout', DownspoutEvent> & + NodeEvents<'duct-segment', DuctSegmentEvent> & + NodeEvents<'duct-fitting', DuctFittingEvent> & + NodeEvents<'duct-terminal', DuctTerminalEvent> & + NodeEvents<'hvac-equipment', HvacEquipmentEvent> & + NodeEvents<'pipe-segment', PipeSegmentEvent> & + NodeEvents<'pipe-fitting', PipeFittingEvent> & + NodeEvents<'pipe-trap', PipeTrapEvent> & + NodeEvents<'lineset', LinesetEvent> & + NodeEvents<'liquid-line', LiquidLineEvent> & CameraControlEvents & ToolEvents & GuideEvents & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 79bef2c3..67ffb151 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -93,6 +93,14 @@ export { type Space, wallTouchesOthers, } from './lib/space-detection' +export { + closestOnSegment, + collectLevelWallSegments, + nearestWallSegment, + WALL_SNAP_DISTANCE_M, + type WallSegment, + type WallSegmentClosest, +} from './lib/wall-distance' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, diff --git a/packages/core/src/lib/wall-distance.ts b/packages/core/src/lib/wall-distance.ts new file mode 100644 index 00000000..cc80fcd5 --- /dev/null +++ b/packages/core/src/lib/wall-distance.ts @@ -0,0 +1,121 @@ +import type { WallNode } from '../schema/nodes/wall' +import type { AnyNode, AnyNodeId } from '../schema/types' +import { isCurvedWall } from '../systems/wall/wall-curve' + +/** + * Pure plan-space wall-distance math shared by the 2D opening snap + * (`findClosestWallInPlan` in @pascal-app/nodes) and the editor's 2D + * Voronoi debug overlay. One source of truth means the overlay is a + * faithful picture of what the snap actually decides. + * + * A wall segment's Voronoi cell is exactly "the points whose nearest wall + * is this segment", so nearest-segment classification == the segment + * Voronoi diagram. Curved walls are excluded (the opening snap rejects + * them — mitering + arc + opening tears in 3D). + */ + +/** + * Max cursor-to-wall plan distance (metres) for a 2D opening to snap onto a + * wall. Tight, because plan walls are thin and often close together — a large + * radius would let a far wall's region reach across a nearer one. Shared so the + * snap and the Voronoi debug overlay clip to the exact same range. + */ +export const WALL_SNAP_DISTANCE_M = 0.4 + +export type WallSegment = { + wall: WallNode + /** [x, z] plan start. */ + start: readonly [number, number] + /** [x, z] plan end. */ + end: readonly [number, number] + /** Unit direction (start → end) in plan. */ + dirX: number + dirY: number + /** Segment length in metres. */ + length: number +} + +export type WallSegmentClosest = { + segment: WallSegment + /** Distance from the query point to the closest point on the segment. */ + distance: number + /** Distance along the wall from `start`, clamped to [0, length]. */ + along: number + /** Signed perpendicular offset from the wall axis (+ on the front side). */ + perp: number +} + +/** + * Collect the straight (non-curved) wall segments that are direct children + * of a level — the candidates an opening can snap onto. + */ +export function collectLevelWallSegments( + nodes: Record, + levelId: AnyNodeId | null, +): WallSegment[] { + if (!levelId) return [] + const level = nodes[levelId] + const childIds = (level as unknown as { children?: AnyNodeId[] })?.children + if (!Array.isArray(childIds)) return [] + + const segments: WallSegment[] = [] + for (const childId of childIds) { + const node = nodes[childId] + if (node?.type !== 'wall') continue + const wall = node as WallNode + if (isCurvedWall(wall)) continue + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dy) + if (length < 1e-6) continue + segments.push({ + wall, + start: wall.start, + end: wall.end, + dirX: dx / length, + dirY: dy / length, + length, + }) + } + return segments +} + +/** Closest point + signed offset of one query point against one segment. */ +export function closestOnSegment( + segment: WallSegment, + pointX: number, + pointY: number, +): { distance: number; along: number; perp: number } { + const px = pointX - segment.start[0] + const py = pointY - segment.start[1] + const along = Math.max(0, Math.min(segment.length, px * segment.dirX + py * segment.dirY)) + const perp = px * -segment.dirY + py * segment.dirX + const closestX = segment.start[0] + segment.dirX * along + const closestY = segment.start[1] + segment.dirY * along + const distance = Math.hypot(pointX - closestX, pointY - closestY) + return { distance, along, perp } +} + +/** + * The single nearest wall segment to a plan point — its Voronoi cell. Returns + * null when `segments` is empty or (when `maxDistance` is given) nothing is + * within range. Ties resolve to the first segment scanned; callers pass an + * already-curved-filtered list from `collectLevelWallSegments`. + */ +export function nearestWallSegment( + segments: readonly WallSegment[], + pointX: number, + pointY: number, + maxDistance = Number.POSITIVE_INFINITY, + excludeWallId?: AnyNodeId, +): WallSegmentClosest | null { + let best: WallSegmentClosest | null = null + for (const segment of segments) { + if (excludeWallId && segment.wall.id === excludeWallId) continue + const { distance, along, perp } = closestOnSegment(segment, pointX, pointY) + if (distance > maxDistance) continue + if (best && distance >= best.distance) continue + best = { segment, distance, along, perp } + } + return best +} diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 2d69058b..5824255c 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -116,6 +116,14 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Optional per-tick hook fired while this handle is being dragged, with the + * live (in-progress, override-merged) node. A pure side-channel for transient + * feedback — doors/windows use it to publish proximity / sill guides for the + * edge being resized. The return value is ignored; the resize itself is driven + * by `apply`. + */ + onDrag?: (node: N, sceneApi: SceneApi) => void /** * Cross-node redirect. By default the drag's live override + the * committed write both land on the SELECTED node. When this returns diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 5eb4939a..37d4ac5e 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export { discoverPlugins, getHostRefFields, getSelectableKinds, + hasRegistry3DMoveTool, isDrawnViaTool, isDrawnViaToolKind, isPresettable, @@ -55,6 +56,7 @@ export type { Capabilities, CapabilityCtx, CuttableConfig, + DistributionRole, DragAction, EditorCtx, FloorPlacedConfig, @@ -84,6 +86,7 @@ export type { MovableConfig, NodeCategory, NodeDefinition, + NodePort, NodeRegistry, PaintCapability, PaintEffectiveMaterialArgs, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 051176fc..57aaabcf 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean { return false } +/** + * Whether the kind has a move tool that MOUNTS in the 3D viewport — the + * generic `capabilities.movable` mover or a bespoke `affordanceTools.move`. + * Narrower than {@link isRegistryMovable}, which also accepts floorplan-only + * movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag + * and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D + * movability predicates can't drift apart. + */ +export function hasRegistry3DMoveTool(kind: string): boolean { + const def = nodeRegistry.get(kind) + if (!def) return false + return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined +} + /** * Whether the kind can be saved as a reusable preset. Default: an * explicit `capabilities.presettable` boolean wins; otherwise the kind diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 826f1332..c12bbdfa 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -178,6 +178,40 @@ export type FloorplanStyle = { cursor?: string } +// ─── NodePort ──────────────────────────────────────────────────────── +// +// A typed connection point exposed by a node — the open end of a duct +// run, the collar of a fitting, the supply plenum of an air handler. +// Ports are what placement tools snap to and what a future system graph +// walks to decide connectivity. +// +// Coordinates are LEVEL-LOCAL meters — the same space duct paths and +// grid events use. Kinds whose schema stores a node transform +// (`position` / `rotation`) apply it themselves inside `def.ports` so +// consumers never need to know how a kind stores its placement. + +export type NodePort = { + /** Stable identifier within the node, e.g. 'start', 'end', 'branch'. */ + id: string + /** Level-local meters. */ + position: readonly [number, number, number] + /** Unit vector pointing OUT of the port (away from the node body). */ + direction: readonly [number, number, number] + /** Nominal connection diameter in inches. For a rect / oval port this is + * the area-equivalent round size, so a round run still mates sensibly. */ + diameter: number + /** Which distribution loop the port belongs to, e.g. 'supply' | 'return'. */ + system?: string + /** Cross-section of the connection. Omitted = round at `diameter`. A duct + * run joining a rect / oval port adopts this shape and rolls its + * cross-section to line up with the collar. */ + shape?: 'round' | 'rect' | 'oval' + /** Rect / oval cross-section in inches: width is the collar's horizontal + * face at roll 0, height the vertical one. */ + width?: number + height?: number +} + // ─── ToolHint ──────────────────────────────────────────────────────── // // A single key + label entry in the contextual shortcut hint panel. @@ -450,6 +484,20 @@ export type FloorplanGeometry = /** Rotation in radians. The renderer auto-flips to keep text upright. */ angle: number } + /** + * Equal-spacing badge — a small accent pill marking one gap in a run of + * (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance + * chips). Emitted once per equal gap so the repeated value reads as a rhythm. + * `text` is the shared gap distance; `angle` orients the pill along the wall + * (the renderer auto-flips it upright). + */ + | { + kind: 'equal-spacing-badge' + point: FloorplanPoint + text: string + /** Rotation in radians. */ + angle: number + } /** * Architect's dimension overlay — extension lines from the edge * endpoints out past the dimension line, two dimension line halves @@ -626,6 +674,14 @@ export type FloorplanMoveTargetSession = { * returns. */ commit?(): void + /** + * Optional R-key flip toggle. Kinds with a directional facing + * (door / window: front ↔ back) implement this so the overlay can flip + * the orientation mid-placement before commit. Toggling just records the + * intent; the visible change lands when the overlay re-runs `apply()` with + * the last pointer position. Kinds with no facing leave it unset. + */ + flipSide?(): void } export type FloorplanMoveTarget = (args: { @@ -654,12 +710,40 @@ export type SurfaceRole = | 'glazing' | 'furnishing' +/** Role a kind plays in a duct / pipe / lineset distribution system. */ +export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' + export type NodeDefinition> = { kind: string schemaVersion: number schema: S category: NodeCategory surfaceRole?: SurfaceRole + /** + * Role this kind plays in a distribution system (HVAC duct / DWV pipe / + * refrigerant lineset). Lets the system-graph summary classify a + * component without branching on `node.type`: + * - `'run'` — a duct / pipe / lineset segment (carries `path`). + * - `'fitting'` — an inline fitting (elbow / tee / reducer / trap). + * - `'terminal'` — a grille / register / diffuser endpoint. + * - `'equipment'` — a furnace / air handler / condenser source. + * Kinds outside any distribution system leave this unset. + */ + distributionRole?: DistributionRole + /** + * When `distributionRole` is `'fitting'`, controls whether this fitting + * is dragged as a rigid follower when a connected run endpoint moves. + * + * - `true` (default for `distributionRole === 'fitting'`): the fitting + * translates rigidly so its mated collar stays on the moved port — the + * right behaviour for in-line fittings (elbows, tees, wyes, crosses). + * - `false`: the fitting is anchored in space; moving a connected run + * endpoint stretches the run arm, not the fitting. Use this for + * fixed-position fixtures like `pipe-trap`. + * + * Has no effect when `distributionRole` is not `'fitting'`. + */ + portConnectivityFollow?: boolean defaults: () => Omit, 'id' | 'type'> migrate?: Record unknown> @@ -817,6 +901,15 @@ export type NodeDefinition> = { nodes: Record liveOverrides: Map> }) => Record + /** + * Typed connection points this kind exposes (duct/pipe open ends, + * fitting collars, equipment plenums). Pure function of the node — + * returns LEVEL-LOCAL positions/directions (the kind applies its own + * transform). Consumed by placement tools for port-snapping and, in a + * later slice, by the system graph for connectivity. Kinds with no + * connectable geometry omit this. + */ + ports?: (node: z.infer) => NodePort[] system?: SystemContribution tool?: LazyComponent /** @@ -903,6 +996,14 @@ export type KeyboardActions = { r?: KeyboardAction /** T / Shift+T secondary action. */ t?: KeyboardAction + /** + * Set for kinds whose R/T rotation turns around a user-cyclable world + * axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D + * orientation. The floating action menu reads this to surface the + * active-axis pill above the selected node; kinds with plain Y-only + * rotation omit it. + */ + axisCycling?: boolean } export type KeyboardAction = { @@ -1308,6 +1409,31 @@ export type CapabilityCtx = { node: AnyNode } export type MovableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> gridSnap?: boolean + /** + * Pin the dragged node to the cursor (absolute placement) instead of the + * default offset-preserving drag, where the node moves by the cursor's + * delta from where the drag started. Offset preservation suits large + * furniture you grab by an edge; small connector-like kinds (duct + * fittings) read as "lagging behind the mouse" — they want the cursor. + */ + cursorAttached?: boolean + /** + * Magnetically snap one of this kind's own ports onto a nearby scene + * port while dragging — e.g. a register's collar onto a duct run end. + * The dragged node shifts in XZ so its closest matching port lands on + * the target port. Alt bypasses the snap. Kinds without `def.ports` + * can't use this. Snap takes precedence over grid / alignment snap. + */ + portSnap?: { + /** + * Distribution loops a target port must belong to (e.g. + * `['supply', 'return']`). A target port with no `system` always + * matches. Omit to match every port. + */ + systems?: readonly string[] + /** Snap radius in meters (XZ). Defaults to 0.5. */ + radius?: number + } override?: (ctx: CapabilityCtx) => MovableConfig | null } @@ -1440,7 +1566,24 @@ export type Relations = { export type ParametricDescriptor = { groups: ParamGroup[] invariants?: ReadonlyArray<(n: N) => Issue[]> - derive?: (n: N) => Partial + /** + * Co-update hook for fields that must stay consistent when edited + * from the inspector. Called with the node AFTER `patch` is merged + * plus the patch itself (so the hook can tell which field the user + * touched); whatever it returns is folded into the same update. + * Direct store/MCP writes bypass it — keep real invariants in + * `invariants`. + */ + derive?: (next: N, patch: Partial) => Partial + /** + * Cross-node companion to `derive`: after an inspector edit lands on + * this node, return patches for OTHER nodes that must follow to keep + * the scene consistent — e.g. duct runs re-trimmed onto a resized + * fitting's collars. `prev` is the node before the edit, `next` after + * (with `derive` already folded in). Applied in the same gesture via + * `updateNodes`. + */ + reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial }> customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ca8fc184..e9d270dc 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -59,6 +59,9 @@ export { getEffectiveDormerSurfaceMaterial, } from './nodes/dormer' export { DownspoutNode } from './nodes/downspout' +export { DuctFittingNode } from './nodes/duct-fitting' +export { DuctSegmentNode } from './nodes/duct-segment' +export { DuctTerminalNode } from './nodes/duct-terminal' export { ElevatorDoorPanelStyle, ElevatorDoorStyle, @@ -69,6 +72,7 @@ export { EyebrowVentNode } from './nodes/eyebrow-vent' export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode, GuideScaleReference } from './nodes/guide' export { GutterNode, GutterOutlet } from './nodes/gutter' +export { HvacEquipmentNode } from './nodes/hvac-equipment' export type { AnimationEffect, Asset, @@ -88,6 +92,11 @@ export { LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT, } from './nodes/item' export { LevelNode } from './nodes/level' +export { LinesetNode } from './nodes/lineset' +export { LiquidLineNode } from './nodes/liquid-line' +export { PipeFittingNode } from './nodes/pipe-fitting' +export { PipeSegmentNode } from './nodes/pipe-segment' +export { PipeTrapNode } from './nodes/pipe-trap' // Nodes export { RidgeVentNode } from './nodes/ridge-vent' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' diff --git a/packages/core/src/schema/nodes/duct-fitting.ts b/packages/core/src/schema/nodes/duct-fitting.ts new file mode 100644 index 00000000..277b02f7 --- /dev/null +++ b/packages/core/src/schema/nodes/duct-fitting.ts @@ -0,0 +1,94 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Duct fitting — the junction pieces that connect round duct segments: + * elbows (direction change), tees (branch takeoff), reducers (diameter + * transition). + * + * Phase 2 of the HVAC node system. Fittings are the first kind to expose + * typed ports (`def.ports`) — placement tools snap duct endpoints onto a + * fitting's collars, and the future system graph walks ports to decide + * connectivity. + * + * `position` is level-local meters; `rotation` is an XYZ euler in radians + * so a fitting can turn a horizontal run vertical (riser elbows). + * + * Local-frame conventions (before `rotation` is applied): + * - elbow: inlet faces -X, outlet turned by `angle` degrees in the + * XZ plane (90° → +Z). + * - tee: run along the X axis (ports face -X and +X), branch + * collar at `branchAngle`° from the +X (outlet) axis in the + * XZ plane — 90° a square straight tee, <90° a lateral + * leaning downstream toward the outlet, >90° leaning upstream + * toward the inlet — sized at `diameter2`. + * - cross: four-way junction — run along the X axis (ports face -X + * and +X) at the run profile, two opposed branches square to + * the run along ±Z (branch faces +Z, branch2 faces -Z) at the + * branch profile (`shape2` / `diameter2`). + * - reducer: inlet at `diameter` faces -X, outlet at `diameter2` + * faces +X. + * - transition: square-to-round — rect end at `width` × `height` faces + * -X, round end at `diameter2` faces +X. `diameter` carries + * the rect end's area-equivalent round size. + */ +export const DuctFittingNode = BaseNode.extend({ + id: objectId('duct-fitting'), + type: nodeType('duct-fitting'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // XYZ euler radians. + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + fittingType: z.enum(['elbow', 'tee', 'cross', 'reducer', 'transition']).default('elbow'), + // Run-leg cross-section: round collars, or a rect / flat-oval profile + // matching the trunk the fitting sits in. Reducers ignore the shape. + // When non-round, `diameter` carries the area-equivalent round size + // (drives leg lengths + advertised ports). + shape: z.enum(['round', 'rect', 'oval']).default('round'), + // Rect / oval run-leg profile in inches (used when shape ≠ 'round'). + width: z.number().min(4).max(60).default(14), + height: z.number().min(3).max(40).default(8), + // Tee / cross BRANCH cross-section: a round collar at `diameter2` or a + // rect / oval profile matching the duct drawn off the tap. When + // non-round, `diameter2` carries the branch's area-equivalent round + // size. A cross's two opposed branches share this one profile. + shape2: z.enum(['round', 'rect', 'oval']).default('round'), + // Rect / oval branch profile in inches (used when shape2 ≠ 'round'). + width2: z.number().min(4).max(60).default(14), + height2: z.number().min(3).max(40).default(8), + // Elbow turn angle in degrees. Residential sheet-metal elbows come in + // 90° and 45°; adjustable elbows cover the range between. + angle: z.number().min(15).max(90).default(90), + // Tee branch angle in degrees, measured off the +X (outlet) axis: 90° + // is a square straight tee, <90° a lateral whose branch sweeps + // downstream toward the outlet (flow merges), >90° leans the branch + // upstream toward the inlet. Ignored by every other fitting type. + branchAngle: z.number().min(45).max(135).default(90), + // Main (run/inlet) nominal diameter in inches. + diameter: z.number().min(2).max(48).default(6), + // Secondary diameter in inches — tee branch collar, reducer outlet. + // Ignored by elbows. + diameter2: z.number().min(2).max(48).default(6), + ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'), + system: z.enum(['supply', 'return']).default('supply'), +}).describe( + dedent` + Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs. + - position: [x, y, z] level-local meters + - rotation: [x, y, z] euler radians + - fittingType: elbow | tee | cross | reducer | transition (rect end -X, round end +X) + - shape: round | rect | oval run legs (matches the trunk; ignored by reducer / transition) + - width / height: rect / oval run-leg profile in inches (transition: the rect end) + - shape2: round | rect | oval tee / cross branch (matches the duct drawn off the tap) + - width2 / height2: rect / oval branch profile in inches + - angle: elbow turn in degrees (45 or 90 typical) + - branchAngle: tee branch angle off the outlet axis (90 straight tee, 45 downstream lateral, 135 upstream); cross branches are always square + - diameter: main nominal diameter in inches + - diameter2: tee / cross branch / reducer outlet / transition round-end diameter in inches + - ductMaterial: sheet-metal | flex | duct-board + - system: supply | return + `, +) +export type DuctFittingNode = z.infer +export type DuctFittingNodeId = DuctFittingNode['id'] diff --git a/packages/core/src/schema/nodes/duct-segment.ts b/packages/core/src/schema/nodes/duct-segment.ts new file mode 100644 index 00000000..21af751f --- /dev/null +++ b/packages/core/src/schema/nodes/duct-segment.ts @@ -0,0 +1,77 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Round duct segment — a polyline of 3D points connected by cylindrical + * duct sections. Forced-air HVAC supply/return runs in US residential. + * + * Phase 1 of the HVAC node system: just the geometry primitive. Fittings, + * terminals, equipment, and typed ports come in later slices. + * + * Path coordinates are level-local meters: [x, y, z] tuples. y is height + * above the level floor. A duct hung at ceiling height through three points + * is e.g. `[[0, 2.6, 0], [3, 2.6, 0], [3, 2.6, 4]]`. + * + * Diameters are nominal US round-duct sizes in inches; the geometry + * builder converts to meters for the cylinder radius. + */ +export const DuctSegmentNode = BaseNode.extend({ + id: objectId('duct-segment'), + type: nodeType('duct-segment'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Cross-section. Round is the branch default; rect is the trunk / + // plenum profile (real US systems: rect trunk, round branches); oval + // is the flat-oval profile (two semicircles of the duct height joined + // by flat sides) used where round won't fit a joist bay. + shape: z.enum(['round', 'rect', 'oval']).default('round'), + // Nominal inner diameter in inches (round shape). Common residential + // sizes 4"–14"; we accept any positive number so the inspector slider + // stays ergonomic and larger commercial sizes load without a schema bump. + diameter: z.number().min(2).max(48).default(6), + // Rect / oval cross-section in inches: width is the horizontal face, + // height the vertical. Typical residential trunks 12×8 – 24×10. For + // oval, height is also the end-cap semicircle diameter (width ≥ height). + width: z.number().min(4).max(60).default(14), + height: z.number().min(3).max(40).default(8), + // Cross-section roll (radians) about the run direction. 0 = width + // horizontal / height vertical (the natural orientation the geometry + // derives from direction). Non-zero only on a rect riser turned out of + // the horizontal plane, so its profile stays continuous through the + // elbow it left instead of snapping to the world-axis fallback. + roll: z.number().default(0), + // Construction material. Spiral is round rigid sheet metal with the + // helical lock seam drawn on the body (round shape only — rect / oval + // runs render it as plain sheet metal). + ductMaterial: z.enum(['sheet-metal', 'spiral', 'flex', 'duct-board']).default('flex'), + // Whether to draw the construction body detail (spiral lock seam / + // flex wire corrugation) on round runs. Off renders a smooth body — + // lighter on the eyes and the GPU in dense scenes. + seamDetail: z.boolean().default(false), + // Whether the run wears its external insulation wrap (drawn as a + // translucent shell). Off by default — bare duct. + insulated: z.boolean().default(false), + // External insulation R-value (used when insulated). Common flex-duct + // values are R-4.2, R-6, R-8. + insulationR: z.number().min(0).max(12).default(0.5), + // Which side of the air loop this segment belongs to. Drives visual tint + // and (in later slices) System graph membership. + system: z.enum(['supply', 'return']).default('supply'), +}).describe( + dedent` + Duct segment - polyline of 3D points connected by duct sections. + - path: list of [x, y, z] points in level-local meters (min 2) + - shape: round (branches) | rect (trunks / plenums) | oval (flat-oval, tight joist bays) + - diameter: nominal inner diameter in inches for round (typ. 4-14 residential) + - width / height: rect / oval cross-section in inches (typ. 12x8 - 24x10 trunks) + - roll: cross-section roll in radians (0 = upright; set on risers to stay continuous through their elbow) + - ductMaterial: sheet-metal | spiral (round rigid, helical seam) | flex | duct-board + - seamDetail: draw the spiral seam / flex corrugation on round runs (default off) + - insulated: whether the run wears its external insulation wrap (default off) + - insulationR: external insulation R-value when insulated (4, 6, 8 typical) + - system: supply | return (drives visual tint) + `, +) +export type DuctSegmentNode = z.infer +export type DuctSegmentNodeId = DuctSegmentNode['id'] diff --git a/packages/core/src/schema/nodes/duct-terminal.ts b/packages/core/src/schema/nodes/duct-terminal.ts new file mode 100644 index 00000000..5eefff31 --- /dev/null +++ b/packages/core/src/schema/nodes/duct-terminal.ts @@ -0,0 +1,56 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Duct terminal — where the air loop meets the room: supply registers, + * ceiling diffusers, return grilles. + * + * Phase 3 of the HVAC node system. Each terminal exposes a single typed + * port at its collar (behind/above/below the face depending on mount), + * so duct runs end onto it like any other port. + * + * `position` is the center of the visible face in level-local meters — + * floor registers at y≈0, ceiling diffusers at ceiling height, wall + * registers at their height on the wall. `rotation` is yaw radians. + */ +export const DuctTerminalNode = BaseNode.extend({ + id: objectId('duct-terminal'), + type: nodeType('duct-terminal'), + // Level-local meters — center of the face. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians. + rotation: z.number().default(0), + terminalType: z.enum(['supply-register', 'diffuser', 'return-grille']).default('supply-register'), + // Which surface the terminal mounts on. Drives face orientation and + // which way the collar (and its port) points. + mount: z.enum(['floor', 'ceiling', 'wall']).default('floor'), + // Face dimensions in meters. Typical floor register ~0.30 × 0.15; + // ceiling diffusers are square (0.6 × 0.6); return grilles run large. + width: z.number().min(0.1).max(1.5).default(0.3), + depth: z.number().min(0.05).max(1.5).default(0.15), + // Collar cross-section on the duct side. Round is the default; rect and + // oval (flat-oval) match the duct shapes a run might end with. + collarShape: z.enum(['round', 'rect', 'oval']).default('round'), + // Round collar diameter in inches on the duct side. + collarDiameter: z.number().min(4).max(20).default(6), + // Rect / oval collar cross-section in inches: width is the horizontal + // face, height the vertical. For oval, height is also the end-cap + // semicircle diameter (width ≥ height). + collarWidth: z.number().min(4).max(20).default(10), + collarHeight: z.number().min(3).max(20).default(6), +}).describe( + dedent` + Duct terminal - supply register, ceiling diffuser, or return grille. + - position: [x, y, z] level-local meters, center of the face + - rotation: yaw radians + - terminalType: supply-register | diffuser | return-grille (grille = return side) + - mount: floor | ceiling | wall - face orientation + collar direction + - width / depth: face size in meters + - collarShape: round | rect | oval - duct-side collar cross-section + - collarDiameter: round collar diameter in inches + - collarWidth / collarHeight: rect / oval collar cross-section in inches + `, +) +export type DuctTerminalNode = z.infer +export type DuctTerminalNodeId = DuctTerminalNode['id'] diff --git a/packages/core/src/schema/nodes/hvac-equipment.ts b/packages/core/src/schema/nodes/hvac-equipment.ts new file mode 100644 index 00000000..a0e00a13 --- /dev/null +++ b/packages/core/src/schema/nodes/hvac-equipment.ts @@ -0,0 +1,60 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * HVAC equipment — the boxes duct systems start and end at: furnace, + * air handler, outdoor condenser. + * + * Phase 3 of the HVAC node system. Furnaces and air handlers expose + * typed duct ports (supply plenum on top, return drop on the side) so + * duct runs and fittings snap onto them. Every unit also exposes a + * refrigerant service port on its valve face — a condenser, the outdoor + * half of a split system, carries no duct ports but pipes to the indoor + * coil through a `lineset` run mating onto that port. + * + * Floor-placed: `position` is level-local meters with y at the base, + * `rotation` is yaw radians (the editor's default R-rotate applies). + */ +export const HvacEquipmentNode = BaseNode.extend({ + id: objectId('hvac-equipment'), + type: nodeType('hvac-equipment'), + // Level-local meters, y at the unit's base. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians. + rotation: z.number().default(0), + equipmentType: z.enum(['furnace', 'air-handler', 'condenser']).default('furnace'), + // Cabinet dimensions in meters. Defaults match a typical upflow + // furnace cabinet (~22" × 28" footprint, ~43" tall). + width: z.number().min(0.3).max(2).default(0.56), + depth: z.number().min(0.3).max(2).default(0.71), + height: z.number().min(0.4).max(2.5).default(1.1), + // Duct collar cross-section on the supply / return connections. Round is + // the default; rect and oval (flat-oval) match the duct shapes a run + // might mate with. Condensers carry no duct collars (ignored). + supplyShape: z.enum(['round', 'rect', 'oval']).default('round'), + returnShape: z.enum(['round', 'rect', 'oval']).default('round'), + // Round collar diameters in inches. + supplyDiameter: z.number().min(6).max(30).default(8), + returnDiameter: z.number().min(6).max(30).default(8), + // Rect / oval collar cross-section in inches: width is the horizontal + // face, height the vertical. For oval, height is also the end-cap + // semicircle diameter (width ≥ height). + supplyWidth: z.number().min(6).max(30).default(12), + supplyHeight: z.number().min(6).max(30).default(8), + returnWidth: z.number().min(6).max(30).default(14), + returnHeight: z.number().min(6).max(30).default(8), +}).describe( + dedent` + HVAC equipment cabinet - furnace, air handler, or outdoor condenser. + - position: [x, y, z] level-local meters (y = base) + - rotation: yaw radians + - equipmentType: furnace | air-handler | condenser + - width / depth / height: cabinet size in meters + - supplyShape / returnShape: round | rect | oval duct collar cross-section (ignored by condenser) + - supplyDiameter / returnDiameter: round collar sizes in inches + - supplyWidth / supplyHeight / returnWidth / returnHeight: rect / oval collar cross-section in inches + `, +) +export type HvacEquipmentNode = z.infer +export type HvacEquipmentNodeId = HvacEquipmentNode['id'] diff --git a/packages/core/src/schema/nodes/lineset.ts b/packages/core/src/schema/nodes/lineset.ts new file mode 100644 index 00000000..06bc7ff3 --- /dev/null +++ b/packages/core/src/schema/nodes/lineset.ts @@ -0,0 +1,43 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Refrigerant lineset — the copper pipe pair that links the outdoor + * condenser to the indoor coil (furnace / air handler) of a split system. + * It is the refrigerant-side analogue of a duct run: a polyline of points, + * but carrying two lines instead of one airway. + * + * Real linesets run a fat insulated SUCTION line (cool vapour back to the + * compressor) beside a thin bare LIQUID line (warm liquid out to the coil). + * The geometry builder draws a single copper line on the path centerline + * (sized to `suctionDiameter`, wrapped in a foam jacket when `insulated`); + * draw the liquid line as a second lineset rather than both off one path. + * + * Path coordinates are level-local meters: [x, y, z] tuples, same space as + * duct paths and grid events. Diameters are nominal copper OD in inches. + */ +export const LinesetNode = BaseNode.extend({ + id: objectId('lineset'), + type: nodeType('lineset'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal suction-line copper OD in inches (the large insulated line). + // Common residential sizes are 3/4"–1-1/8". + suctionDiameter: z.number().min(0.25).max(2).default(0.875), + // Nominal liquid-line copper OD in inches (the small bare line). + // Common residential sizes are 1/4"–3/8". + liquidDiameter: z.number().min(0.125).max(1).default(0.375), + // Whether the suction line carries its foam insulation jacket. Bare = false. + insulated: z.boolean().default(true), +}).describe( + dedent` + Refrigerant lineset - copper suction + liquid pair linking a condenser to an indoor coil. + - path: list of [x, y, z] points in level-local meters (min 2) + - suctionDiameter: nominal copper OD in inches of the large insulated line (typ. 3/4"-1-1/8") + - liquidDiameter: nominal copper OD in inches of the small bare line (typ. 1/4"-3/8") + - insulated: whether the suction line wears its foam jacket + `, +) +export type LinesetNode = z.infer +export type LinesetNodeId = LinesetNode['id'] diff --git a/packages/core/src/schema/nodes/liquid-line.ts b/packages/core/src/schema/nodes/liquid-line.ts new file mode 100644 index 00000000..5b3f8b38 --- /dev/null +++ b/packages/core/src/schema/nodes/liquid-line.ts @@ -0,0 +1,29 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * Standalone refrigerant liquid line — the thin bare-copper line that carries + * warm liquid out to the indoor coil. It is the line that used to be drawn as + * the lineset's second rail; broken out here as its own polyline run so it can + * be drawn on its own, including traced alongside an existing lineset. + * + * Path coordinates are level-local meters: [x, y, z] tuples, the same space as + * lineset and duct paths. Diameter is nominal copper OD in inches. + */ +export const LiquidLineNode = BaseNode.extend({ + id: objectId('liquid-line'), + type: nodeType('liquid-line'), + // Polyline path in level-local meters. Minimum two points (start, end). + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal copper OD in inches. Common residential sizes are 1/4"–3/8". + diameter: z.number().min(0.125).max(1).default(0.375), +}).describe( + dedent` + Standalone refrigerant liquid line - a thin bare-copper polyline run. + - path: list of [x, y, z] points in level-local meters (min 2) + - diameter: nominal copper OD in inches (typ. 1/4"-3/8") + `, +) +export type LiquidLineNode = z.infer +export type LiquidLineNodeId = LiquidLineNode['id'] diff --git a/packages/core/src/schema/nodes/pipe-fitting.ts b/packages/core/src/schema/nodes/pipe-fitting.ts new file mode 100644 index 00000000..88e38e9a --- /dev/null +++ b/packages/core/src/schema/nodes/pipe-fitting.ts @@ -0,0 +1,48 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV pipe fitting — the joints drain systems are actually built from: + * elbows (bends), wyes (45° branch entries, the code-preferred way to + * join horizontal drains), sanitary tees (square branch entries), and + * crosses (two opposed branches where a run passes straight through). + * + * Local-frame conventions (before `rotation`): + * - elbow: inlet faces -X, outlet turned `angle`° in XZ. + * - wye: run along X (inlet -X, outlet +X), branch collar at + * 45° between +X and +Z. + * - sanitary-tee: run along X, branch collar faces +Z. + * - cross: run along X, two opposed branch collars on ±Z. + */ +export const PipeFittingNode = BaseNode.extend({ + id: objectId('pipe-fitting'), + type: nodeType('pipe-fitting'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // XYZ euler radians. + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'), + // Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long + // sweep" for drains); adjustable range matches the duct elbow. + angle: z.number().min(15).max(90).default(90), + // Run nominal size in inches. + diameter: z.number().min(1.25).max(8).default(2), + // Branch collar size (wye / sanitary-tee). + diameter2: z.number().min(1.25).max(8).default(2), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + system: z.enum(['waste', 'vent']).default('waste'), +}).describe( + dedent` + DWV pipe fitting - elbow (bend), wye (45° branch), sanitary tee (square branch), or cross (two opposed branches). + - position: [x, y, z] level-local meters + - rotation: [x, y, z] euler radians + - fittingType: elbow | wye | sanitary-tee | cross + - angle: elbow turn in degrees (22.5 / 45 / 90 typical) + - diameter: run size in inches; diameter2: branch collar size (both branches for a cross) + - pipeMaterial: pvc | abs | cast-iron + - system: waste | vent + `, +) +export type PipeFittingNode = z.infer +export type PipeFittingNodeId = PipeFittingNode['id'] diff --git a/packages/core/src/schema/nodes/pipe-segment.ts b/packages/core/src/schema/nodes/pipe-segment.ts new file mode 100644 index 00000000..6f7512ec --- /dev/null +++ b/packages/core/src/schema/nodes/pipe-segment.ts @@ -0,0 +1,41 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV pipe segment — drain / waste / vent runs in US residential + * plumbing. Phase 2 of the distribution-system effort: the plumbing + * sibling of `duct-segment`, sharing the polyline model and the typed + * port machinery. + * + * The defining difference from ducts is SLOPE: drains must fall + * (IPC: ¼" per foot for pipes under 3", ⅛" allowed at 3"+). Slope is + * stored implicitly in the path's Y coordinates — the draw tool drops + * Y as you draw a waste run; vents run level or vertical. + * + * Path coordinates are level-local meters. Y may be negative (drains + * drop below the floor into the joist / crawl space). + */ +export const PipeSegmentNode = BaseNode.extend({ + id: objectId('pipe-segment'), + type: nodeType('pipe-segment'), + // Polyline path in level-local meters. Minimum two points. + path: z.array(z.tuple([z.number(), z.number(), z.number()])).min(2), + // Nominal pipe size in inches. Residential DWV: 1¼ (lav tailpiece) to + // 4 (building drain); 6 covers oversized mains. + diameter: z.number().min(1.25).max(8).default(2), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + // Which DWV role the run plays. Waste carries water (sloped); vent + // carries air (level or vertical, dashed in plan). + system: z.enum(['waste', 'vent']).default('waste'), +}).describe( + dedent` + DWV pipe segment - drain / waste / vent run as a polyline of 3D points. + - path: list of [x, y, z] points in level-local meters (min 2; y may go below the floor) + - diameter: nominal size in inches (1.5 / 2 / 3 / 4 typical residential) + - pipeMaterial: pvc | abs | cast-iron + - system: waste (sloped drains) | vent (level / vertical air pipes) + `, +) +export type PipeSegmentNode = z.infer +export type PipeSegmentNodeId = PipeSegmentNode['id'] diff --git a/packages/core/src/schema/nodes/pipe-trap.ts b/packages/core/src/schema/nodes/pipe-trap.ts new file mode 100644 index 00000000..ca63f242 --- /dev/null +++ b/packages/core/src/schema/nodes/pipe-trap.ts @@ -0,0 +1,41 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +/** + * DWV trap — the P-trap between a fixture and the waste system. Holds a + * water seal that blocks sewer gas; every drained fixture has exactly + * one. Modeled as an explicit fitting (not folded into the fixture) so + * the trap-arm rule (IPC 909.1 max developed length to the vent) has a + * node to attach to and the inspector can edit size + arm length. + * + * Local-frame convention (before `rotation`): inlet faces +Y (up, to + * the fixture tailpiece), outlet faces +X (the horizontal trap arm + * toward the vented waste line). + */ +export const PipeTrapNode = BaseNode.extend({ + id: objectId('pipe-trap'), + type: nodeType('pipe-trap'), + // Level-local meters. + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + // Yaw in radians (the arm direction in plan). + rotation: z.number().default(0), + // Trap size in inches — matches the fixture drain it serves. + diameter: z.number().min(1.25).max(4).default(1.5), + pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'), + // Developed length of the trap arm (trap weir → vent) in meters. The + // draw tool measures it when the arm is drawn; editable in the + // inspector. Drives the IPC 909.1 max-trap-arm check. + armLengthM: z.number().min(0).default(0), +}).describe( + dedent` + DWV trap (P-trap) - the water-seal fitting between a fixture and the waste line. + - position: [x, y, z] level-local meters + - rotation: yaw radians (trap-arm direction in plan) + - diameter: trap size in inches (matches the fixture drain) + - pipeMaterial: pvc | abs | cast-iron + - armLengthM: developed length from trap to vent in meters (IPC 909.1 limited by size) + `, +) +export type PipeTrapNode = z.infer +export type PipeTrapNodeId = PipeTrapNode['id'] diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index b866f4e6..d404367b 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -8,13 +8,22 @@ import { CupolaNode } from './nodes/cupola' import { DoorNode } from './nodes/door' import { DormerNode } from './nodes/dormer' import { DownspoutNode } from './nodes/downspout' +import { DuctFittingNode } from './nodes/duct-fitting' +import { DuctSegmentNode } from './nodes/duct-segment' +import { DuctTerminalNode } from './nodes/duct-terminal' import { ElevatorNode } from './nodes/elevator' import { EyebrowVentNode } from './nodes/eyebrow-vent' import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { GutterNode } from './nodes/gutter' +import { HvacEquipmentNode } from './nodes/hvac-equipment' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' +import { LinesetNode } from './nodes/lineset' +import { LiquidLineNode } from './nodes/liquid-line' +import { PipeFittingNode } from './nodes/pipe-fitting' +import { PipeSegmentNode } from './nodes/pipe-segment' +import { PipeTrapNode } from './nodes/pipe-trap' import { RidgeVentNode } from './nodes/ridge-vent' import { RoofNode } from './nodes/roof' import { RoofSegmentNode } from './nodes/roof-segment' @@ -65,6 +74,15 @@ export const AnyNode = z.discriminatedUnion('type', [ SkylightNode, DormerNode, DownspoutNode, + DuctSegmentNode, + DuctFittingNode, + DuctTerminalNode, + HvacEquipmentNode, + LinesetNode, + LiquidLineNode, + PipeSegmentNode, + PipeFittingNode, + PipeTrapNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/services/alignment-anchors.ts b/packages/core/src/services/alignment-anchors.ts index cf8bc974..5032b154 100644 --- a/packages/core/src/services/alignment-anchors.ts +++ b/packages/core/src/services/alignment-anchors.ts @@ -295,8 +295,43 @@ export function nodeAlignmentAnchors( const poly = (node as { polygon?: [number, number][] }).polygon return poly ? polygonAnchors(node.id, poly) : [] } + + const anchors: AlignmentAnchor[] = [] + + // Box footprint (items, columns, shelves, stairs, …). const aabb = alignmentAABB(node, nodes) - return aabb ? bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) : [] + if (aabb) { + anchors.push(...bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)) + } + + // Polyline kinds (duct / pipe / lineset): every path vertex is an anchor, + // so anything dragged snaps to a run's ends and bends. + const path = (node as { path?: unknown }).path + if (Array.isArray(path)) { + for (const p of path as Array<[number, number, number]>) { + anchors.push({ nodeId: node.id, kind: 'corner', x: p[0], z: p[2] }) + } + } + + // Typed ports (fittings, equipment, terminals, run ends): connection points + // are natural alignment targets — line a new run up with an existing collar. + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (ports) { + for (const port of ports) { + anchors.push({ nodeId: node.id, kind: 'corner', x: port.position[0], z: port.position[2] }) + } + } + + // Position-based kinds with no footprint (e.g. duct fittings): the origin + // itself is a useful centre anchor. + if (!aabb) { + const position = (node as { position?: [number, number, number] }).position + if (Array.isArray(position)) { + anchors.push({ nodeId: node.id, kind: 'center', x: position[0], z: position[2] }) + } + } + + return anchors } /** diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index b3eb1a26..f52f9394 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -41,6 +41,10 @@ export { pickHost, type Vec3, } from './hosting' +export { + DEFAULT_LEVEL_HEIGHT, + getLevelHeight, +} from './level-height' export { type AxisLock, applyAxisLock, @@ -49,6 +53,39 @@ export { moveToward, resolveMovable, } from './movement' +export { + type AlongWallAlignment, + type AlongWallFeature, + computeEdgeGaps, + computeOpeningGuides, + DEFAULT_OPENING_GUIDE_TOLERANCES, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type EdgeGap, + type EqualSpacingRun, + type OpeningGuideInput, + type OpeningGuides, + type OpeningGuideTolerances, + type OpeningSpan, + type SillHeadGuide, + type VerticalAlignment, + type VerticalFeature, + type WallExtent, +} from './opening-guides' +export { + analyzePortConnectivity, + type PortConnection, + type PortConnectivity, + resolveConnectivityUpdates, +} from './port-connectivity' +export { + buildRiserDiagram, + projectIso, + type RiserDiagram, + type RiserLine, + type RiserMarker, +} from './riser-diagram' export { DEFAULT_ANGLE_STEP, DEFAULT_GRID_STEP, @@ -62,3 +99,13 @@ export { snapVec3ToGrid, snapWorldXZToBuildingLocal, } from './snap' +export { + buildPortComponents, + type SystemSummary, + summarizeSystemFor, +} from './system-graph' +export { + type DwvFinding, + type DwvSeverity, + validateDwv, +} from './validate-dwv' diff --git a/packages/core/src/services/level-height.ts b/packages/core/src/services/level-height.ts new file mode 100644 index 00000000..46f4da0a --- /dev/null +++ b/packages/core/src/services/level-height.ts @@ -0,0 +1,42 @@ +import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' + +export const DEFAULT_LEVEL_HEIGHT = 2.5 + +/** + * Optional resolver for a wall's rendered base Y (mesh elevation). + * + * `packages/core` is pure domain logic and must not read viewer/Three.js + * state (see AGENTS.md “Layer Boundaries”). Callers that legitimately have + * registry access (viewer systems, node tools) may pass a resolver so the + * mesh elevation is factored in; pure/headless callers (MCP, tests, server) + * omit it and get a deterministic result from serialized node data alone. + */ +export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined + +export function getLevelHeight( + levelId: string, + nodes: Record, + resolveWallBaseY?: WallBaseYResolver, +): number { + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return DEFAULT_LEVEL_HEIGHT + + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (!child) continue + if (child.type === 'ceiling') { + const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (ch > maxTop) maxTop = ch + } else if (child.type === 'wall') { + let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0 + if (baseY < 0) baseY = 0 + const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + if (top > maxTop) maxTop = top + } + } + + return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT +} diff --git a/packages/core/src/services/opening-guides.test.ts b/packages/core/src/services/opening-guides.test.ts new file mode 100644 index 00000000..648800b4 --- /dev/null +++ b/packages/core/src/services/opening-guides.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from 'bun:test' +import { + computeEdgeGaps, + computeOpeningGuides, + detectAlongWallAlignment, + detectEqualSpacing, + detectVerticalAlignment, + type OpeningSpan, + type WallExtent, +} from './opening-guides' + +function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan { + return { id, centerS, width, centerY, height } +} + +const WALL: WallExtent = { length: 10, height: 2.5 } + +describe('detectEqualSpacing', () => { + test('returns null for fewer than three openings', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull() + }) + + test('detects a run of equal gaps across three openings', () => { + // width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m. + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) + const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02) + expect(run).not.toBeNull() + expect(run?.gap).toBeCloseTo(1) + expect(run?.segments).toHaveLength(2) + expect(run?.openingIds).toEqual(['a', 'b', 'c']) + expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 }) + expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 }) + }) + + test('extends a run across four openings (three gaps)', () => { + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)] + const run = detectEqualSpacing(openings, 'c', 0.03, 0.02) + expect(run?.segments).toHaveLength(3) + expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd']) + }) + + test('returns null when gaps differ beyond tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1 + const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('returns null when the moving opening is not part of the equal run', () => { + const a = span('a', 0.5, 1) + const b = span('b', 2.5, 1) + const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1 + const d = span('d', 10, 1) // far right, breaks the run + expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull() + }) + + test('a near-zero (touching) gap breaks a run', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap + const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull() + }) + + test('honours the equal-spacing tolerance', () => { + const a = span('a', 0.5, 1) // [0,1] + const b = span('b', 2.5, 1) // [2,3] → gap 1.0 + const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02 + expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2) + expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull() + }) +}) + +describe('computeEdgeGaps', () => { + test('measures clearance to the nearest neighbour on each side', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const left = span('l', 2, 1) // [1.5,2.5] + const right = span('r', 8, 1) // [7.5,8.5] + const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.distance).toBeCloseTo(2) + expect(byside.left?.target).toBe('opening') + expect(byside.left?.targetId).toBe('l') + expect(byside.right?.distance).toBeCloseTo(2) + expect(byside.right?.targetId).toBe('r') + }) + + test('falls back to wall ends with no neighbour', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const gaps = computeEdgeGaps(moving, [], WALL, 0.02) + const byside = Object.fromEntries(gaps.map((g) => [g.side, g])) + expect(byside.left?.target).toBe('wall-start') + expect(byside.left?.distance).toBeCloseTo(4.5) + expect(byside.right?.target).toBe('wall-end') + expect(byside.right?.distance).toBeCloseTo(4.5) + }) + + test('omits a side that is flush / overlapping (below minGap)', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left + const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02) + expect(gaps.find((g) => g.side === 'left')).toBeUndefined() + expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end') + }) +}) + +describe('detectAlongWallAlignment', () => { + test('detects edge-to-edge alignment within tolerance', () => { + const moving = span('m', 5, 2) // [4,6] + const sib = span('s', 7.05, 2) // left edge 6.05 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('right') + expect(a?.targetFeature).toBe('left') + expect(a?.snap).toBeCloseTo(0.05) + expect(a?.s).toBeCloseTo(6.05) + }) + + test('detects centre alignment', () => { + const moving = span('m', 5, 2) + const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.targetFeature).toBe('center') + expect(a?.snap).toBeCloseTo(0.03) + }) + + test('returns null when nothing is within tolerance', () => { + const moving = span('m', 5, 2) + const sib = span('s', 9, 2) + expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('detectVerticalAlignment', () => { + test('detects a shared sill within tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1.0 + const sib = span('s', 8, 1, 2.04, 2) // sill 1.04 + const a = detectVerticalAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('sill') + expect(a?.targetFeature).toBe('sill') + expect(a?.snap).toBeCloseTo(0.04) + expect(a?.y).toBeCloseTo(1.04) + }) + + test('returns null when sills/tops differ beyond tolerance', () => { + const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5 + const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4 + expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull() + }) +}) + +describe('computeOpeningGuides', () => { + test('includes sill/head for windows', () => { + const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2 + const guides = computeOpeningGuides({ + moving, + siblings: [], + wall: WALL, + includeVertical: true, + }) + expect(guides.sillHead?.sill).toBeCloseTo(1) + expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2 + expect(guides.sillHead?.bottomY).toBeCloseTo(1) + expect(guides.sillHead?.topY).toBeCloseTo(2) + }) + + test('omits vertical guides for doors (sit on the floor)', () => { + const moving = span('m', 5, 1, 1, 2) + const sib = span('s', 8, 1, 1, 2) + const guides = computeOpeningGuides({ + moving, + siblings: [sib], + wall: WALL, + includeVertical: false, + }) + expect(guides.sillHead).toBeNull() + expect(guides.vertical).toBeNull() + // along-wall + proximity still computed for doors + expect(guides.gaps.length).toBeGreaterThan(0) + }) + + test('combines proximity and equal-spacing in one pass', () => { + const moving = span('b', 2.5, 1) + const guides = computeOpeningGuides({ + moving, + siblings: [span('a', 0.5, 1), span('c', 4.5, 1)], + wall: WALL, + includeVertical: true, + }) + expect(guides.gaps).toHaveLength(2) + expect(guides.equalSpacing?.gap).toBeCloseTo(1) + expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c']) + }) +}) + +describe('opening-guides — review regressions', () => { + test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => { + // gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the + // moving opening; a first-gap-anchored greedy scan used to drop it. + const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)] + const run = detectEqualSpacing(openings, 'd', 0.03, 0.02) + expect(run?.openingIds).toEqual(['b', 'c', 'd']) + expect(run?.gap).toBeCloseTo(1.03) + expect(run?.segments).toHaveLength(2) + }) + + test('detectEqualSpacing prefers the leftmost run on a length tie', () => { + // gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs. + const openings = [ + span('a', 0.5, 1), + span('b', 2.5, 1), + span('c', 4.5, 1), + span('d', 7.5, 1), + span('e', 10.5, 1), + ] + expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c']) + }) + + test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => { + const moving = span('m', 5, 1) // [4.5,5.5] + const containing = span('s', 5, 2) // [4,6] straddles both edges + expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([]) + }) + + test('alignment detectors ignore the moving opening if present in siblings', () => { + const moving = span('m', 5, 2, 1.5, 1) + expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull() + expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull() + }) + + test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => { + const moving = span('m', 5, 2) // centre 5 + const sib = span('s', 4.96, 0.5) // centre 4.96 + const a = detectAlongWallAlignment(moving, [sib], 0.08) + expect(a?.movingFeature).toBe('center') + expect(a?.snap).toBeCloseTo(-0.04) + }) +}) diff --git a/packages/core/src/services/opening-guides.ts b/packages/core/src/services/opening-guides.ts new file mode 100644 index 00000000..5f0da29e --- /dev/null +++ b/packages/core/src/services/opening-guides.ts @@ -0,0 +1,404 @@ +// Proximity / alignment guides for wall-hosted openings (doors, windows). +// +// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene +// store, no React — so it runs identically for the 3D viewport and the 2D +// floor plan and is unit-testable in isolation. Callers extract the spans from +// the scene graph (an opening's `position[0]` is its along-wall centre, its +// `position[1]` its vertical centre with the wall base at y=0) and feed them in; +// the renderers transform the returned wall-local coordinates back to world +// (3D) or plan (2D). +// +// What it produces, mirroring the affordances architects expect (and Figma's +// smart guides): +// - sill/head : a window's bottom edge → floor and top edge → wall top. +// - edge gaps : along-wall clearance to the nearest neighbour opening (or +// the wall end) on each side. +// - alongWall : the moving opening's edge/centre lining up with a +// neighbour's edge/centre along the wall. +// - vertical : two openings sharing a sill / head / vertical centre. +// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them. +// +// Detection is passive — it reports what currently coincides within tolerance +// and the snap delta that would make it exact, leaving the snap decision to the +// caller's manipulation policy (grid vs. alignment vs. Shift bypass). + +/** An opening's footprint in its host wall's local frame. */ +export type OpeningSpan = { + id: string + /** Centre along the wall, measured from `wall.start` (m). */ + centerS: number + /** Along-wall extent (m). */ + width: number + /** Vertical centre above the wall base (floor at y=0) (m). */ + centerY: number + /** Vertical extent (m). */ + height: number +} + +export type WallExtent = { + /** Wall length (m). */ + length: number + /** Wall height (m). */ + height: number +} + +export type OpeningGuideTolerances = { + /** Max distance for an edge/centre to count as aligned with a neighbour (m). */ + align: number + /** Max difference between two gaps for them to count as equal (m). */ + equalSpacing: number + /** Gaps below this are treated as touching/overlap noise and ignored (m). */ + minGap: number +} + +export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = { + // Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`). + align: 0.08, + equalSpacing: 0.03, + minGap: 0.02, +} + +/** Which along-wall feature of an opening a guide references. */ +export type AlongWallFeature = 'left' | 'center' | 'right' +/** Which vertical feature of an opening a guide references. */ +export type VerticalFeature = 'sill' | 'center' | 'top' + +export type SillHeadGuide = { + /** Floor (y=0) → the opening's bottom edge (m). */ + sill: number + /** Wall-local y of the bottom edge. */ + bottomY: number + /** The opening's top edge → the wall top (m). */ + head: number + /** Wall-local y of the top edge. */ + topY: number +} + +export type EdgeGap = { + side: 'left' | 'right' + /** Clearance along the wall (m). */ + distance: number + /** Wall-local s of the moving opening's edge. */ + fromS: number + /** Wall-local s of the neighbour edge / wall end. */ + toS: number + target: 'opening' | 'wall-start' | 'wall-end' + /** Set when `target === 'opening'`. */ + targetId?: string +} + +export type AlongWallAlignment = { + /** Wall-local s the two features share. */ + s: number + movingFeature: AlongWallFeature + targetId: string + targetFeature: AlongWallFeature + /** Delta to add to the moving opening's `centerS` to make them coincide. */ + snap: number +} + +export type VerticalAlignment = { + /** Wall-local y the two features share. */ + y: number + movingFeature: VerticalFeature + targetId: string + targetFeature: VerticalFeature + /** Delta to add to the moving opening's `centerY` to make them coincide. */ + snap: number +} + +export type EqualSpacingRun = { + /** The repeated gap value (average of the run's gaps) (m). */ + gap: number + /** The equal-gap segments along the wall, in order (left → right). */ + segments: { fromS: number; toS: number }[] + /** Participating opening ids, ordered along the wall, including the moving one. */ + openingIds: string[] +} + +export type OpeningGuides = { + sillHead: SillHeadGuide | null + gaps: EdgeGap[] + alongWall: AlongWallAlignment | null + vertical: VerticalAlignment | null + equalSpacing: EqualSpacingRun | null +} + +export type OpeningGuideInput = { + moving: OpeningSpan + /** Other openings on the SAME wall (the moving opening excluded). */ + siblings: readonly OpeningSpan[] + wall: WallExtent + /** + * Whether to compute vertical (sill/head/vertical-alignment) guides. True for + * windows; false for doors, which sit on the floor so their sill is always 0. + */ + includeVertical: boolean + tolerances?: Partial +} + +const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2 +const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2 +const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2 +const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2 + +function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number { + if (feature === 'left') return leftEdge(s) + if (feature === 'right') return rightEdge(s) + return s.centerS +} + +function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number { + if (feature === 'sill') return bottomEdge(s) + if (feature === 'top') return topEdge(s) + return s.centerY +} + +const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right'] +const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top'] + +/** + * Edge-to-edge clearance from the moving opening to the nearest neighbour on + * each side, falling back to the wall ends when there is no neighbour — the + * "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side + * is omitted when its clearance is below `minGap` (the opening is flush against + * or overlapping that neighbour). + */ +export function computeEdgeGaps( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + wall: WallExtent, + minGap: number, +): EdgeGap[] { + const movingLeft = leftEdge(moving) + const movingRight = rightEdge(moving) + + // A sibling that straddles one of the moving opening's edges is an OVERLAP, + // not a neighbour: there is no clearance on that side, and we must not fall + // back to the wall end (which would report a misleading distance measured + // "through" the overlapping opening). + const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft) + const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight) + + let leftNeighbour: { s: number; id: string } | null = null + let rightNeighbour: { s: number; id: string } | null = null + for (const sib of siblings) { + const sibRight = rightEdge(sib) + const sibLeft = leftEdge(sib) + // Entirely to the left of the moving opening → candidate left neighbour. + if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) { + leftNeighbour = { s: sibRight, id: sib.id } + } + // Entirely to the right → candidate right neighbour. + if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) { + rightNeighbour = { s: sibLeft, id: sib.id } + } + } + + const gaps: EdgeGap[] = [] + + if (!leftCrossed) { + const leftToS = leftNeighbour ? leftNeighbour.s : 0 + const leftDistance = movingLeft - leftToS + if (leftDistance >= minGap) { + gaps.push({ + side: 'left', + distance: leftDistance, + fromS: movingLeft, + toS: leftToS, + target: leftNeighbour ? 'opening' : 'wall-start', + targetId: leftNeighbour?.id, + }) + } + } + + if (!rightCrossed) { + const rightToS = rightNeighbour ? rightNeighbour.s : wall.length + const rightDistance = rightToS - movingRight + if (rightDistance >= minGap) { + gaps.push({ + side: 'right', + distance: rightDistance, + fromS: movingRight, + toS: rightToS, + target: rightNeighbour ? 'opening' : 'wall-end', + targetId: rightNeighbour?.id, + }) + } + } + + return gaps +} + +/** + * The closest coincidence between any of the moving opening's edges/centre and + * any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge + * and centre-to-centre are weighed equally; the single closest pair wins + * (matching the one-guide-per-axis behaviour of the floor-plane resolver). + */ +export function detectAlongWallAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): AlongWallAlignment | null { + let best: AlongWallAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of ALONG_WALL_FEATURES) { + const movingCoord = alongWallFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of ALONG_WALL_FEATURES) { + const targetCoord = alongWallFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + s: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * The closest coincidence between the moving opening's sill/centre/top and any + * sibling's sill/centre/top, within `tolerance` — the "these two windows share + * a sill height" detector. Same single-best-match policy as the along-wall + * variant. + */ +export function detectVerticalAlignment( + moving: OpeningSpan, + siblings: readonly OpeningSpan[], + tolerance: number, +): VerticalAlignment | null { + let best: VerticalAlignment | null = null + let bestAbs = tolerance + for (const movingFeature of VERTICAL_FEATURES) { + const movingCoord = verticalFeatureCoord(moving, movingFeature) + for (const sib of siblings) { + if (sib.id === moving.id) continue + for (const targetFeature of VERTICAL_FEATURES) { + const targetCoord = verticalFeatureCoord(sib, targetFeature) + const diff = targetCoord - movingCoord + const abs = Math.abs(diff) + if (abs <= bestAbs && (best === null || abs < bestAbs)) { + bestAbs = abs + best = { + y: targetCoord, + movingFeature, + targetId: sib.id, + targetFeature, + snap: diff, + } + } + } + } + } + return best +} + +/** + * Figma-style equal-spacing detection: order all openings along the wall, look + * at the clearances BETWEEN consecutive openings, and return the longest run of + * ≥2 consecutive gaps that are equal within `tolerance` and that the moving + * opening participates in (so the badges only appear while the drag is actually + * forming or extending a series). Returns null when no such run exists. + * + * Gaps below `minGap` (touching/overlapping openings) break a run — a row of + * flush openings is not "equally spaced". + */ +export function detectEqualSpacing( + allOpenings: readonly OpeningSpan[], + movingId: string, + tolerance: number, + minGap: number, +): EqualSpacingRun | null { + if (allOpenings.length < 3) return null + const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS) + const movingIndex = sorted.findIndex((s) => s.id === movingId) + if (movingIndex < 0) return null + + // Clearance between opening i and i+1. + const gaps: { value: number; fromS: number; toS: number }[] = [] + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i] + const b = sorted[i + 1] + if (!a || !b) continue + const fromS = rightEdge(a) + const toS = leftEdge(b) + gaps.push({ value: toS - fromS, fromS, toS }) + } + + // Longest contiguous window of gaps that are (a) each ≥ minGap and (b) + // mutually equal within tolerance (window max − min ≤ tolerance), spanning at + // least 2 gaps and including the moving opening. Brute force over windows + // (openings per wall are few). A first-gap-anchored greedy scan is NOT + // equivalent: it drops a valid run that begins partway through a drifting + // sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end, + // where [1.02, 1.04] is a real run. On a length tie the leftmost window wins, + // for determinism. + let best: EqualSpacingRun | null = null + for (let lo = 0; lo < gaps.length; lo++) { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (let hi = lo; hi < gaps.length; hi++) { + const gap = gaps[hi] + if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run + min = Math.min(min, gap.value) + max = Math.max(max, gap.value) + if (max - min > tolerance) break // extending only widens the spread + const gapCount = hi - lo + 1 + if (gapCount < 2) continue + const firstOpening = lo // gap i sits between openings i and i+1 + const lastOpening = hi + 1 + if (movingIndex < firstOpening || movingIndex > lastOpening) continue + if (best !== null && gapCount <= best.segments.length) continue + const windowGaps = gaps.slice(lo, hi + 1) + best = { + gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length, + segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })), + openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id), + } + } + } + return best +} + +/** + * Compute every proximity/alignment guide for the moving opening in one pass. + * Pure: feed it the moving opening's wall-local span, its same-wall siblings, + * and the wall extent; render the result in whichever view. + */ +export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides { + const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances } + const { moving, siblings, wall, includeVertical } = input + + const sillHead: SillHeadGuide | null = includeVertical + ? { + sill: bottomEdge(moving), + bottomY: bottomEdge(moving), + head: wall.height - topEdge(moving), + topY: topEdge(moving), + } + : null + + return { + sillHead, + gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap), + alongWall: detectAlongWallAlignment(moving, siblings, tol.align), + vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null, + equalSpacing: detectEqualSpacing( + [moving, ...siblings], + moving.id, + tol.equalSpacing, + tol.minGap, + ), + } +} diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts new file mode 100644 index 00000000..3dbbbf35 --- /dev/null +++ b/packages/core/src/services/port-connectivity.ts @@ -0,0 +1,213 @@ +import { nodeRegistry } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * Connectivity-aware editing for port-bearing distribution kinds + * (HVAC ductwork AND DWV plumbing). + * + * Two nodes are "connected" when a port of one coincides in space with a + * port of the other — exactly how the placement tools mate a fitting onto + * a duct end (they snap the fitting's collar onto the run's open port). + * This service reads that relationship back out so an edit to one node can + * carry its neighbours along. + * + * Pure logic: it asks each node for its ports via `def.ports` (level-local + * meters) and does arithmetic. No Three.js, no rendering — it lives in + * core and is consumed by the editor's move tool and the duct-segment + * system alike. + * + * Propagation is intentionally **one hop**: a moved fitting stretches the + * ducts touching it (their near endpoint follows) and rigidly drags any + * fitting mated collar-to-collar, but it does NOT chase the far end of + * those ducts or anything beyond. Bounded and predictable — no runaway + * network rearrangement. + */ + +type Point = readonly [number, number, number] + +/** Distance (meters) under which two ports count as the same joint. Joints + * formed by placement snapping coincide to sub-millimeter; 5 cm leaves + * generous slack for grid-snapped hand placement without false matches. */ +const COINCIDENT_EPS_M = 0.05 + +/** A node attached to one of the moved node's ports, plus how it follows. */ +export type PortConnection = + | { + /** Partner is a duct run: the endpoint touching the moved port slides + * to track it (one hop — the far endpoint stays put, stretching the + * run). */ + kind: 'duct-endpoint' + nodeId: AnyNodeId + /** Index in the duct's `path` that tracks the moved port. */ + pathIndex: number + /** The moved node's port id this endpoint follows. */ + movedPortId: string + /** The duct's full path at edit-start (other points are preserved). */ + startPath: Point[] + } + | { + /** Partner is another fitting mated collar-to-collar: it translates + * rigidly so its collar stays on the moved collar. */ + kind: 'rigid-node' + nodeId: AnyNodeId + movedPortId: string + /** Partner node's `position` at edit-start. */ + startPosition: Point + } + +export type PortConnectivity = { + movedNodeId: AnyNodeId + /** The moved node's port world positions at edit-start, keyed by port id. + * Used as the reference each connection's delta is measured from. */ + startMovedPorts: Record + connections: PortConnection[] +} + +function portsOf( + node: AnyNode, +): ReadonlyArray<{ id: string; position: Point; system?: string }> | undefined { + return nodeRegistry.get(node.type)?.ports?.(node) as + | ReadonlyArray<{ id: string; position: Point; system?: string }> + | undefined +} + +/** A node's distribution role from the registry (run / fitting / …). */ +function roleOf(node: AnyNode): string | undefined { + return nodeRegistry.get(node.type)?.distributionRole +} + +function distSq(a: Point, b: Point): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +/** + * Snapshot which nodes are connected to `movedNode`'s ports, taken at the + * start of a move/resize. Call once before the drag; feed the result to + * `resolveConnectivityUpdates` on every frame. + * + * Only `run`-role partners (segments — endpoint stretch) and `fitting`-role + * partners (rigid follow) are tracked — terminals and equipment usually mount + * to a surface and shouldn't be yanked off it when an adjacent fitting nudges. + */ +export function analyzePortConnectivity( + movedNode: AnyNode, + nodes: Record, +): PortConnectivity { + const movedPorts = portsOf(movedNode) ?? [] + const startMovedPorts: Record = {} + const movedPortSystem: Record = {} + for (const p of movedPorts) { + startMovedPorts[p.id] = p.position + movedPortSystem[p.id] = p.system + } + + const connections: PortConnection[] = [] + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + + for (const other of Object.values(nodes)) { + if (!other || other.id === movedNode.id) continue + // Generalised across every distribution family (HVAC duct + DWV pipe): + // `run` partners stretch an endpoint, `fitting` partners follow rigidly. + // Terminals/equipment mount to surfaces and are intentionally NOT dragged. + // Fittings that declare `portConnectivityFollow: false` are anchored + // fixtures (e.g. pipe-trap) — moving a connected run stretches the arm. + const otherRole = roleOf(other) + if (otherRole !== 'run' && otherRole !== 'fitting') continue + const otherDef = nodeRegistry.get(other.type) + if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue + const otherPorts = portsOf(other) + if (!otherPorts) continue + + for (const op of otherPorts) { + // Find which of the moved node's ports this partner port sits on. + let matchedId: string | null = null + for (const mp of movedPorts) { + if (distSq(op.position, mp.position) > epsSq) continue + // Don't fuse ports from incompatible systems (e.g. a supply duct + // and a waste pipe that happen to cross): only mate when both + // ports declare the same system, or at least one is unscoped. + const ms = movedPortSystem[mp.id] + if (ms && op.system && ms !== op.system) continue + matchedId = mp.id + break + } + if (!matchedId) continue + + if (otherRole === 'run') { + const path = (other as unknown as { path?: Point[] }).path + if (!Array.isArray(path) || path.length < 2) continue + // Port id 'start' → first point, 'end' → last point. + const pathIndex = op.id === 'start' ? 0 : path.length - 1 + connections.push({ + kind: 'duct-endpoint', + nodeId: other.id, + pathIndex, + movedPortId: matchedId, + startPath: path.map((p) => [...p] as Point), + }) + } else { + const position = (other as unknown as { position?: Point }).position + if (!position) continue + connections.push({ + kind: 'rigid-node', + nodeId: other.id, + movedPortId: matchedId, + startPosition: [position[0], position[1], position[2]], + }) + } + } + } + + return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts } +} + +/** + * Given the moved node in its live (in-drag) transform, produce the patches + * that keep every connected node attached. `previewNode` is the moved node + * with its current drag position/rotation applied so its ports recompute. + * + * - Duct endpoint: set the tracked path point to the moved port's new + * position (the joint stays welded; the run stretches). + * - Rigid fitting: translate by the moved port's delta so its mated collar + * rides along. + */ +export function resolveConnectivityUpdates( + connectivity: PortConnectivity, + previewNode: AnyNode, +): { id: AnyNodeId; data: Partial }[] { + const newPorts = portsOf(previewNode) ?? [] + const newById: Record = {} + for (const p of newPorts) newById[p.id] = p.position + + const updates: { id: AnyNodeId; data: Partial }[] = [] + for (const conn of connectivity.connections) { + const start = connectivity.startMovedPorts[conn.movedPortId] + const now = newById[conn.movedPortId] + if (!start || !now) continue + + if (conn.kind === 'duct-endpoint') { + const path = conn.startPath.map((p, i) => + i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point), + ) + updates.push({ id: conn.nodeId, data: { path } as Partial }) + } else { + const dx = now[0] - start[0] + const dy = now[1] - start[1] + const dz = now[2] - start[2] + updates.push({ + id: conn.nodeId, + data: { + position: [ + conn.startPosition[0] + dx, + conn.startPosition[1] + dy, + conn.startPosition[2] + dz, + ], + } as Partial, + }) + } + } + return updates +} diff --git a/packages/core/src/services/riser-diagram.test.ts b/packages/core/src/services/riser-diagram.test.ts new file mode 100644 index 00000000..56014f29 --- /dev/null +++ b/packages/core/src/services/riser-diagram.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '../schema' +import { buildRiserDiagram, projectIso } from './riser-diagram' + +type Point = [number, number, number] + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +describe('projectIso', () => { + test('higher elevation maps to smaller screen Y', () => { + const [, lowY] = projectIso(0, 0, 0) + const [, highY] = projectIso(0, 2, 0) + expect(highY).toBeLessThan(lowY) + }) +}) + +describe('buildRiserDiagram', () => { + test('null when no DWV nodes', () => { + const wall = makeNode('wall', {}) + expect(buildRiserDiagram(sceneOf(wall))).toBeNull() + }) + + test('classifies a vertical stack vs a sloped horizontal drain', () => { + const stack = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 3, + system: 'vent', + }) + const drain = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [3, -0.06, 0], + ] as Point[], + diameter: 2, + system: 'waste', + }) + const diagram = buildRiserDiagram(sceneOf(stack, drain))! + const stackLine = diagram.lines.find((l) => l.nodeId === stack.id)! + const drainLine = diagram.lines.find((l) => l.nodeId === drain.id)! + expect(stackLine.vertical).toBe(true) + expect(drainLine.vertical).toBe(false) + }) + + test('emits a vent-termination marker for a vent run', () => { + const vent = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 2, + system: 'vent', + }) + const diagram = buildRiserDiagram(sceneOf(vent))! + expect(diagram.markers.some((m) => m.kind === 'vent-termination')).toBe(true) + }) + + test('labels traps', () => { + const trap = makeNode('pipe-trap', { + position: [1, 0, 0] as Point, + diameter: 1.5, + }) + const diagram = buildRiserDiagram(sceneOf(trap))! + expect(diagram.markers.some((m) => m.kind === 'trap')).toBe(true) + }) +}) diff --git a/packages/core/src/services/riser-diagram.ts b/packages/core/src/services/riser-diagram.ts new file mode 100644 index 00000000..d5c7a7ae --- /dev/null +++ b/packages/core/src/services/riser-diagram.ts @@ -0,0 +1,143 @@ +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * Riser diagram (plumbing isometric) — the conventional way DWV systems + * are drawn for permit: the drain/vent tree projected to a 30° iso so + * vertical stacks read as vertical and horizontal runs lean off at 30°, + * annotated with size + slope and vent terminations. + * + * This is a pure projector: it turns the scene's DWV nodes into 2D + * drawables (level-independent, no rendering). The editor draws the + * result as SVG. Air/refrigerant nodes are ignored — riser diagrams are + * a plumbing convention. + */ + +const COS30 = Math.cos(Math.PI / 6) +const SIN30 = Math.sin(Math.PI / 6) + +/** A 3D level-local point (meters) projected to 2D iso screen space. + * Screen Y grows DOWNWARD (SVG convention), so higher elevation → lower + * screen Y. */ +export function projectIso(x: number, y: number, z: number): [number, number] { + const sx = (x - z) * COS30 + const sy = (x + z) * SIN30 - y + return [sx, sy] +} + +export type RiserLine = { + /** Projected endpoints in iso screen space. */ + from: [number, number] + to: [number, number] + system: 'waste' | 'vent' + /** Nominal size in inches. */ + diameter: number + /** True for a (near-)vertical run — drawn solid/bold as a stack. */ + vertical: boolean + /** Source node, so the editor can link selection. */ + nodeId: AnyNodeId +} + +export type RiserMarker = { + point: [number, number] + kind: 'trap' | 'vent-termination' | 'fitting' + label: string + nodeId: AnyNodeId +} + +export type RiserDiagram = { + lines: RiserLine[] + markers: RiserMarker[] + /** Bounding box of all projected geometry, screen space. */ + bounds: { minX: number; minY: number; maxX: number; maxY: number } +} + +/** Elevation gain per horizontal meter under which a leg is "vertical". */ +const VERTICAL_EPS = 4 // dy/dxz ratio: steeper than this reads as a stack + +type Vec3 = readonly [number, number, number] + +function legIsVertical(a: Vec3, b: Vec3): boolean { + const horizontal = Math.hypot(b[0] - a[0], b[2] - a[2]) + const vertical = Math.abs(b[1] - a[1]) + if (horizontal < 1e-4) return true + return vertical / horizontal > VERTICAL_EPS +} + +/** + * Build the riser diagram for the whole scene. Returns null when there's + * no DWV geometry to draw. + */ +export function buildRiserDiagram( + nodes: Readonly>, +): RiserDiagram | null { + const lines: RiserLine[] = [] + const markers: RiserMarker[] = [] + + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + const grow = (p: [number, number]) => { + if (p[0] < minX) minX = p[0] + if (p[1] < minY) minY = p[1] + if (p[0] > maxX) maxX = p[0] + if (p[1] > maxY) maxY = p[1] + } + + for (const node of Object.values(nodes)) { + if (!node) continue + if (node.type === 'pipe-segment') { + const path = node.path as Vec3[] + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + const from = projectIso(a[0], a[1], a[2]) + const to = projectIso(b[0], b[1], b[2]) + grow(from) + grow(to) + lines.push({ + from, + to, + system: node.system, + diameter: node.diameter, + vertical: legIsVertical(a, b), + nodeId: node.id, + }) + } + // Vent runs that end above everything are vent terminations + // (through-roof). Tag the highest endpoint of a vent run. + if (node.system === 'vent') { + const top = path.reduce((hi, p) => (p[1] > hi[1] ? p : hi), path[0]!) + const pt = projectIso(top[0], top[1], top[2]) + markers.push({ + point: pt, + kind: 'vent-termination', + label: `${node.diameter}" VTR`, + nodeId: node.id, + }) + } + } else if (node.type === 'pipe-trap') { + const pt = projectIso(node.position[0], node.position[1], node.position[2]) + grow(pt) + markers.push({ + point: pt, + kind: 'trap', + label: `${node.diameter}" P-trap`, + nodeId: node.id, + }) + } else if (node.type === 'pipe-fitting') { + const pt = projectIso(node.position[0], node.position[1], node.position[2]) + grow(pt) + markers.push({ + point: pt, + kind: 'fitting', + label: node.fittingType, + nodeId: node.id, + }) + } + } + + if (lines.length === 0 && markers.length === 0) return null + + return { lines, markers, bounds: { minX, minY, maxX, maxY } } +} diff --git a/packages/core/src/services/system-graph.test.ts b/packages/core/src/services/system-graph.test.ts new file mode 100644 index 00000000..e7640d67 --- /dev/null +++ b/packages/core/src/services/system-graph.test.ts @@ -0,0 +1,158 @@ +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 { buildPortComponents, summarizeSystemFor } from './system-graph' + +type Point = [number, number, number] + +// Stub registrations: the graph consults `def.ports` for the connectivity +// graph and `def.distributionRole` to classify each node. Mirrors the real +// kinds' port + role conventions (duct runs expose start/end, equipment a +// supply collar, terminals one collar) without importing the nodes package. +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('hvac-equipment', 'equipment', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'supply', position, direction: [0, 1, 0], diameter: 12, system: 'supply' }] +}) +stubDef('duct-terminal', 'terminal', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'collar', position, direction: [0, -1, 0], diameter: 6, system: 'supply' }] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +function run(path: Point[], system = 'supply'): AnyNode { + return makeNode('duct-segment', { path, system, diameter: 6 }) +} + +describe('buildPortComponents', () => { + test('chained runs land in one component; a distant run is separate', () => { + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const b = run([ + [3, 0, 0], + [3, 0, 4], + ]) // shares a's end + const c = run([ + [20, 0, 0], + [24, 0, 0], + ]) // far away + const components = buildPortComponents(sceneOf(a, b, c)) + expect(components.length).toBe(2) + const joined = components.find((g) => g.length === 2)! + expect(new Set(joined)).toEqual(new Set([a.id, b.id])) + }) + + test('joints within tolerance still join; outside do not', () => { + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const near = run([ + [3.03, 0, 0], + [6, 0, 0], + ]) // 3 cm — joined + const far = run([ + [3.2, 0, 4], + [6, 0, 4], + ]) // 20 cm in another row — separate + const components = buildPortComponents(sceneOf(a, near, far)) + expect(components.length).toBe(2) + }) + + test('nodes without ports do not participate', () => { + const wall = makeNode('wall', {}) + const a = run([ + [0, 0, 0], + [3, 0, 0], + ]) + const components = buildPortComponents(sceneOf(wall, a)) + expect(components.length).toBe(1) + expect(components[0]).toEqual([a.id]) + }) +}) + +describe('summarizeSystemFor', () => { + test('full tree: equipment → run → terminal, stats add up', () => { + const furnace = makeNode('hvac-equipment', { position: [0, 0, 0] as Point }) + const trunk = run([ + [0, 0, 0], + [4, 0, 0], + ]) + const branch = run([ + [4, 0, 0], + [4, 0, 3], + ]) + const register = makeNode('duct-terminal', { + position: [4, 0, 3] as Point, + terminalType: 'supply-register', + }) + const scene = sceneOf(furnace, trunk, branch, register) + + const summary = summarizeSystemFor(register.id, scene)! + expect(summary.nodeIds.length).toBe(4) + expect(summary.connectedToEquipment).toBe(true) + expect(summary.runCount).toBe(2) + expect(summary.runLengthM).toBeCloseTo(7, 6) + expect(summary.terminalCount).toBe(1) + expect(summary.equipmentCount).toBe(1) + expect(summary.systems).toEqual(['supply']) + }) + + test('orphaned run reports no equipment', () => { + const lonely = run([ + [10, 0, 10], + [14, 0, 10], + ]) + const summary = summarizeSystemFor(lonely.id, sceneOf(lonely))! + expect(summary.connectedToEquipment).toBe(false) + expect(summary.runCount).toBe(1) + expect(summary.runLengthM).toBeCloseTo(4, 6) + }) + + test('port-less node → null', () => { + const wall = makeNode('wall', {}) + expect(summarizeSystemFor(wall.id, sceneOf(wall))).toBeNull() + }) +}) diff --git a/packages/core/src/services/system-graph.ts b/packages/core/src/services/system-graph.ts new file mode 100644 index 00000000..9dc6d90d --- /dev/null +++ b/packages/core/src/services/system-graph.ts @@ -0,0 +1,196 @@ +import { nodeRegistry } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * The "System" primitive: connected components over the port graph. + * + * Two nodes are joined when a port of one coincides in space with a port + * of the other — the same mated-joint relationship `port-connectivity` + * uses for drag propagation, read here at whole-scene scope. A component + * is one distribution system: a furnace, its trunk, the tees, branches, + * and registers hanging off it. + * + * Pure logic (def.ports + arithmetic), no rendering — lives in core so + * the editor (badges, schedules) and analyses (sizing, code checks) can + * share it. + */ + +/** Distance (meters) under which two ports count as the same joint — + * matches port-connectivity's tolerance for hand-placed joints. */ +const COINCIDENT_EPS_M = 0.05 + +export type SystemSummary = { + /** Every node in this connected component. */ + nodeIds: AnyNodeId[] + /** Distribution loops present, e.g. ['supply'], ['supply','return']. */ + systems: string[] + /** Duct / lineset run statistics. */ + runCount: number + runLengthM: number + fittingCount: number + terminalCount: number + equipmentCount: number + /** False = orphaned subtree: air goes nowhere (no furnace / air + * handler / condenser anywhere in the component). */ + connectedToEquipment: boolean +} + +type PortRecord = { + nodeId: AnyNodeId + x: number + y: number + z: number + system: string | undefined +} + +function collectPorts(nodes: Readonly>): PortRecord[] { + const result: PortRecord[] = [] + for (const node of Object.values(nodes)) { + if (!node) continue + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (!ports) continue + for (const port of ports) { + result.push({ + nodeId: node.id, + x: port.position[0], + y: port.position[1], + z: port.position[2], + system: port.system, + }) + } + } + return result +} + +/** Union-find over node ids. */ +class Components { + private parent = new Map() + + find(id: AnyNodeId): AnyNodeId { + let root = this.parent.get(id) ?? id + if (root !== id) { + root = this.find(root) + this.parent.set(id, root) + } + return root + } + + union(a: AnyNodeId, b: AnyNodeId): void { + const ra = this.find(a) + const rb = this.find(b) + if (ra !== rb) this.parent.set(rb, ra) + } +} + +function pathLength(path: ReadonlyArray): number { + let total = 0 + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + total += Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + } + return total +} + +/** + * Group every port-bearing node into connected components via coinciding + * ports. Nodes with ports but no joints form singleton components; nodes + * without `def.ports` don't participate at all. + */ +export function buildPortComponents(nodes: Readonly>): AnyNodeId[][] { + const ports = collectPorts(nodes) + const components = new Components() + const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M + + for (let i = 0; i < ports.length; i++) { + const a = ports[i]! + for (let j = i + 1; j < ports.length; j++) { + const b = ports[j]! + if (a.nodeId === b.nodeId) continue + const dx = a.x - b.x + const dy = a.y - b.y + const dz = a.z - b.z + if (dx * dx + dy * dy + dz * dz <= epsSq) components.union(a.nodeId, b.nodeId) + } + } + + const grouped = new Map() + const seen = new Set() + for (const port of ports) { + if (seen.has(port.nodeId)) continue + seen.add(port.nodeId) + const root = components.find(port.nodeId) + const group = grouped.get(root) + if (group) group.push(port.nodeId) + else grouped.set(root, [port.nodeId]) + } + return [...grouped.values()] +} + +function summarize( + nodeIds: AnyNodeId[], + nodes: Readonly>, +): SystemSummary { + const systems = new Set() + let runCount = 0 + let runLengthM = 0 + let fittingCount = 0 + let terminalCount = 0 + let equipmentCount = 0 + + for (const id of nodeIds) { + const node = nodes[id] + if (!node) continue + const role = nodeRegistry.get(node.type)?.distributionRole + const fields = node as { + path?: ReadonlyArray + system?: string + terminalType?: string + } + if (role === 'run') { + runCount += 1 + if (fields.path) runLengthM += pathLength(fields.path) + // Linesets carry refrigerant; duct / pipe runs name their own loop. + systems.add(fields.system ?? 'refrigerant') + } else if (role === 'fitting') { + fittingCount += 1 + if (fields.system) systems.add(fields.system) + } else if (role === 'terminal') { + terminalCount += 1 + systems.add(fields.terminalType === 'return-grille' ? 'return' : 'supply') + } else if (role === 'equipment') { + equipmentCount += 1 + } + } + + return { + nodeIds, + systems: [...systems].sort(), + runCount, + runLengthM, + fittingCount, + terminalCount, + equipmentCount, + connectedToEquipment: equipmentCount > 0, + } +} + +/** + * Summary of the system the given node belongs to, or null when the node + * has no ports (not a distribution kind). A node with ports but no + * joints yet still gets a (singleton) summary — `connectedToEquipment: + * false` is the interesting signal there. + */ +export function summarizeSystemFor( + nodeId: AnyNodeId, + nodes: Readonly>, +): SystemSummary | null { + const node = nodes[nodeId] + if (!node) return null + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (!ports || ports.length === 0) return null + for (const component of buildPortComponents(nodes)) { + if (component.includes(nodeId)) return summarize(component, nodes) + } + return summarize([nodeId], nodes) +} diff --git a/packages/core/src/services/validate-dwv.test.ts b/packages/core/src/services/validate-dwv.test.ts new file mode 100644 index 00000000..8d62b30a --- /dev/null +++ b/packages/core/src/services/validate-dwv.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNodeDefinition, NodePort } from '../registry' +import { registerNode } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' +import { validateDwv } from './validate-dwv' + +type Point = [number, number, number] + +// The validator reads node fields directly + buildPortComponents (which +// consults def.ports), so register stub port-providers for the DWV kinds +// it groups by. Mirrors the system-graph test's approach. +function stubDef(kind: string, ports: (node: AnyNode) => NodePort[]): void { + registerNode({ + kind, + schemaVersion: 1, + schema: {}, + category: 'utility', + defaults: () => ({}), + capabilities: {}, + ports, + } as unknown as AnyNodeDefinition) +} + +stubDef('pipe-segment', (node) => { + const path = (node as unknown as { path: Point[] }).path + const diameter = (node as unknown as { diameter: number }).diameter + const system = (node as unknown as { system: string }).system + return [ + { id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter, system }, + { + id: 'end', + position: path[path.length - 1]!, + direction: [1, 0, 0], + diameter, + system, + }, + ] +}) +stubDef('pipe-trap', (node) => { + const position = (node as unknown as { position: Point }).position + return [{ id: 'inlet', position, direction: [0, 1, 0], diameter: 1.5, system: 'waste' }] +}) + +let nextId = 0 +function makeNode(type: string, fields: Record): AnyNode { + nextId += 1 + return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode +} + +function sceneOf(...nodes: AnyNode[]): Record { + return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record +} + +/** A waste run from a→b. Drop the end Y to slope it. */ +function waste(path: Point[], diameter = 2): AnyNode { + return makeNode('pipe-segment', { path, diameter, system: 'waste' }) +} + +const QUARTER_PER_FOOT = 1 / 48 + +describe('validateDwv — slope', () => { + test('flags a flat waste run', () => { + const run = waste([ + [0, 0, 0], + [3, 0, 0], // dead level + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(true) + }) + + test('passes a run sloped at quarter-inch per foot', () => { + const drop = 3 * QUARTER_PER_FOOT + const run = waste([ + [0, 0, 0], + [3, -drop, 0], + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-flat')).toBe(false) + }) + + test('flags an over-steep run (siphoning risk)', () => { + // 2" pipe, max slope = 2/12 ≈ 0.167; drop 2m over 1m horizontal. + const run = waste([ + [0, 0, 0], + [1, -2, 0], + ]) + const findings = validateDwv(sceneOf(run)) + expect(findings.some((f) => f.code === 'slope-too-steep')).toBe(true) + }) + + test('ignores vents (level is fine)', () => { + const vent = makeNode('pipe-segment', { + path: [ + [0, 0, 0], + [0, 3, 0], + ] as Point[], + diameter: 2, + system: 'vent', + }) + const findings = validateDwv(sceneOf(vent)) + expect(findings.length).toBe(0) + }) +}) + +describe('validateDwv — trap arm', () => { + test('flags an over-long trap arm', () => { + const trap = makeNode('pipe-trap', { + position: [0, 0, 0] as Point, + diameter: 1.5, // max arm 42in = 1.067m + armLengthM: 2, // way over + }) + const findings = validateDwv(sceneOf(trap)) + expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(true) + }) + + test('passes a trap arm within the limit', () => { + const trap = makeNode('pipe-trap', { + position: [0, 0, 0] as Point, + diameter: 2, // max arm 60in = 1.524m + armLengthM: 1, + }) + const findings = validateDwv(sceneOf(trap)) + expect(findings.some((f) => f.code === 'trap-arm-too-long')).toBe(false) + }) +}) diff --git a/packages/core/src/services/validate-dwv.ts b/packages/core/src/services/validate-dwv.ts new file mode 100644 index 00000000..a2b3aecc --- /dev/null +++ b/packages/core/src/services/validate-dwv.ts @@ -0,0 +1,161 @@ +import type { AnyNode, AnyNodeId } from '../schema' +import { buildPortComponents } from './system-graph' + +/** + * IPC validators for the DWV (drain-waste-vent) system — the "CodeRule" + * primitive from the domain brief. The slope, minimum-size, and + * trap-arm rules are all geometric and read straight off the node + * fields, so they live here in core (pure logic) where the editor can + * surface them and analyses can reuse them. + * + * Scope is residential IPC, simplified: + * - 704.1 drainage slope by pipe size. + * - 909 trap-arm maximum developed length by trap size. + * + * These are intentionally conservative approximations, not a certified + * plan-check — enough to flag the mistakes a drawing tool invites. + */ + +/** Drainage findings, worst-first per consumer's sort. */ +export type DwvSeverity = 'error' | 'warning' + +export type DwvFinding = { + severity: DwvSeverity + /** Stable rule id, e.g. 'slope-too-flat'. */ + code: string + /** Human-readable, already-formatted message. */ + message: string + /** Nodes the finding implicates (usually one). */ + nodeIds: AnyNodeId[] +} + +/** IPC 704.1 minimum drainage slope (rise/run, dimensionless) by + * nominal pipe size: ¼"/ft (1:48) under 3", ⅛"/ft (1:96) for 3–6", + * 1/16"/ft (1:192) at 8"+. */ +function minSlopeFor(diameterIn: number): number { + if (diameterIn < 3) return 1 / 48 + if (diameterIn < 8) return 1 / 96 + return 1 / 192 +} + +/** IPC Table 909.1 maximum trap-arm developed length (meters) by trap + * size: 30" @ 1¼", 42" @ 1½", 60" @ 2", 72" @ 3", 120" @ 4". */ +const TRAP_ARM_MAX_M: ReadonlyArray = [ + [1.25, 30 * 0.0254], + [1.5, 42 * 0.0254], + [2, 60 * 0.0254], + [3, 72 * 0.0254], + [4, 120 * 0.0254], +] + +function trapArmMaxFor(diameterIn: number): number { + let max = Infinity + for (const [size, lengthM] of TRAP_ARM_MAX_M) { + if (diameterIn <= size) return lengthM + max = lengthM + } + return max +} + +/** Slopes shallower than this fraction of the minimum are flagged + * "too flat" — a small tolerance keeps round-off off the list. */ +const SLOPE_TOLERANCE = 0.9 +/** Horizontal legs shorter than this (meters) are treated as vertical + * stacks and skipped from the slope check. */ +const VERTICAL_LEG_EPS_M = 0.02 + +type Vec3 = readonly [number, number, number] + +function legSlope(a: Vec3, b: Vec3): { horizontalM: number; slope: number } { + const horizontalM = Math.hypot(b[0] - a[0], b[2] - a[2]) + if (horizontalM < VERTICAL_LEG_EPS_M) return { horizontalM, slope: Infinity } + return { horizontalM, slope: Math.abs(a[1] - b[1]) / horizontalM } +} + +function inchLabel(value: number): string { + return `${value}"` +} + +/** Per-foot slope as a readable fraction, e.g. 0.0208 → '¼"/ft'. */ +function slopePerFootLabel(slope: number): string { + const inchesPerFoot = slope * 12 + return `${inchesPerFoot.toFixed(2)}"/ft` +} + +/** + * Run every DWV rule over the scene and return the findings. Empty + * array = nothing to flag. Pure: no scene/store access, no rendering. + */ +export function validateDwv(nodes: Readonly>): DwvFinding[] { + const findings: DwvFinding[] = [] + + // ── Per-segment slope (waste only) ────────────────────────────── + for (const node of Object.values(nodes)) { + if (node?.type !== 'pipe-segment' || node.system !== 'waste') continue + const path = node.path as Vec3[] + const minSlope = minSlopeFor(node.diameter) + const maxSlope = node.diameter / 12 // 1 pipe-diameter per foot → siphoning + let flaggedFlat = false + let flaggedSteep = false + for (let i = 0; i < path.length - 1; i++) { + const { slope } = legSlope(path[i]!, path[i + 1]!) + if (slope === Infinity) continue // vertical stack leg + if (!flaggedFlat && slope < minSlope * SLOPE_TOLERANCE) { + findings.push({ + severity: 'error', + code: 'slope-too-flat', + message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel( + slope, + )} — IPC 704.1 requires at least ${slopePerFootLabel(minSlope)}.`, + nodeIds: [node.id], + }) + flaggedFlat = true + } + if (!flaggedSteep && slope > maxSlope) { + findings.push({ + severity: 'warning', + code: 'slope-too-steep', + message: `${inchLabel(node.diameter)} drain slopes ${slopePerFootLabel( + slope, + )} — over one pipe-diameter per foot risks siphoning the traps.`, + nodeIds: [node.id], + }) + flaggedSteep = true + } + } + } + + // ── Component-scoped trap rules ────────────────────────────────── + for (const component of buildPortComponents(nodes)) { + const traps: AnyNode[] = [] + + for (const id of component) { + const node = nodes[id] + if (!node) continue + if (node.type === 'pipe-trap') { + traps.push(node) + } + } + + // Trap-arm developed length: trap outlet → its vent, capped by size. + // Independent of waste segments — a trap on its own can already be + // over-armed. + for (const trap of traps) { + const t = trap as { id: AnyNodeId; diameter: number; armLengthM?: number } + const armLengthM = t.armLengthM ?? 0 + const maxArm = trapArmMaxFor(t.diameter) + if (armLengthM > maxArm + 1e-6) { + findings.push({ + severity: 'error', + code: 'trap-arm-too-long', + message: `${inchLabel(t.diameter)} trap arm runs ${(armLengthM / 0.0254).toFixed( + 0, + )}" to its vent — IPC 909.1 caps it at ${(maxArm / 0.0254).toFixed(0)}".`, + nodeIds: [t.id], + }) + } + } + } + + return findings +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index e33b2aeb..c7dcbb5e 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -31,6 +31,7 @@ import { type SceneMaterialId, } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' import * as nodeActions from './actions/node-actions' import { resetSceneHistoryPauseDepth } from './history-control' @@ -542,7 +543,10 @@ function migrateNodes(nodes: Record): { nodes: Record mintedMaterials: Record } { - const patchedNodes = { ...nodes } + // Repair pre-existing corruption (null children, zero-length walls) before + // any per-type migration runs, so already-saved scenes load cleanly. + const { nodes: healed } = healSceneNodes(nodes) + const patchedNodes = { ...healed } as Record // Scene materials minted while moving legacy wall fields onto `node.slots`; // merged into the scene material map by the caller (`setScene`). const mintedMaterials: Record = {} diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts new file mode 100644 index 00000000..349850ed --- /dev/null +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '../../schema' +import { calculateLevelMiters, getWallMiterBoundaryPoints } from './wall-mitering' + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_test', + children: [], + start, + end, + thickness: 0.1, + height: 2.5, + frontSide: 'interior', + backSide: 'exterior', + metadata: {}, + } as WallNode +} + +function maxBoundaryCoord(walls: WallNode[]): number { + const miter = calculateLevelMiters(walls) + let max = 0 + for (const w of walls) { + const bp = getWallMiterBoundaryPoints(w, miter) + expect(bp).not.toBeNull() + if (!bp) continue + for (const p of [bp.startLeft, bp.startRight, bp.endLeft, bp.endRight]) { + expect(Number.isFinite(p.x)).toBe(true) + expect(Number.isFinite(p.y)).toBe(true) + max = Math.max(max, Math.abs(p.x), Math.abs(p.y)) + } + } + return max +} + +describe('wall mitering miter limit', () => { + // Two 3 m walls sharing the origin, meeting at decreasing angles. Without a + // miter limit the joint point runs to infinity as the angle → 0 (∝ 1/sin θ), + // which is the "infinite wall" seen when a room-preset preview lands on top of + // an existing wall. The boundary must stay bounded near the wall length. + test.each([90, 30, 10, 5, 1, 0.1, 0.01])('stays bounded at a %s° junction', (deg) => { + const rad = (deg * Math.PI) / 180 + const walls = [ + wall('A', [0, 0], [3, 0]), + wall('B', [0, 0], [3 * Math.cos(rad), 3 * Math.sin(rad)]), + ] + // 3 m walls + a few cm of joint: anything past ~4 m is a runaway spike. + expect(maxBoundaryCoord(walls)).toBeLessThan(4) + }) + + test('still miters a normal 90° corner', () => { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])] + const miter = calculateLevelMiters(walls) + const bpA = getWallMiterBoundaryPoints(walls[0]!, miter) + expect(bpA).not.toBeNull() + if (!bpA) throw new Error('expected miter boundary points') + // The shared corner is pulled to the mitred intersection, offset from the + // raw butt position (halfThickness 0.05) by the diagonal of the joint. + const startSideX = Math.min(bpA.startLeft.x, bpA.startRight.x) + expect(startSideX).toBeLessThan(-0.001) + expect(startSideX).toBeGreaterThan(-0.5) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index e6a2f295..193f11be 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -35,6 +35,17 @@ type JunctionData = Map const TOLERANCE = 0.001 +// Miter joints are line-line intersections, so the joint point sits a distance +// ≈ halfThickness / sin(θ) from the junction, where θ is the angle between the +// two walls. As θ → 0 (two walls nearly collinear — e.g. a room-preset preview +// dragged on top of an existing wall, or a freshly-drawn wall almost parallel +// to its neighbour) that distance runs away to infinity and the wall renders as +// an infinite spike. Cap the joint at this multiple of the wall half-thickness; +// beyond it we fall back to a square (butt) joint, exactly like the existing +// parallel-walls guard. 10× preserves every realistic corner (a 0.1 m wall keeps +// mitering down to ~11°) while bounding the pathological near-collinear case. +const MITER_LIMIT = 10 + function pointToKey(p: Point2D, tolerance = TOLERANCE): string { const snap = 1 / tolerance return `${Math.round(p.x * snap)},${Math.round(p.y * snap)}` @@ -198,6 +209,7 @@ interface ProcessedWall { edgeA: LineEquation // Left edge edgeB: LineEquation // Right edge isPassthrough: boolean // True if wall passes through junction (T-junction) + halfThickness: number // Used to bound the miter joint against runaway spikes } function calculateJunctionIntersections( @@ -228,7 +240,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: true }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: true, + halfThickness: halfT, + }) } } else { // Normal wall endpoint (start or end) @@ -245,7 +264,14 @@ function calculateJunctionIntersections( const edgeB = createLineFromPointAndVector(pB, v) const angle = Math.atan2(v.y, v.x) - processedWalls.push({ wallId: wall.id, angle, edgeA, edgeB, isPassthrough: false }) + processedWalls.push({ + wallId: wall.id, + angle, + edgeA, + edgeB, + isPassthrough: false, + halfThickness: halfT, + }) } } @@ -275,6 +301,21 @@ function calculateJunctionIntersections( y: (wall2.edgeB.a * wall1.edgeA.c - wall1.edgeA.a * wall2.edgeB.c) / det, } + // Miter limit: `det` only catches walls that are *exactly* parallel. Two + // walls meeting at a shallow angle have a small-but-nonzero `det`, so `p` + // lands far from the junction (∝ 1/sin θ) and the wall renders as an + // infinite spike. Reject any joint farther than MITER_LIMIT half-thicknesses + // from the meeting point — those walls fall back to a square joint. + const maxMiter = MITER_LIMIT * Math.max(wall1.halfThickness, wall2.halfThickness) + const dx = p.x - meetingPoint.x + const dy = p.y - meetingPoint.y + if ( + !(Number.isFinite(p.x) && Number.isFinite(p.y)) || + dx * dx + dy * dy > maxMiter * maxMiter + ) { + continue + } + // Only assign intersection to non-passthrough walls // Passthrough walls don't receive junction data (their geometry doesn't change) if (!wall1.isPassthrough) { diff --git a/packages/core/src/utils/heal-scene-graph.test.ts b/packages/core/src/utils/heal-scene-graph.test.ts new file mode 100644 index 00000000..6b64658e --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' +import { healSceneNodes } from './heal-scene-graph' + +describe('healSceneNodes', () => { + test('strips non-string (null) children entries', () => { + const { nodes, strippedChildRefs } = healSceneNodes({ + wall_a: { + id: 'wall_a', + type: 'wall', + start: [0, 0], + end: [1, 0], + children: [null, 'item_x'], + }, + item_x: { id: 'item_x', type: 'item' }, + }) + expect(strippedChildRefs).toBe(1) + expect((nodes.wall_a as { children: string[] }).children).toEqual(['item_x']) + }) + + test('drops childless zero-length walls and removes their parent reference', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + level_0: { id: 'level_0', type: 'level', children: ['wall_zero', 'wall_real'] }, + wall_zero: { id: 'wall_zero', type: 'wall', start: [5, 5], end: [5, 5], children: [] }, + wall_real: { id: 'wall_real', type: 'wall', start: [0, 0], end: [3, 0], children: [] }, + }) + expect(droppedWallIds).toEqual(['wall_zero']) + expect('wall_zero' in nodes).toBe(false) + expect((nodes.level_0 as { children: string[] }).children).toEqual(['wall_real']) + }) + + test('keeps a zero-length wall that still hosts a door/window', () => { + const { nodes, droppedWallIds } = healSceneNodes({ + wall_z: { id: 'wall_z', type: 'wall', start: [1, 1], end: [1, 1], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + }) + expect(droppedWallIds).toEqual([]) + expect('wall_z' in nodes).toBe(true) + }) + + test('passes a clean scene through untouched', () => { + const input = { + wall_a: { id: 'wall_a', type: 'wall', start: [0, 0], end: [2, 0], children: ['door_1'] }, + door_1: { id: 'door_1', type: 'door' }, + } + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes(input) + expect(droppedWallIds).toEqual([]) + expect(strippedChildRefs).toBe(0) + expect(nodes.wall_a).toBe(input.wall_a) + }) +}) diff --git a/packages/core/src/utils/heal-scene-graph.ts b/packages/core/src/utils/heal-scene-graph.ts new file mode 100644 index 00000000..9ada1400 --- /dev/null +++ b/packages/core/src/utils/heal-scene-graph.ts @@ -0,0 +1,83 @@ +// Repairs scene-graph corruption that pre-dates the source fixes, so existing +// saved scenes still load. Two known kinds of damage, both produced by the +// capture wall-merge before it was fixed: +// +// 1. A `children` array containing a non-string entry. The merge re-attached a +// wall-hosted item without minting an id, so `undefined` was pushed into the +// wall's children — which serializes to `[null]`. The wall schema rejects +// `null` children, so the whole scene fails to load. +// 2. A zero-length wall (start === end). It renders nothing, but lingers as a +// junk node and is a foot-gun for snapping/mitering. +// +// Both are also prevented at the source now (see merge-walls.ts and the wall +// miter limit); this is the load-time safety net for already-saved scenes. + +const ZERO_LENGTH_EPS = 1e-6 + +export interface HealSceneResult { + nodes: Record + /** Ids of zero-length walls that were dropped. */ + droppedWallIds: string[] + /** Count of non-string (e.g. null) entries removed from `children` arrays. */ + strippedChildRefs: number +} + +function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } { + if (!node || typeof node !== 'object') return false + const n = node as Record + return ( + n.type === 'wall' && + Array.isArray(n.start) && + Array.isArray(n.end) && + typeof n.start[0] === 'number' && + typeof n.start[1] === 'number' && + typeof n.end[0] === 'number' && + typeof n.end[1] === 'number' + ) +} + +/** + * Returns a healed copy of a `nodes` map. Pure — does not mutate `input`. + * Nodes that need no repair are passed through by reference. + */ +export function healSceneNodes(input: Record): HealSceneResult { + const droppedWallIds: string[] = [] + + // Pass 1: drop childless zero-length walls. (Only childless ones — a wall + // carrying a door/window must keep its hosts, degenerate or not.) + const kept: Record = {} + for (const [id, node] of Object.entries(input)) { + if (isWallLike(node)) { + const children = (node as { children?: unknown }).children + const childless = !Array.isArray(children) || children.length === 0 + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + if (childless && Math.hypot(dx, dz) <= ZERO_LENGTH_EPS) { + droppedWallIds.push(id) + continue + } + } + kept[id] = node + } + + const dropped = new Set(droppedWallIds) + let strippedChildRefs = 0 + + // Pass 2: clean `children` arrays — drop non-string entries (the `[null]` bug) + // and references to walls we just removed. + const nodes: Record = {} + for (const [id, node] of Object.entries(kept)) { + const children = (node as { children?: unknown })?.children + if (Array.isArray(children)) { + const cleaned = children.filter((c): c is string => typeof c === 'string' && !dropped.has(c)) + if (cleaned.length !== children.length) { + strippedChildRefs += children.length - cleaned.length + nodes[id] = { ...(node as Record), children: cleaned } + continue + } + } + nodes[id] = node + } + + return { nodes, droppedWallIds, strippedChildRefs } +} diff --git a/packages/core/src/validation/validate-build-json.ts b/packages/core/src/validation/validate-build-json.ts index 23873c3e..128bfc34 100644 --- a/packages/core/src/validation/validate-build-json.ts +++ b/packages/core/src/validation/validate-build-json.ts @@ -1,4 +1,5 @@ import { AnyNode, type AnyNodeType } from '../schema/types' +import { healSceneNodes } from '../utils/heal-scene-graph' export type ValidationSeverity = 'error' | 'warning' @@ -127,9 +128,22 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult { } } - const nodes = nodesRaw as Record + // Heal known pre-existing corruption (null children, zero-length walls) up + // front, so a scene saved before the source fixes still imports instead of + // hard-failing schema validation. `parsed` below carries the repaired nodes. + const { nodes, droppedWallIds, strippedChildRefs } = healSceneNodes( + nodesRaw as Record, + ) const rootNodeIds = rootNodeIdsRaw as string[] + if (strippedChildRefs > 0 || droppedWallIds.length > 0) { + warnings.push({ + severity: 'warning', + code: 'auto_repaired', + message: `Repaired on import: removed ${strippedChildRefs} invalid child reference${strippedChildRefs === 1 ? '' : 's'} and ${droppedWallIds.length} zero-length wall${droppedWallIds.length === 1 ? '' : 's'}.`, + }) + } + if (rootNodeIds.length === 0) { errors.push({ severity: 'error', diff --git a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx index 731925fa..80a672db 100644 --- a/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx @@ -77,7 +77,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi } if (mode === 'material-paint') { - return { kind: 'asset', iconSrc: '/icons/paint.png' } + return { kind: 'asset', iconSrc: '/icons/paint.webp' } } return null diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index d6adf6ab..ada0b4a8 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -48,6 +48,13 @@ export function FloorplanRegistryActionMenu() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined const movingNode = useEditor((s) => s.movingNode) const setMovingNode = useEditor((s) => s.setMovingNode) + // Gate on floorplan hover so this 2D menu never coexists with the 3D + // FloatingActionMenu in split view — that menu hides while the floorplan + // is hovered, so this one must only show then. Mirrors the legacy + // FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a + // duct) shows two Duplicate buttons whenever the pointer is outside the + // 2D panel. + const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const [position, setPosition] = useState<{ left: number; top: number } | null>(null) @@ -56,7 +63,7 @@ export function FloorplanRegistryActionMenu() { const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) const def = selectedKind ? nodeRegistry.get(selectedKind) : null const isRegistryKind = !!def - const isVisible = isRegistryKind && !movingNode + const isVisible = isRegistryKind && !movingNode && isFloorplanHovered const isWall = selectedKind === 'wall' useEffect(() => { @@ -191,6 +198,11 @@ export function FloorplanRegistryActionMenu() { cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata) ? (cloned.metadata as Record) : {} + // Mark fresh + hand to the placement cursor so the copy follows the + // pointer and only lands on the next click — same gesture for every + // kind. Polyline runs (duct / pipe / lineset) ride the same path: + // `FloorplanRegistryMoveOverlay` translates their whole `path`, so they + // no longer need the old "offset + drop already-placed" special case. cloned.metadata = { ...prevMeta, isNew: true } const parsed = def.schema.parse(cloned) as AnyNode useScene.getState().createNode(parsed, node.parentId as AnyNodeId) diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index c87211c0..0be1bac8 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() { // to be consumed. That legacy flow is gone in the registry layer; // all entries use the action menu now. let hasMovedSinceStart = false + // Live cursor location — updated on EVERY pointermove (even over the 3D + // canvas) so R-key ownership can follow the pointer's CURRENT pane rather + // than the sticky `hasMovedSinceStart`. Without this, once the user touched + // the 2D pane the overlay claimed R forever and the 3D flip went dead. + let pointerOverFloorplan = false + const onPointerTrack = (event: PointerEvent) => { + pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY) + } const onMove = (event: PointerEvent) => { // Skip 3D-canvas / other-UI cursor moves so the overlay only @@ -283,6 +291,33 @@ export function FloorplanRegistryMoveOverlay() { } const onKey = (event: KeyboardEvent) => { + // R flips a directional kind's facing mid-placement (door / window: + // front ↔ back). The session records the flip and re-runs its last + // apply so the 2D symbol updates immediately; kinds without a facing + // leave `flipSide` unset and R falls through to the global handler. + // + // Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D + // move tool ALSO listens for R on `window`. We own R only while the + // cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the + // 2D mover has actually engaged (`hasMovedSinceStart`); then we + // `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs + // first) so the 3D tool can't also flip. When the cursor is over the 3D + // pane we yield — the 3D tool owns R there. (The old sticky + // `hasMovedSinceStart`-only gate made the overlay claim R forever after + // the first 2D move, killing the 3D flip.) + if (event.key === 'r' || event.key === 'R') { + if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return + if (event.repeat) return + const t = event.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) { + return + } + event.preventDefault() + event.stopImmediatePropagation() + session.flipSide() + sfxEmitter.emit('sfx:item-rotate') + return + } if (event.key !== 'Escape') return // Claim teardown ownership so the 3D move tool's cleanup skips // its own restore — without this, both sides would race to @@ -328,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() { setMovingNode(null) } + window.addEventListener('pointermove', onPointerTrack) window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onPointerUp) - window.addEventListener('keydown', onKey) + // Capture phase so this runs BEFORE the 3D move tool's bubble-phase R + // listener — when the cursor is over the 2D pane, `stopImmediatePropagation` + // then pre-empts it so only one handler flips. + window.addEventListener('keydown', onKey, true) return () => { + window.removeEventListener('pointermove', onPointerTrack) window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) - window.removeEventListener('keydown', onKey) + window.removeEventListener('keydown', onKey, true) // Unmount cleanup. `historyPaused === true` here means none of // our terminal paths (commit, Esc) ran in this overlay — they // each call `resumeSceneHistory` and flip the flag. @@ -389,11 +429,32 @@ export function FloorplanRegistryMoveOverlay() { const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null if (!entry) return - const originalPosition = (( - movingNode as unknown as { - position?: [number, number, number] - } - ).position ?? [0, 0, 0]) as [number, number, number] + // Polyline kinds (duct / pipe / lineset) carry a `path`, not a + // `position` — translating a `position` here would write a field their + // schema ignores and snap the run back. For those we move every path + // point by the cursor delta and commit the translated `path` instead. + // The reference origin is the path centre so the SVG `translate` delta + // matches the geometry's actual location (which isn't at [0,0,0]). + const originalPath = + 'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path) + ? (movingNode as { path: [number, number, number][] }).path.map( + (p) => [...p] as [number, number, number], + ) + : null + const originalPosition: [number, number, number] = originalPath + ? (() => { + let cx = 0 + let cz = 0 + for (const p of originalPath) { + cx += p[0] + cz += p[2] + } + const n = originalPath.length || 1 + return [cx / n, originalPath[0]?.[1] ?? 0, cz / n] + })() + : (((movingNode as unknown as { position?: [number, number, number] }).position ?? [ + 0, 0, 0, + ]) as [number, number, number]) const isFreshPlacement = isFreshPlacementMetadata( (movingNode as { metadata?: unknown }).metadata, ) @@ -410,13 +471,34 @@ export function FloorplanRegistryMoveOverlay() { const otherId = el.getAttribute('data-node-id') if (!otherId || otherId === movingNode.id) continue const b = (el as SVGGraphicsElement).getBBox() - if (b.width <= 0 || b.height <= 0) continue + // Skip only fully-degenerate (point) entries. A thin run (duct / pipe / + // lineset drawn as a line) has one zero dimension but is still a valid + // alignment target — its endpoints become line anchors. + if (b.width <= 0 && b.height <= 0) continue candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height)) } let lastSnapped: [number, number] | null = null let dragAnchor: [number, number] | null = null + // Footprint bounding box drawn around the dragged entry — the 2D + // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node + // reads the same in both views. Green wireframe rect over the entry's + // own bbox, translated in lockstep with it. The entry stays visible the + // whole drag (no hide-until-move) so it never appears to vanish. + const SVG_NS = 'http://www.w3.org/2000/svg' + const boxEl = document.createElementNS(SVG_NS, 'rect') + boxEl.setAttribute('x', String(movingLocalBBox.x)) + boxEl.setAttribute('y', String(movingLocalBBox.y)) + boxEl.setAttribute('width', String(movingLocalBBox.width)) + boxEl.setAttribute('height', String(movingLocalBBox.height)) + boxEl.setAttribute('fill', 'none') + boxEl.setAttribute('stroke', '#22c55e') + boxEl.setAttribute('stroke-width', '1.5') + boxEl.setAttribute('vector-effect', 'non-scaling-stroke') + boxEl.setAttribute('pointer-events', 'none') + scene.appendChild(boxEl) + const onMove = (event: PointerEvent) => { // Same target guard as Path 1 — pointer must be over the floor // plan scene; otherwise we'd react to 3D-canvas moves with garbage @@ -487,6 +569,7 @@ export function FloorplanRegistryMoveOverlay() { const dx = finalX - originalPosition[0] const dz = finalZ - originalPosition[2] entry.setAttribute('transform', `translate(${dx} ${dz})`) + boxEl.setAttribute('transform', `translate(${dx} ${dz})`) lastSnapped = [finalX, finalZ] } @@ -500,6 +583,33 @@ export function FloorplanRegistryMoveOverlay() { const [, oldY] = originalPosition setMovingNodeOrigin('2d') let selectedId = movingNode.id as AnyNodeId + if (originalPath) { + // Polyline kinds: shift every point by the committed delta and + // write `path`. Strip the fresh-placement flags on first drop. + const dx = sx - originalPosition[0] + const dz = sz - originalPosition[2] + const nextPath = originalPath.map( + ([x, y, z]) => [x + dx, y, z + dz] as [number, number, number], + ) + useScene.getState().updateNode( + movingNode.id as AnyNodeId, + (isFreshPlacement + ? { + path: nextPath, + metadata: stripPlacementMetadataFlags( + (movingNode as { metadata?: unknown }).metadata, + ), + visible: true, + } + : { path: nextPath }) as Partial, + ) + useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] }) + entry.removeAttribute('transform') + useAlignmentGuides.getState().clear() + setMovingNode(null) + swallowNextClick() + return + } if (isFreshPlacement) { selectedId = commitFreshPlacementSubtree( @@ -552,6 +662,10 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) entry.removeAttribute('transform') + // Always un-hide on teardown so a committed copy shows and a + // never-revealed entry doesn't leak a hidden style onto a reused node. + entry.style.visibility = '' + boxEl.remove() useAlignmentGuides.getState().clear() } }, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def]) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx index 775b27fb..197f9363 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' */ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { const node = usePlacementPreview((s) => s.node) + const parentNode = usePlacementPreview((s) => s.parentNode) if (!node) return null const builder = nodeRegistry.get(node.type)?.floorplan @@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr // `resolve` reads the scene lazily (a builder rarely calls it for a ghost, // and `parent: null` short-circuits the elevator's level walk) so the layer // never subscribes to / bulk-reads the nodes map during render. + // `parentNode` is the synthetic wall for an off-wall door/window ghost so + // its builder draws the real swing-arc / pane symbol (see use-placement-preview). const ctx = { resolve: (id: AnyNodeId) => useScene.getState().nodes[id], children: [], siblings: [], - parent: null, + parent: parentNode ?? null, viewState: undefined, } as unknown as GeometryContext diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 120f642c..817bc42b 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (live) { const floorPlaced = def?.capabilities?.floorPlaced const hasPosition = Array.isArray((node as { position?: unknown }).position) - if (floorPlaced && hasPosition) { + if (node.type === 'door' || node.type === 'window') { + // Door / window movers publish WALL-LOCAL live transforms + // ([along-wall x, sill y, 0], wall-local Y rotation) — see + // wiki/architecture/tools.md. The mover only writes + // `useScene.updateNode` on a wall CHANGE, so a same-wall slide + // updates the 3D mesh imperatively but never the scene node — + // without applying the live transform here the 2D symbol stays + // frozen while the cursor slides. Merge the wall-local position + + // rotation onto the node but KEEP `parentId` (the wall) so + // `buildDoorFloorplan` still resolves `ctx.parent` and draws the + // real swing-arc / pane symbol at the live spot. + const r = (node as { rotation?: unknown }).rotation + effectiveNode = { + ...node, + position: live.position, + rotation: Array.isArray(r) + ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0] + : r, + } as AnyNode + } else if (floorPlaced && hasPosition) { effectiveNode = applyPositionLiveTransform(node, live) } else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') { const dx = live.position[0] @@ -1656,6 +1675,58 @@ function InteractiveGeometry({ ) } + case 'equal-spacing-badge': { + // A distinct accent (Figma-style "=" rhythm) so equal spacing reads + // apart from the orange placement dimensions. Same screen-upright flip + // as the dimension-label case above. + const accent = '#ec4899' + let degrees = (g.angle * 180) / Math.PI + let screenDegrees = degrees + sceneRotationDeg + screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180 + if (screenDegrees > 90) degrees -= 180 + else if (screenDegrees <= -90) degrees += 180 + + const label = `= ${g.text}` + const padX = unitsPerPixel * 6 + const padY = unitsPerPixel * 3 + const fontSize = Math.max(unitsPerPixel * 10, 0.08) + const textWidth = label.length * unitsPerPixel * 6.2 + const plateW = textWidth + padX * 2 + const plateH = fontSize + padY * 2 + return ( + + + + {label} + + + ) + } case 'dimension': { if (!palette) return <> const stroke = g.stroke ?? palette.measurementStroke @@ -1959,6 +2030,7 @@ const OVERLAY_KINDS = new Set([ 'rotate-arrow', 'dimension', 'dimension-label', + 'equal-spacing-badge', ]) /** diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx new file mode 100644 index 00000000..c4d81830 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx @@ -0,0 +1,128 @@ +'use client' + +import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { memo, useMemo } from 'react' +import useEditor from '../../../store/use-editor' + +/** + * Dev-only 2D debug overlay for the opening (door / window) wall snap. + * + * The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is + * within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the + * nearest such wall. The set of points within that radius of a segment is a + * **capsule** (stadium): a band of half-width = the snap radius along the + * wall, with semicircular caps at each end. That capsule IS the wall's + * (normally invisible) hit target — so this layer draws it directly, one + * analytic `` per wall, instead of sampling a grid (which produced the + * stair-stepped boundary the previous version showed). No per-point + * classification, so it's cheap regardless of plan size. + * + * Where two walls sit closer than 2× the radius their capsules overlap; the + * snap resolves the overlap to the nearer wall (the translucent fills just + * blend there — a darker patch reads as "either wall is in reach, nearest + * wins"). Drawing the true bisector-clipped cells would need the expensive + * per-point pass this rewrite removes, and the hit-area view is what the + * user asked for. + * + * Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the + * floor-plan scene ``, so it shares the plan→SVG transform and the scene + * rotation with every other floor-plan layer. + */ + +/** Stable hue per wall id so a wall keeps its colour across re-renders. */ +function wallHue(id: string): number { + let hash = 0 + for (let i = 0; i < id.length; i++) { + hash = (hash * 31 + id.charCodeAt(i)) >>> 0 + } + // Spread hues around the wheel with an offset that avoids a muddy + // red-orange clump for short, similar ids. + return (hash * 47) % 360 +} + +export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() { + const show2dVoronoi = useEditor((s) => s.show2dVoronoi) + const selectedLevelId = useViewer((s) => s.selection.levelId) + // Recompute only when the wall geometry on this level changes — not on + // every scene write. Dragging a door updates the door node every frame; + // keying the build on this string means the capsule paths don't rebuild + // for openings, only for wall edits. + const nodes = useScene((s) => s.nodes) + const wallsKey = useMemo(() => { + if (!show2dVoronoi || !selectedLevelId) return '' + const segments = collectLevelWallSegments(nodes, selectedLevelId) + return segments + .map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`) + .join('|') + }, [show2dVoronoi, selectedLevelId, nodes]) + + const walls = useMemo(() => { + if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null + // Read nodes imperatively: `wallsKey` already encodes every wall change, + // so this memo is keyed on it rather than on the per-frame `nodes` ref. + const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId) + if (segments.length === 0) return null + + const R = WALL_SNAP_DISTANCE_M + const r = R.toFixed(3) + return segments.map((s) => { + // Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the + // segment endpoints ±R along the normal for the long sides, then a + // semicircular cap (radius R, sweep-flag 0 bulges outward past each end) + // joins them. Verified winding holds for every orientation because the + // whole construction is a rigid transform of the axis-aligned case. + const nx = -s.dirY + const ny = s.dirX + const ax = (s.start[0] + nx * R).toFixed(3) + const ay = (s.start[1] + ny * R).toFixed(3) + const bx = (s.end[0] + nx * R).toFixed(3) + const by = (s.end[1] + ny * R).toFixed(3) + const cx = (s.end[0] - nx * R).toFixed(3) + const cy = (s.end[1] - ny * R).toFixed(3) + const dx = (s.start[0] - nx * R).toFixed(3) + const dy = (s.start[1] - ny * R).toFixed(3) + const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z` + return { + wallId: s.wall.id, + d, + hue: wallHue(s.wall.id), + x1: s.start[0], + y1: s.start[1], + x2: s.end[0], + y2: s.end[1], + } + }) + }, [show2dVoronoi, selectedLevelId, wallsKey]) + + if (!walls) return null + + return ( + + {walls.map(({ wallId, d, hue }) => ( + + ))} + {walls.map(({ wallId, hue, x1, y1, x2, y2 }) => ( + + ))} + + ) +}) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index aa09c288..6ae52550 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -24,6 +24,7 @@ import { StairNode, StairSegmentNode, sceneRegistry, + summarizeSystemFor, useLiveNodeOverrides, useScene, WallNode, @@ -32,7 +33,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' -import { useCallback, useRef } from 'react' +import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' @@ -41,6 +42,21 @@ import useEditor from '../../store/use-editor' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' +/** + * A kind shows the system pill when it exposes typed ports — `def.ports` + * is exactly what makes a node participate in the supply/return graph the + * pill summarizes. Keeps the menu off a hand-maintained kind list. + */ +const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null + +/** + * A kind shows the rotation-axis pill when its R/T keyboard rotation + * turns around a user-cyclable axis (`keyboardActions.axisCycling`) — + * duct / pipe fittings with full 3D orientation. + */ +const hasAxisCycling = (type: string) => + nodeRegistry.get(type)?.keyboardActions?.axisCycling === true + const ALLOWED_TYPES = [ 'item', 'door', @@ -200,6 +216,8 @@ export function FloatingActionMenu() { // flips only at drag start / end, so subscribing here is cheap — the live // height value is written imperatively in the useFrame below. const activeHandleDrag = useEditor((s) => s.activeHandleDrag) + // R/T rotation axis for kinds with full 3D orientation (duct fittings). + const rotationAxis = useEditor((s) => s.rotationAxis) const groupRef = useRef(null) const menuScaleRef = useRef(null) @@ -490,10 +508,26 @@ export function FloatingActionMenu() { // item without clicking" bug. (Item has its own // draft-committing move tool, so it must skip the generic // registry auto-create branch below.) + } else if ( + duplicate.type === 'duct-segment' || + duplicate.type === 'duct-fitting' || + duplicate.type === 'pipe-segment' || + duplicate.type === 'lineset' || + duplicate.type === 'liquid-line' + ) { + // Duct runs & fittings, DWV pipe runs, and refrigerant linesets use + // pure drag-to-place: NO node is inserted into the scene until the + // commit click. `setMovingNode` below hands the clone (with + // `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` / + // `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`), + // which previews a translucent copy inside a footprint bounding box + // on the cursor and calls `createNode` on the drop click. + // Pre-creating here would drop a copy before any click — the + // "auto-places it" bug. } else if (nodeRegistry.has(duplicate.type)) { - // Registry-driven kinds: offset the position slightly so the - // duplicate doesn't overlap exactly, then create + hand to the - // move tool. Mirrors the roof-segment / stair-segment behavior. + // Registry-driven kinds: offset slightly so the duplicate doesn't + // overlap exactly, then create + hand to the move tool. Mirrors the + // roof-segment / stair-segment behavior. if ('position' in duplicate && Array.isArray((duplicate as any).position)) { const pos = (duplicate as { position: [number, number, number] }).position ;(duplicate as { position: [number, number, number] }).position = [ @@ -501,6 +535,12 @@ export function FloatingActionMenu() { pos[1], pos[2] + 1, ] + } else if ('path' in duplicate && Array.isArray((duplicate as any).path)) { + // Other polyline kinds (pipe / lineset) carry a `path`, not a + // `position`. Create the copy HIDDEN so nothing is auto-placed: + // their shared path mover reveals it as a cursor-following + // preview on the first mouse move and commits on the next click. + ;(duplicate as { visible?: boolean }).visible = false } useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } @@ -643,9 +683,86 @@ export function FloatingActionMenu() { /> ) : null} + {/* HVAC chrome above the menu — same slot as the wall height + pill. System pill (which tree, run length, equipment reach) + for every distribution kind; the rotation-axis pill stacks + under it for duct fittings. */} + {node && hasPorts(node.type) ? ( +
+ + {hasAxisCycling(node.type) ? ( +
+ + Axis {rotationAxis.toUpperCase()} + + + · + + R/T rotate + + · + + ⌥ axis +
+ ) : null} +
+ ) : null} ) } + +/** + * System summary pill for a selected distribution kind (HVAC duct / DWV + * pipe / refrigerant lineset): which supply/return tree it belongs to, its + * run length, and whether it actually reaches a piece of equipment. + * + * Mounted only while an HVAC node is selected, so the full-`nodes` + * subscription it needs (connectivity changes when ANY joint moves) doesn't + * re-render the always-mounted parent menu on every unrelated scene tick. + */ +function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) { + const allNodes = useScene((s) => s.nodes) + const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes]) + if (!summary) return null + return ( +
+ + {summary.systems.length > 0 + ? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ') + : 'System'} + + {summary.runCount > 0 ? ( + <> + + · + + + {formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '} + {summary.runCount === 1 ? 'run' : 'runs'} + + + ) : null} + {summary.terminalCount > 0 ? ( + <> + + · + + + {summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'} + + + ) : null} + {summary.connectedToEquipment ? null : ( + <> + + · + + ⚠ no equipment + + )} +
+ ) +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6a423670..53a9d5ef 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -10,6 +10,7 @@ import { calculateLevelMiters, DEFAULT_ANGLE_STEP, type DoorNode, + DoorNode as DoorNodeSchema, type ElevatorNode, emitter, type FenceNode, @@ -45,7 +46,9 @@ import { useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, type WindowNode, + WindowNode as WindowNodeSchema, ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' @@ -85,6 +88,7 @@ import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor' +import usePlacementPreview from '../../store/use-placement-preview' import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer' import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay' import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers' @@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee- import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer' import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer' import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer' +import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer' import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { snapToHalf } from '../tools/item/placement-math' @@ -5501,6 +5506,63 @@ export function FloorplanPanel({ return 0 }, [isWindowBuildActive, movingNode, shiftPressed]) + // Float the faithful door/window symbol at the cursor while it isn't over a + // wall (the off-wall placement ghost), by publishing a transient opening on a + // synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer` + // renders it through the real `def.floorplan` builder (swing arc / panes), so + // it reads as a real door/window, not a bare rectangle. Off any wall there's + // no orientation to inherit, so the synthetic wall runs along plan-X. + const showOpeningGhost = useCallback( + (planPoint: WallPlanPoint) => { + const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType) + // Synthetic wall centred at the cursor; the opening sits at its midpoint. + const half = + (isDoor + ? movingNode?.type === 'door' + ? movingNode.width + : 0.9 + : movingNode?.type === 'window' + ? movingNode.width + : 1.5) / + 2 + + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + // Clone the moving opening (carries width / type / hinge / swing) onto the + // synthetic wall, or parse a default for build mode. position[0] = the + // along-wall midpoint so the symbol centres on the cursor. + const base = + movingNode?.type === 'door' || movingNode?.type === 'window' + ? { ...movingNode } + : isDoor + ? DoorNodeSchema.parse({}) + : WindowNodeSchema.parse({}) + const ghost = { + ...base, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, floorplanOpeningLocalY, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + } as AnyNode + usePlacementPreview.getState().set(ghost, wall) + }, + [floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType], + ) + // Drop the floating opening ghost whenever opening placement ends (commit, + // tool change, mode switch, cancel) or the active level changes, so a stale + // ghost never lingers on the wrong level. + useEffect(() => { + if (!isOpeningPlacementActive) usePlacementPreview.getState().clear() + }, [isOpeningPlacementActive]) + // biome-ignore lint/correctness/useExhaustiveDependencies: `levelId` is an intentional re-run trigger; the effect drops the placement ghost when the active level changes. + useEffect(() => { + usePlacementPreview.getState().clear() + }, [levelId]) const isMarqueeSelectionToolActive = mode === 'select' && floorplanSelectionTool === 'marquee' && @@ -8568,7 +8630,16 @@ export function FloorplanPanel({ // `wall:move` events the door / window placement tools listen for. // Same reason `handleBackgroundPlacementClick` runs its opening // branch before its grid catch-all. - if (isOpeningPlacementActive) { + // + // Only the pure BUILD case (a door/window tool armed with no + // `movingNode`) drives placement through these synthesized `wall:*` + // events. When a door/window `movingNode` is set — the community + // preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D + // placement end-to-end via `def.floorplanMoveTarget` (faithful symbol, + // plan-space snap, single-undo commit, R-flip). Running both at once + // made them fight (R-flip overwritten on the next move, click-commit + // dropped), so the move case is excluded here. + if (isOpeningBuildActive && !isOpeningMoveActive) { const closest = findClosestWallPoint(planPoint, walls, { canUseWall: (wall) => !isCurvedWall(wall), }) @@ -8595,9 +8666,23 @@ export function FloorplanPanel({ } else { emitter.emit('wall:move', wallEvent as any) } - } else if (hoveredWallIdRef.current) { - emitFloorplanWallLeave(hoveredWallIdRef.current) - hoveredWallIdRef.current = null + // Snapped to a wall — the real on-wall draft is the preview; drop + // the loose free-follow ghost. + usePlacementPreview.getState().clear() + } else { + if (hoveredWallIdRef.current) { + emitFloorplanWallLeave(hoveredWallIdRef.current) + hoveredWallIdRef.current = null + } + // Off any wall — float the FAITHFUL door/window symbol (swing arc / + // panes) following the cursor, not a bare rectangle. The glyph + // builder needs a wall for `ctx.parent`, so we publish the opening on + // a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to + // `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it + // through the real `def.floorplan` builder. Shift bypasses grid snap. + const snappedPoint = + shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + showOpeningGhost(snappedPoint) } return } @@ -8622,7 +8707,13 @@ export function FloorplanPanel({ // window are also registered kinds, but need wall events — see // comment there). Wall build skips this so its own branch below // updates local `draftEnd` state alongside the registry tool. - if (!isWallBuildActive && isFloorplanGridInteractionActive) { + // + // A door/window MOVE (community preset) is owned by + // `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true + // for it (build mode + a registered `door`/`window` tool), so without + // this exclusion the catch-all would emit `grid:move` and re-drive the + // 3D MoveDoorTool's free-follow, fighting the overlay again. + if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) { const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8715,7 +8806,14 @@ export function FloorplanPanel({ isFenceBuildActive, isFloorplanGridInteractionActive, isMarqueeSelectionToolActive, - isOpeningPlacementActive, + isOpeningBuildActive, + isOpeningMoveActive, + // The off-wall opening ghost is published through this memoised + // callback, whose glyph (door swing-arc vs window panes) is bound to + // `isDoorBuildActive`. It must be a dependency or a door→window tool + // switch (which changes none of the other listed deps) would keep the + // stale closure and float a door symbol while the window tool is armed. + showOpeningGhost, isPolygonBuildActive, isRoofBuildActive, isSlabBuildActive, @@ -8969,8 +9067,15 @@ export function FloorplanPanel({ isCeilingBuildActive, isCeilingItemPlacementActive, isFenceBuildActive, - isFloorplanGridInteractionActive, - isOpeningPlacementActive, + // Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the + // grid catch-all true for it, but the overlay owns its commit (its own + // pointerup). Letting the catch-all emit `grid:click` here would consume + // the commit click and fight the overlay. + isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive, + // Only the pure-build opening case (tool armed, no movingNode) commits via + // the synthesized `wall:click`; the move case (community preset) is owned + // by FloorplanRegistryMoveOverlay, which commits on its own pointerup. + isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive, isPolygonBuildActive, isRoofBuildActive, isSlabBuildActive, @@ -10211,14 +10316,14 @@ export function FloorplanPanel({ (compassHost ? ( createPortal( , compassHost, ) ) : ( ))} @@ -10412,6 +10517,13 @@ export function FloorplanPanel({ showGrid={showGrid} /> + {/* Dev-only: draw each wall's opening-snap hit area (the + capsule of points within the snap radius of its centerline). + Gated on the developer-menu toggle. Painted right after the + grid so the translucent capsules sit under the wall / opening + glyphs. */} + + diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 1398dec3..d8b27dec 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -30,6 +30,7 @@ import useEditor from '../../store/use-editor' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' +import { SelectionAffordanceManager } from '../systems/selection-affordance-manager' import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' @@ -61,6 +62,7 @@ import { Grid } from './grid' import { GroupMoveHandle } from './group-move-handle' import { GroupRotateHandle } from './group-rotate-handle' import { NodeArrowHandles } from './node-arrow-handles' +import { RiserDiagramPanel } from './riser-diagram-panel' import { SelectionManager } from './selection-manager' import { SiteEdgeLabels } from './site-edge-labels' import { SlabHoleHighlights } from './slab-hole-highlights' @@ -573,7 +575,7 @@ function PaintCursorBadge({ alt="" aria-hidden="true" className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]" - src="/icons/paint.png" + src="/icons/paint.webp" /> @@ -605,11 +607,15 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ }) { // Studio mode is a clean render/snapshot surface — no selection or editing // affordances. It mirrors version-preview's chrome gating on the canvas. - const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode + // Capture (snapshot) mode is camera-only for the same reason: suppress + // selection, editing handles, and the tool manager (which mounts the site + // boundary flags) so the framed shot stays clean. + const isCaptureMode = useEditor((s) => s.isCaptureMode) + const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode return ( <> - {!(isFirstPersonMode || isStudioMode) && } + {!(isFirstPersonMode || isStudioMode || isCaptureMode) && } {!noEditing && } {!noEditing && } {!noEditing && } @@ -624,6 +630,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {isFirstPersonMode ? : } + {!noEditing && } {!(isLoading || isFirstPersonMode) && } @@ -1294,6 +1301,7 @@ export default function Editor({
+ {isFirstPersonMode && ( useEditor.getState().setFirstPersonMode(false)} /> )} diff --git a/packages/editor/src/components/editor/measurement-pill.tsx b/packages/editor/src/components/editor/measurement-pill.tsx index 892f5d01..793f9d2b 100644 --- a/packages/editor/src/components/editor/measurement-pill.tsx +++ b/packages/editor/src/components/editor/measurement-pill.tsx @@ -23,11 +23,64 @@ const PART_ORDER: { key: MeasurePart; prefix: string }[] = [ { key: 'thickness', prefix: 'T' }, ] +export interface DimensionPillPart { + key: string + prefix: string + value: number + /** Render an explicit +/- sign — for deltas rather than absolute sizes. */ + signed?: boolean +} + +/** + * Generic floating dimension pill: a row of `prefix value` readouts with the + * active one emphasised. Styled to match the top-center floating info bar + * (rounded-full, design-token colours) so it tracks the app theme. + * + * `primaryRef` points at the primary value's `` so a caller driving a + * per-frame drag can rewrite its text imperatively without a React re-render. + */ +export function DimensionPill({ + parts, + unit, + primary, + primaryRef, +}: { + parts: DimensionPillPart[] + unit: 'metric' | 'imperial' + primary?: string + primaryRef?: ForwardedRef +}) { + return ( +
+ {parts.map((part, index) => { + const text = part.signed + ? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}` + : formatMeasurement(part.value, unit) + return ( + + {index > 0 ? ( + + · + + ) : null} + + {`${part.prefix} ${text}`} + + + ) + })} +
+ ) +} + /** * Floating dimension pill shown during wall / fence drags: `H · L · T` with - * the actively-dragged dimension emphasised. Styled to match the top-center - * floating info bar (rounded-full, design-token colours) so it tracks the - * app theme. + * the actively-dragged dimension emphasised. * * The forwarded ref points at the `primary` value's `` so a caller * driving a per-frame drag (the height arrow) can rewrite its text @@ -52,24 +105,11 @@ export const MeasurementPill = forwardRef(function MeasurementPill( ) { const values: Record = { height, length, thickness } return ( -
- {PART_ORDER.map((part, index) => ( - - {index > 0 ? ( - - · - - ) : null} - - {`${part.prefix} ${formatMeasurement(values[part.key], unit)}`} - - - ))} -
+ ({ ...part, value: values[part.key] }))} + primary={primary} + primaryRef={primaryRef} + unit={unit} + /> ) }) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 07fd9fe1..515dc048 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useEditor from '../../store/use-editor' +import useOpeningGuides from '../../store/use-opening-guides' import { suppressBoxSelectForPointer } from '../tools/select/box-select-state' import { formatAngleRadians } from '../tools/shared/segment-angle' import { @@ -70,6 +71,10 @@ const _resizePositionW = new Vector3() const _resizeRay = new Ray() const _resizeRayW = new Vector3() +// Tilt that stands a flat XZ-plane move cross up into a node's facing plane +// (its local XY = a wall face) for `plane: 'node-normal'` handles. +const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0] + function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) { target.set(0, 0, 0) if (axis === 'x') target.x = 1 @@ -589,6 +594,9 @@ function LinearArrow({ // floating dimension pill (via `activeHandleDrag`) and its own in-world // chip is suppressed — matches the wall height handle. const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined + // Optional per-tick feedback hook (doors/windows publish proximity/sill guides + // for the edge being resized); cleared when the drag ends. + const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined const placementSceneApi = useMemo(() => createSceneApi(useScene), []) const basePosition = descriptor.placement.position(node, placementSceneApi) // `freezeOffset` (in node-local frame) cancels the mesh's `position` @@ -671,6 +679,7 @@ function LinearArrow({ if (measureLabel) { useEditor.getState().setActiveHandleDrag(null) } + if (onDrag) useOpeningGuides.getState().clear() }, move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { const currentPointer = @@ -686,7 +695,10 @@ function LinearArrow({ ? snapScalar(rawNext, gridSnapStep) : rawNext const next = Math.min(maxBound, Math.max(minBound, snappedNext)) - return descriptor.apply(initialNode as never, next, sceneApi) as Partial + const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial + // Let the kind publish live guides for the edge being resized. + onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) + return patch }, } }, @@ -1230,7 +1242,7 @@ function TranslateArrow({ // The cross is built flat in the XZ plane. On a wall, tilt it up about X so // it lies in the item-local XY plane (= the wall face). - const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0] + const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0] return ( ) } diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx new file mode 100644 index 00000000..8b39ef50 --- /dev/null +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { memo, useEffect, useLayoutEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../lib/constants' +import useOpeningGuides, { + type OpeningGuide3D, + type OpeningGuideVec3, +} from '../../store/use-opening-guides' +import { formatMeasurement } from './measurement-pill' + +const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement +const ALIGN_COLOR = 0xef_44_44 // red — a snapped alignment (matches the 2D guide accent) +const DIMENSION_PILL = '#6366f1' +const BADGE_PILL = '#ec4899' // pink — matches the 2D equal-spacing badge + +// Shared depth-test-off materials so the guides read on top of the wall and +// don't rebuild GPU buffers as guides churn during a drag. +const dimensionMaterial = new LineBasicNodeMaterial({ + color: DIMENSION_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) +const alignMaterial = new LineBasicNodeMaterial({ + color: ALIGN_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) + +const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [ + (a[0] + b[0]) / 2, + (a[1] + b[1]) / 2, + (a[2] + b[2]) / 2, +] + +/** + * Wall-plane proximity / alignment guides for the 3D editor — the spatial twin + * of the floor-plan placement dimensions + equal-spacing badges. Subscribes to + * `useOpeningGuides` (published by the door/window move, placement, and resize + * interactions each drag tick) and draws sill/head + edge-proximity dimensions, a sill-alignment line, and + * equal-spacing badges. Coordinates are already in the move tool's render frame + * (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's + * building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and + * renders them as-is. + */ +export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() { + const guides = useOpeningGuides((s) => s.guides) + const unit = useViewer((s) => s.unit) + if (guides.length === 0) return null + return ( + <> + {guides.map((guide) => ( + + ))} + + ) +}) + +function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) { + if (guide.kind === 'badge') { + return ( + +
+ {`= ${formatMeasurement(guide.value, unit)}`} +
+ + ) + } + + const material = guide.kind === 'align-line' ? alignMaterial : dimensionMaterial + return ( + <> + + {guide.kind === 'dimension' ? ( + +
+ {formatMeasurement(guide.value, unit)} +
+ + ) : null} + + ) +} + +function GuideSegment({ + from, + to, + material, +}: { + from: OpeningGuideVec3 + to: OpeningGuideVec3 + material: LineBasicNodeMaterial +}) { + // Build the THREE.Line once with a preallocated 2-point position buffer and + // mount it via (the intrinsic JSX element collides with + // React's SVG ). `material` is a module-level constant, so this memo + // runs exactly once per mounted slot; subsequent drag ticks mutate the + // existing buffer in place via the layout effect below rather than rebuilding + // the geometry, line, and GPU buffer every frame. + 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, material) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, [material]) + + const [fx, fy, fz] = from + const [tx, ty, tz] = to + useLayoutEffect(() => { + position.setXYZ(0, fx, fy, fz) + position.setXYZ(1, tx, ty, tz) + position.needsUpdate = true + }, [position, fx, fy, fz, tx, ty, tz]) + + useEffect(() => () => line.geometry.dispose(), [line]) + return +} diff --git a/packages/editor/src/components/editor/riser-diagram-panel.tsx b/packages/editor/src/components/editor/riser-diagram-panel.tsx new file mode 100644 index 00000000..e89122e1 --- /dev/null +++ b/packages/editor/src/components/editor/riser-diagram-panel.tsx @@ -0,0 +1,137 @@ +'use client' + +import { type AnyNodeId, buildRiserDiagram, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { X } from 'lucide-react' +import { useMemo } from 'react' +import useEditor from '../../store/use-editor' + +const WASTE_COLOR = '#0ea5e9' +const VENT_COLOR = '#a855f7' +const MARKER_COLOR = '#1e293b' +const PADDING = 32 +/** Meters → SVG units. The iso projection is in meters; scale up so a + * typical house drain (a few meters) fills the panel. */ +const SCALE = 90 + +/** + * DWV riser diagram — the plumbing isometric drawn from the scene's + * drain/waste/vent nodes. Read-only; toggled from the view controls. + * Vertical stacks read vertical, sloped drains lean at 30°, with size + + * vent-termination annotations, matching the permit-drawing convention. + * Clicking a line/marker selects its node in 3D. + */ +export function RiserDiagramPanel() { + const isOpen = useEditor((s) => s.isRiserOpen) + // Only the open flag lives here. The whole-scene subscription that drives + // the diagram lives in the child, mounted only while the panel is open — + // so a closed panel doesn't re-render on every scene mutation. + if (!isOpen) return null + return +} + +function RiserDiagramContent() { + const setRiserOpen = useEditor((s) => s.setRiserOpen) + const nodes = useScene((s) => s.nodes) + const selectedIds = useViewer((s) => s.selection.selectedIds) + + const diagram = useMemo(() => buildRiserDiagram(nodes), [nodes]) + + const select = (nodeId: AnyNodeId) => useViewer.getState().setSelection({ selectedIds: [nodeId] }) + + const width = diagram ? (diagram.bounds.maxX - diagram.bounds.minX) * SCALE + PADDING * 2 : 320 + const height = diagram ? (diagram.bounds.maxY - diagram.bounds.minY) * SCALE + PADDING * 2 : 200 + const tx = diagram ? -diagram.bounds.minX * SCALE + PADDING : 0 + const ty = diagram ? -diagram.bounds.minY * SCALE + PADDING : 0 + + return ( +
+
+
+ Riser Diagram + DWV plumbing isometric +
+ +
+ +
+ + Waste + + + {' '} + Vent + +
+ +
+ {diagram ? ( + + + {diagram.lines.map((line, i) => { + const isSel = selectedIds.includes(line.nodeId) + const color = line.system === 'waste' ? WASTE_COLOR : VENT_COLOR + return ( + + select(line.nodeId)} + stroke={color} + strokeDasharray={line.system === 'vent' ? '5 4' : undefined} + strokeLinecap="round" + strokeWidth={(line.vertical ? 3.5 : 2.5) + (isSel ? 2 : 0)} + x1={line.from[0] * SCALE} + x2={line.to[0] * SCALE} + y1={line.from[1] * SCALE} + y2={line.to[1] * SCALE} + /> + + {line.diameter}" + + + ) + })} + {diagram.markers.map((marker, i) => ( + select(marker.nodeId)} + transform={`translate(${marker.point[0] * SCALE}, ${marker.point[1] * SCALE})`} + > + {marker.kind === 'vent-termination' ? ( + + ) : ( + + )} + + {marker.label} + + + ))} + + + ) : ( +
+ No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram. +
+ )} +
+
+ ) +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index e6cc48c1..d1a42ee6 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1104,7 +1104,11 @@ export const SelectionManager = () => { } const onEnter = (event: NodeEvent) => { - if (boxSelectHandled) return + // A host-driven drag (handle resize/rotate) sets `inputDragging`. + // useNodeEvents now emits hover events during such a drag so surface + // move tools keep tracking the cursor — but paint preview must not fire + // mid-drag, so gate on `inputDragging` here too. + if (boxSelectHandled || useViewer.getState().inputDragging) return const interaction = getPaintInteraction(event) if (!interaction) return @@ -1676,6 +1680,11 @@ export const SelectionManager = () => { if (movingNode || curvingWall || curvingFence) return const onEnter = (event: NodeEvent) => { + // A host-driven drag (handle resize/rotate, box-select) sets + // `inputDragging`. useNodeEvents still emits hover events during it so + // surface move tools keep tracking — but the select-hover outline must + // stay put, so don't repaint under the cursor mid-drag. + if (useViewer.getState().inputDragging) return const node = event.node const currentPhase = useEditor.getState().phase @@ -1703,6 +1712,7 @@ export const SelectionManager = () => { } const onLeave = (event: NodeEvent) => { + if (useViewer.getState().inputDragging) return const nodeId = event?.node?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index f885ff4e..abb00e40 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import useAlignmentGuides from '../../store/use-alignment-guides' +import usePlacementPreview from '../../store/use-placement-preview' import useSegmentDraftChain from '../../store/use-segment-draft-chain' import { snapFenceDraftPoint } from '../tools/fence/fence-drafting' import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting' @@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({ stopPropagation: () => {}, } as any) } + // Drop the off-wall ghost on commit so it doesn't linger at the + // just-placed spot before the next pointer move re-evaluates. + usePlacementPreview.getState().clear() return true } diff --git a/packages/editor/src/components/systems/selection-affordance-manager.tsx b/packages/editor/src/components/systems/selection-affordance-manager.tsx new file mode 100644 index 00000000..61ad5224 --- /dev/null +++ b/packages/editor/src/components/systems/selection-affordance-manager.tsx @@ -0,0 +1,38 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { type ComponentType, Suspense, useMemo } from 'react' +import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch' + +/** + * Editor-mounted dispatcher for a kind's selection-time editing UI. + * + * Some kinds expose drag-to-edit affordances that should appear only + * while a single node of that kind is selected — duct / pipe / lineset + * path-point handles, fitting Alt-axis-cycling listeners. These read + * `useEditor` (grid snap step, rotation axis) and render the editor's + * `DimensionPill`, so they must NOT ride in `def.system` (which the + * viewer package mounts for the read-only route). The kind declares the + * component under `def.affordanceTools.selection` and this manager — + * mounted inside the editor only — loads it for the selected kind. + */ +export function SelectionAffordanceManager() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedKind = useScene((s) => { + if (selectedIds.length !== 1) return null + return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null + }) + + const Component = useMemo(() => { + if (!selectedKind) return null + return getRegistryAffordanceTool(selectedKind, 'selection') + }, [selectedKind]) + + if (!Component) return null + return ( + + + + ) +} diff --git a/packages/editor/src/components/systems/zone/zone-system.tsx b/packages/editor/src/components/systems/zone/zone-system.tsx index e42ac385..98e68b6c 100644 --- a/packages/editor/src/components/systems/zone/zone-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-system.tsx @@ -16,6 +16,9 @@ export const ZoneSystem = () => { const selectedLevelId = useViewer.getState().selection.levelId const selectedZoneId = useViewer.getState().selection.zoneId const hoveredId = useViewer.getState().hoveredId + // Snapshot capture is a clean, camera-only surface — never show zone + // geometry or the HTML zone tags in the framed shot. + const isCaptureMode = useEditor.getState().isCaptureMode const zoneGeometryVisible = structureLayer === 'zones' const zones = sceneRegistry.byType.zone || new Set() @@ -35,8 +38,14 @@ export const ZoneSystem = () => { // Keep group visible (so labels stay active), hide/show meshes only. // Show meshes when: in zone mode, selected, or delete-hovered. if (!obj.visible) obj.visible = true - const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered - const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0 + const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered) + const targetOpacity = isCaptureMode + ? 0 + : isSelected || isDeleteHovered + ? 1 + : zoneGeometryVisible + ? 1 + : 0 const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined if (walls) { @@ -73,8 +82,9 @@ export const ZoneSystem = () => { obj.userData.__raycastDisabled = true } - // Labels: always visible on the current level (regardless of mode) - const showLabel = !!selectedLevelId && isOnSelectedLevel + // Labels: visible on the current level (regardless of mode), but never + // during snapshot capture. + const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel const labelOpacity = showLabel ? '1' : '0' const labelEl = document.getElementById(`${zoneId}-label`) if (labelEl && labelEl.style.opacity !== labelOpacity) { diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5ba49e38..4dd39177 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' /** * MoveTool dispatcher. Routes to (in order): * - * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that - * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach, - * …). - * 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded - * via `getRegistryAffordanceTool`. Covers both generic movers - * (slab / ceiling / wall / fence / column / item / door / window) and - * the bespoke roof / roof-segment / stair / stair-segment / building - * movers ported into `@pascal-app/nodes`. + * 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded + * via `getRegistryAffordanceTool`. Covers generic movers + * (slab / ceiling / wall / fence / column / item / door / window), the + * bespoke roof / roof-segment / stair / stair-segment / building + * movers, and the polyline / fitting ghost-placement movers + * (duct-segment / duct-fitting). A kind that ships its own mover wins + * even if it also declares `capabilities.movable` (duct-fitting keeps + * `movable` for the inspector / hint readers but places via its ghost). + * 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only + * declare `capabilities.movable` (shelf, spawn, duct-terminal, + * hvac-equipment, …). * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft * mover hasn't been ported to a kind-owned affordance yet. */ @@ -29,9 +32,6 @@ export const MoveTool: React.FC<{ if (!movingNode) return null const def = nodeRegistry.get(movingNode.type) - if (def?.capabilities?.movable) { - return - } const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move') if (RegistryMove) { @@ -42,6 +42,10 @@ export const MoveTool: React.FC<{ ) } + if (def?.capabilities?.movable) { + return + } + if (movingNode.type === 'elevator') return return null diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index e0dac1b9..dbe35414 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -192,8 +192,10 @@ export interface PlacementCoordinatorConfig { initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] - /** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */ - preserveFloorDragOffset?: boolean + /** Move-mode sessions keep the grabbed item offset from the first surface hit + * (floor / wall / ceiling / item-surface / shelf) instead of snapping the + * item's origin under the cursor. */ + preserveDragOffset?: boolean } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { @@ -461,6 +463,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z) } + const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => { + const buildingId = useViewer.getState().selection.buildingId + const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null + return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z) + } + const applyTransition = (result: TransitionResult) => { // Alignment guides are floor-only; clear them when the cursor moves // onto a wall / ceiling / item surface (only those paths call this). @@ -528,11 +536,95 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Init draft ---- configRef.current.initDraft(gridPosition.current) - const preserveFloorDragOffset = - configRef.current.preserveFloorDragOffset === true && - placementState.current.surface === 'floor' && - !asset.attachTo - const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null + const preserveDragOffset = configRef.current.preserveDragOffset === true + const relativeFloorStart = + preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo + ? gridPosition.current.clone() + : null + + // Grab anchors for the non-floor surfaces. Each captures the cursor's + // surface-local position and the item's stored position on the first move + // for a given host, then offsets every later move by + // `start + (raw - anchor)` — mirroring the floor path and the door/window + // move tools so the item tracks the grabbed point instead of teleporting + // its origin under the cursor. Reset on host change (re-seeded from the + // item's then-current position) by the surface leave handlers. + let wallDragAnchor: { + wallId: string + rawX: number + rawY: number + startX: number + startY: number + } | null = null + let ceilingDragAnchor: { + ceilingId: string + rawX: number + rawZ: number + startX: number + startZ: number + } | null = null + let hostSurfaceDragAnchor: { + hostId: string + rawX: number + rawZ: number + startX: number + startZ: number + } | null = null + + // Item-surface / shelf moves snap from a WORLD cursor hit projected into the + // host's local frame. Re-project the offset-corrected local point back to + // world so the strategy (which re-derives both the stored position and the + // visual cursor from `event.position`) stays self-consistent. + const resolveHostSurfaceWorld = ( + hostId: string, + worldPos: readonly [number, number, number], + ): [number, number, number] | null => { + const draft = draftNode.current + const hostMesh = sceneRegistry.nodes.get(hostId) + if (!(preserveDragOffset && draft && hostMesh)) return null + const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2])) + if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) { + hostSurfaceDragAnchor = { + hostId, + rawX: rawLocal.x, + rawZ: rawLocal.z, + startX: draft.position[0], + startZ: draft.position[2], + } + } + const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX) + const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ) + const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ)) + return [world.x, world.y, world.z] + } + + // Floor grab-offset: the item tracks the grabbed point instead of snapping + // its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid + // (`event.position`) on its default path and only reads `event.localPosition` + // under Shift, so both frames must carry the offset; the world point is + // derived from the corrected local one so the two stay consistent. + const applyFloorGrabOffset = (event: GridEvent): GridEvent => { + if (relativeFloorStart === null) return event + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + const anchor = floorDragAnchor ?? [rawX, rawZ] + floorDragAnchor = anchor + const correctedLocal: [number, number, number] = [ + relativeFloorStart.x + (rawX - anchor[0]), + event.localPosition[1], + relativeFloorStart.z + (rawZ - anchor[1]), + ] + const correctedWorld = buildingLocalToWorld( + correctedLocal[0], + correctedLocal[1], + correctedLocal[2], + ) + return { + ...event, + position: [correctedWorld.x, event.position[1], correctedWorld.z], + localPosition: correctedLocal, + } + } // Sync cursor to the draft mesh's world position and rotation if (draftNode.current) { @@ -656,23 +748,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea detachItemSurfaceToFloor(event as unknown as ItemEvent) } - const floorEvent = - relativeFloorStart !== null - ? (() => { - const rawX = event.localPosition[0] - const rawZ = event.localPosition[2] - const anchor = floorDragAnchor ?? [rawX, rawZ] - floorDragAnchor = anchor - return { - ...event, - localPosition: [ - relativeFloorStart.x + (rawX - anchor[0]), - event.localPosition[1], - relativeFloorStart.z + (rawZ - anchor[1]), - ] as [number, number, number], - } - })() - : event + const floorEvent = applyFloorGrabOffset(event) lastRawPos.current.set( floorEvent.localPosition[0], @@ -865,7 +941,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - const result = wallStrategy.move(ctx, event, getActiveValidators()) + let wallMoveEvent = event + if (preserveDragOffset && draftNode.current) { + const rawX = event.localPosition[0] + const rawY = event.localPosition[1] + if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) { + wallDragAnchor = { + wallId: event.node.id, + rawX, + rawY, + startX: draftNode.current.position[0], + startY: draftNode.current.position[1], + } + } + const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) + const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY) + const wallMesh = sceneRegistry.nodes.get(event.node.id) + // Derive the world cursor from the corrected wall-local point so the + // visual cursor (world) and the stored position (wall-local) agree; if + // the wall mesh is somehow absent, keep the raw world hit unchanged. + const correctedWorld = wallMesh + ? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2])) + : null + wallMoveEvent = { + ...event, + localPosition: [correctedX, correctedY, event.localPosition[2]], + position: correctedWorld + ? [correctedWorld.x, correctedWorld.y, correctedWorld.z] + : event.position, + } + } + const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators()) if (!result) return event.stopPropagation() @@ -962,6 +1068,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallLeave = (event: WallEvent) => { + wallDragAnchor = null const result = wallStrategy.leave(getContext()) if (!result) return @@ -1133,6 +1240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Item Surface Handlers ---- const detachItemSurfaceToFloor = (event: ItemEvent) => { + hostSurfaceDragAnchor = null const buildingLocalPoint = worldToBuildingLocal( event.position[0], event.position[1], @@ -1233,8 +1341,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set(event.position[0], event.position[1], event.position[2]) - const result = itemSurfaceStrategy.move(ctx, event) + const surfaceWorld = + ctx.state.surfaceItemId !== null + ? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position) + : null + const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event + lastRawPos.current.set( + itemMoveEvent.position[0], + itemMoveEvent.position[1], + itemMoveEvent.position[2], + ) + const result = itemSurfaceStrategy.move(ctx, itemMoveEvent) if (!result) return event.stopPropagation() @@ -1428,8 +1545,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } - lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2]) - const result = ceilingStrategy.move(getContext(), event) + let ceilingMoveEvent = event + if (preserveDragOffset && draftNode.current) { + const rawX = event.localPosition[0] + const rawZ = event.localPosition[2] + if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) { + ceilingDragAnchor = { + ceilingId: event.node.id, + rawX, + rawZ, + startX: draftNode.current.position[0], + startZ: draftNode.current.position[2], + } + } + ceilingMoveEvent = { + ...event, + localPosition: [ + ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX), + event.localPosition[1], + ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ), + ], + } + } + lastRawPos.current.set( + ceilingMoveEvent.localPosition[0], + ceilingMoveEvent.localPosition[1], + ceilingMoveEvent.localPosition[2], + ) + const result = ceilingStrategy.move(getContext(), ceilingMoveEvent) if (!result) return event.stopPropagation() @@ -1493,6 +1636,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onCeilingLeave = (event: CeilingEvent) => { + ceilingDragAnchor = null const result = ceilingStrategy.leave(getContext()) if (!result) return @@ -1566,7 +1710,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } return } - const result = shelfSurfaceStrategy.move(ctx, event) + const shelfWorld = + ctx.state.shelfId !== null + ? resolveHostSurfaceWorld(ctx.state.shelfId, event.position) + : null + const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event + const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent) if (!result) return event.stopPropagation() @@ -1905,6 +2054,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current if (!(draft && viewerLevelId) || asset.attachTo) return if (draft.parentId === viewerLevelId) return + // A non-attach item resting on a host surface (table / counter / shelf) is + // intentionally parented to that host while it's moved — the surface move + // handlers keep it hosted and the commit writes the host parent back. Only + // free floor items get re-homed to the level here; yanking a hosted item + // onto the level would re-interpret its host-local position in level space + // and float the dragged mesh off the host toward the building origin. + const draftParent = draft.parentId + ? useScene.getState().nodes[draft.parentId as AnyNodeId] + : undefined + if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return draft.parentId = viewerLevelId useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId }) }, [viewerLevelId, draftNode, asset]) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 797d29b7..d68e02f4 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,6 +5,7 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, + analyzePortConnectivity, collectAlignmentAnchors, type EventSuffix, emitter, @@ -12,9 +13,12 @@ import { movingFootprintAnchors, type NodeEvent, nodeRegistry, + type PortConnectivity, resolveAlignment, + resolveConnectivityUpdates, sceneRegistry, spatialGridManager, + useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -44,6 +48,65 @@ const snapToGridStep = (value: number) => { /** 45° steps, matching the GLB item placement rotation. */ const ROTATION_STEP = Math.PI / 4 +/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ +const PORT_SNAP_RADIUS_M = 0.5 + +/** + * Magnetic port snap for a dragged node: if one of the node's own ports + * (read live from `def.ports`) lands within `radius` of a matching scene + * port at the candidate XZ, return the node XZ that mates them exactly. + * + * Pure core: ports come through `nodeRegistry` so this stays layer-clean. + * Ports are level-local meters — the same frame as the cursor's + * `localPosition`, so no extra transform is needed. The dragged node's + * ports move rigidly with its position, so a port at candidate `(x,z)` + * sits at `portStored + (candidate - nodeStored)`. We pick the closest + * (own-port, target-port) pair and shift the node so they coincide in XZ. + */ +function resolvePortSnap( + node: AnyNode, + candidate: [number, number], + config: { systems?: readonly string[]; radius?: number }, +): [number, number] | null { + const nodePos = (node as { position?: [number, number, number] }).position + if (!nodePos) return null + const ownPorts = nodeRegistry.get(node.type)?.ports?.(node) + if (!ownPorts || ownPorts.length === 0) return null + + const radius = config.radius ?? PORT_SNAP_RADIUS_M + const radiusSq = radius * radius + const { systems } = config + const dragDx = candidate[0] - nodePos[0] + const dragDz = candidate[1] - nodePos[2] + + const nodes = useScene.getState().nodes + let bestDistSq = radiusSq + let snap: [number, number] | null = null + + for (const node2 of Object.values(nodes)) { + if (!node2 || node2.id === node.id) continue + const targets = nodeRegistry.get(node2.type)?.ports?.(node2) + if (!targets) continue + for (const target of targets) { + if (systems && target.system !== undefined && !systems.includes(target.system)) continue + for (const own of ownPorts) { + // Own port at the candidate position = stored port + drag delta. + const ownX = own.position[0] + dragDx + const ownZ = own.position[2] + dragDz + const dx = target.position[0] - ownX + const dz = target.position[2] - ownZ + const distSq = dx * dx + dz * dz + if (distSq <= bestDistSq) { + bestDistSq = distSq + // Shift the node so this own port lands on the target (XZ only). + snap = [candidate[0] + dx, candidate[1] + dz] + } + } + } + } + return snap +} + /** Figma-style alignment-snap threshold (meters), matching the 2D * floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull * without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */ @@ -145,6 +208,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`, // and committed to the scene on drop. const rotationRef = useRef(originalRotationY) + // Snapshot of which ducts / fittings are mated to this node's ports at + // drag-start (duct fittings only). Drives the "connected ductwork follows" + // behaviour: connected nodes preview through `useLiveNodeOverrides` during + // the drag and commit alongside the moved node on drop. Null for kinds with + // no ports, so every other movable kind is unaffected. + const connectivityRef = useRef(null) + // Node ids this drag has pushed live overrides onto — cleared on + // commit / cancel / unmount so a follow-on drag starts clean. + const overriddenIdsRef = useRef([]) // Shelf placement shows the same green/red footprint box GLB items use // (instead of the vertical-arrow cursor) and refuses an invalid drop unless @@ -163,6 +235,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) + // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the + // cursor instead of preserving the grab offset — small connector-like + // nodes read an offset drag as "lagging behind the mouse". + const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true + // Kinds that declare `movable.portSnap` (duct terminals) magnetically + // mate one of their own ports onto a nearby scene port while dragging — + // a register collar drops onto a duct run end. Reads `def.ports` through + // the core registry, so it stays layer-clean (no @pascal-app/nodes import). + const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null // Mirrors of `valid` / Shift for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -212,6 +293,45 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } + // Connectivity follow (duct fittings): the moved node with its live drag + // transform, so `def.ports` recomputes for `resolveConnectivityUpdates`. + // Uses the logical (un-stacked) position + Y rotation that commit writes, + // not the floor-lifted visual position. + const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode => + ({ + ...(node as Record), + position, + rotation: toCommitRotation(rotationY), + }) as AnyNode + + // Resolve the patches that keep connected ductwork attached and preview + // them through `useLiveNodeOverrides` (transient — no history churn; + // GeometrySystem merges overrides via getEffectiveNode). Each connected + // node is re-dirtied so its geometry rebuilds against the new override. + const previewConnectivity = (position: [number, number, number], rotationY: number) => { + const connectivity = connectivityRef.current + if (!connectivity) return + const updates = resolveConnectivityUpdates( + connectivity, + buildPreviewNode(position, rotationY), + ) + if (updates.length === 0) return + useLiveNodeOverrides + .getState() + .setMany(updates.map((u) => [u.id, u.data as Record] as const)) + overriddenIdsRef.current = updates.map((u) => u.id) + for (const u of updates) { + if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id) + } + } + + const clearConnectivityOverrides = () => { + for (const id of overriddenIdsRef.current) { + useLiveNodeOverrides.getState().clear(id) + if (useScene.getState().nodes[id]) useScene.getState().markDirty(id) + } + } + setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) // Re-run the floor-collision check at the live cursor + rotation and push @@ -277,6 +397,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useViewer.getState().selection.levelId ?? node.parentId, ) + // Connectivity snapshot (existing port-bearing nodes only — fresh + // placements aren't connected to anything yet). Records which ducts / + // fittings are mated to this node's ports so they can follow the drag. + connectivityRef.current = null + overriddenIdsRef.current = [] + if (!isNew && nodeRegistry.get(node.type)?.ports) { + const snapshot = analyzePortConnectivity(node, useScene.getState().nodes) + if (snapshot.connections.length > 0) connectivityRef.current = snapshot + } + const onGridMove = (event: GridEvent) => { const rawX = event.localPosition[0] const rawZ = event.localPosition[2] @@ -286,7 +416,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { cursor: [rawX, rawZ], original: [originalPosition[0], originalPosition[2]], anchor: dragAnchorRef.current, - mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative', + mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep, }) dragAnchorRef.current = resolved.anchor @@ -313,6 +443,23 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } + // Magnetic port snap (duct terminals): mate a collar onto a nearby + // duct run end. Takes precedence over grid / alignment snap; Alt + // bypasses. Only kinds that opted in via `movable.portSnap`. + if (!bypass && portSnapConfig) { + // Build the preview node at the ORIGINAL position but with the LIVE + // rotation so `def.ports` reflects any mid-drag R/T rotation. Without + // this the snap solver mates the pre-rotation collar and commit then + // writes the rotated node offset from the port it visually snapped to. + const snapNode = buildPreviewNode(originalPosition, rotationRef.current) + const mated = resolvePortSnap(snapNode, [x, z], portSnapConfig) + if (mated) { + x = mated[0] + z = mated[1] + useAlignmentGuides.getState().clear() + } + } + const position: [number, number, number] = [x, originalPosition[1], z] const visualPosition = getVisualPosition(position) hasMovedRef.current = true @@ -337,6 +484,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { rotation: rotationRef.current, }) markMovedNodeDirty() + // Carry connected ductwork along (preview only — committed on drop). + previewConnectivity(position, rotationRef.current) const prev = previousSnapRef.current if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) { @@ -403,8 +552,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { committedId = finalId } } else { + // Fold the connected-ductwork follow-updates into the SAME + // batch as the moved node so the whole thing is one undo step. + const connectivityUpdates = connectivityRef.current + ? resolveConnectivityUpdates( + connectivityRef.current, + buildPreviewNode(position, rotationRef.current), + ).filter((u) => useScene.getState().nodes[u.id]) + : [] useScene.temporal.getState().resume() - useScene.getState().updateNode(node.id, data) + useScene + .getState() + .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates]) useScene.temporal.getState().pause() committed = true } @@ -430,6 +589,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // canonical position, then restamp the lifted presentation Y for the // current frame. useLiveTransforms.getState().clear(node.id) + // Connected ductwork is now committed to the store — drop its live + // overrides so the renderers read the canonical path/position. + clearConnectivityOverrides() const mesh = sceneRegistry.nodes.get(node.id) if (mesh) { mesh.position.set(...visualPosition) @@ -491,6 +653,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { rotation: rotationRef.current, }) markMovedNodeDirty() + // Rotating the fitting swings its collars — connected ducts follow. + previewConnectivity(position, rotationRef.current) // Rotation changes the footprint's collision span — re-check validity. recomputeValidity() } @@ -533,6 +697,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) + clearConnectivityOverrides() if (isNew) { useScene.getState().deleteNode(node.id as AnyNodeId) } else { @@ -570,6 +735,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d' if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) + clearConnectivityOverrides() sceneRegistry.nodes .get(node.id) ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) @@ -579,6 +745,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } }, [ boxDimensions, + cursorAttached, + portSnapConfig, exitMoveMode, isFreshPlacement, node, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index d942d25f..95df3d10 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' +import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' @@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => { tools above. Lives inside the building-local group so the building-local guide coords render at the right world position. */} + {/* Wall-plane proximity / sill / equal-spacing guides for openings, + published by the door/window move tools in the same world frame. */} + {/* "Magnetic" beacon at the active wall-draft snap point. */} diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index 4a86fb7d..6cf34650 100644 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -33,7 +33,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { alt="Orbit Left" className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100" height={28} - src="/icons/rotate.png" + src="/icons/rotate.webp" width={28} /> @@ -50,7 +50,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { alt="Orbit Right" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" height={28} - src="/icons/rotate.png" + src="/icons/rotate.webp" width={28} /> @@ -69,7 +69,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) { alt="Top View" className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100" height={28} - src="/icons/topview.png" + src="/icons/topview.webp" width={28} /> diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index 15fe4a17..ae1f5114 100644 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -24,7 +24,7 @@ type ControlConfig = { const controls: ControlConfig[] = [ { id: 'select', - imageSrc: '/icons/select.png', + imageSrc: '/icons/select.webp', label: 'Select', shortcut: 'V', color: 'hover:bg-blue-500/20 hover:text-blue-400', @@ -32,7 +32,7 @@ const controls: ControlConfig[] = [ }, { id: 'zone', - imageSrc: '/icons/zone.png', + imageSrc: '/icons/zone.webp', label: 'Zone', shortcut: 'Z', color: 'hover:bg-green-500/20 hover:text-green-400', diff --git a/packages/editor/src/components/ui/action-menu/furnish-tools.tsx b/packages/editor/src/components/ui/action-menu/furnish-tools.tsx index da2c8dfb..a3c629bd 100644 --- a/packages/editor/src/components/ui/action-menu/furnish-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/furnish-tools.tsx @@ -8,9 +8,9 @@ export type FurnishToolConfig = { } export const furnishTools: FurnishToolConfig[] = [ - { id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' }, - { id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' }, - { id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' }, - { id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' }, - { id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' }, + { id: 'item', iconSrc: '/icons/couch.webp', label: 'Furniture', catalogCategory: 'furniture' }, + { id: 'item', iconSrc: '/icons/appliance.webp', label: 'Appliance', catalogCategory: 'appliance' }, + { id: 'item', iconSrc: '/icons/kitchen.webp', label: 'Kitchen', catalogCategory: 'kitchen' }, + { id: 'item', iconSrc: '/icons/bathroom.webp', label: 'Bathroom', catalogCategory: 'bathroom' }, + { id: 'item', iconSrc: '/icons/tree.webp', label: 'Outdoor', catalogCategory: 'outdoor' }, ] diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 30f897ee..c399afba 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -12,17 +12,26 @@ export type ToolConfig = { // for cursor/floorplan indicators. Roof-mounted accessories are intentionally // absent — they're placed from the roof inspector's "Add element" section. export const tools: ToolConfig[] = [ - { id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' }, - { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, - { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, - { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, - { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, - { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, - { id: 'column', iconSrc: '/icons/column.png', label: 'Column' }, - { id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' }, - { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, - { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, - { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, - { id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' }, - { id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' }, + { id: 'wall', iconSrc: '/icons/wall.webp', label: 'Wall' }, + { id: 'door', iconSrc: '/icons/door.webp', label: 'Door' }, + { id: 'window', iconSrc: '/icons/window.webp', label: 'Window' }, + { id: 'stair', iconSrc: '/icons/stairs.webp', label: 'Stairs' }, + { id: 'roof', iconSrc: '/icons/roof.webp', label: 'Gable Roof' }, + { id: 'fence', iconSrc: '/icons/fence.webp', label: 'Fence' }, + { id: 'column', iconSrc: '/icons/column.webp', label: 'Column' }, + { id: 'elevator', iconSrc: '/icons/elevator.webp', label: 'Elevator' }, + { id: 'slab', iconSrc: '/icons/floor.webp', label: 'Slab' }, + { id: 'ceiling', iconSrc: '/icons/ceiling.webp', label: 'Ceiling' }, + { id: 'zone', iconSrc: '/icons/zone.webp', label: 'Zone' }, + { id: 'spawn', iconSrc: '/icons/spawn-point.webp', label: 'Spawn Point' }, + { id: 'shelf', iconSrc: '/icons/shelf.webp', label: 'Shelf' }, + { id: 'duct-segment', iconSrc: '/icons/duct.webp', label: 'Duct' }, + { id: 'duct-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Duct Fitting' }, + { id: 'duct-terminal', iconSrc: '/icons/registers.webp', label: 'Register' }, + { id: 'hvac-equipment', iconSrc: '/icons/HVAC.webp', label: 'HVAC Unit' }, + { id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.webp', label: 'DWV Pipe' }, + { id: 'pipe-trap', iconSrc: '/icons/dwv-pipes.webp', label: 'Trap' }, + { id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Pipe Fitting' }, + { id: 'lineset', iconSrc: '/icons/lineset.webp', label: 'Lineset' }, + { id: 'liquid-line', iconSrc: '/icons/lineset.webp', label: 'Liquid Line' }, ] diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index ca3c74b7..0d7b57dc 100644 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -10,7 +10,7 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react' +import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2, Waypoints } from 'lucide-react' import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { getLevelDisplayName } from '@pascal-app/core' @@ -226,7 +226,7 @@ function GuidesControl() { Guides {guides.length} @@ -265,7 +265,7 @@ function GuidesControl() {
- +

Guide images

@@ -305,7 +305,7 @@ function GuidesControl() {

{guide.name || `Guide image ${index + 1}`} @@ -466,7 +466,7 @@ function ScansControl() { variant="ghost" >

- Scans + Scans {scans.length} @@ -504,7 +504,7 @@ function ScansControl() {
- +

Scans

@@ -544,7 +544,7 @@ function ScansControl() {

{scan.name || `Scan ${index + 1}`} @@ -765,7 +765,7 @@ function ReferencesControl() { References {total} @@ -808,7 +808,7 @@ function ReferencesControl() { )} state.isRiserOpen) + const toggleRiserOpen = useEditor((state) => state.toggleRiserOpen) + + return ( + + + + ) +} + // ── Exports ───────────────────────────────────────────────────────────────── export { GridSnapControl } @@ -1008,6 +1031,7 @@ export function ViewToggles() { +

) } diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index be0fd1c8..8afded77 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -6,26 +6,26 @@ export type NodeDisplay = { } const TYPE_DEFAULTS: Record = { - item: { icon: '/icons/furniture.png', label: 'Item' }, - wall: { icon: '/icons/wall.png', label: 'Wall' }, - door: { icon: '/icons/door.png', label: 'Door' }, - window: { icon: '/icons/window.png', label: 'Window' }, - slab: { icon: '/icons/floor.png', label: 'Slab' }, - ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' }, - column: { icon: '/icons/column.png', label: 'Column' }, - elevator: { icon: '/icons/elevator.png', label: 'Elevator' }, - fence: { icon: '/icons/fence.png', label: 'Fence' }, - roof: { icon: '/icons/roof.png', label: 'Roof' }, - 'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' }, - stair: { icon: '/icons/stair.png', label: 'Stair' }, - 'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' }, - scan: { icon: '/icons/mesh.png', label: '3D Scan' }, - guide: { icon: '/icons/floorplan.png', label: 'Guide image' }, + item: { icon: '/icons/furniture.webp', label: 'Item' }, + wall: { icon: '/icons/wall.webp', label: 'Wall' }, + door: { icon: '/icons/door.webp', label: 'Door' }, + window: { icon: '/icons/window.webp', label: 'Window' }, + slab: { icon: '/icons/floor.webp', label: 'Slab' }, + ceiling: { icon: '/icons/ceiling.webp', label: 'Ceiling' }, + column: { icon: '/icons/column.webp', label: 'Column' }, + elevator: { icon: '/icons/elevator.webp', label: 'Elevator' }, + fence: { icon: '/icons/fence.webp', label: 'Fence' }, + roof: { icon: '/icons/roof.webp', label: 'Roof' }, + 'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' }, + stair: { icon: '/icons/stair.webp', label: 'Stair' }, + 'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' }, + scan: { icon: '/icons/mesh.webp', label: '3D Scan' }, + guide: { icon: '/icons/floorplan.webp', label: 'Guide image' }, } export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay { - if (!node) return { icon: '/icons/select.png', label: 'Selection' } - const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type } + if (!node) return { icon: '/icons/select.webp', label: 'Selection' } + const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type } // Item nodes carry an asset with its own thumbnail/name if (node.type === 'item') { return { diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index b4ae3344..91101d1a 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -52,7 +52,7 @@ export const InspectorFooterContext = createContext(null) interface PanelWrapperProps { title: string - /** Either a URL path (legacy panels pass `/icons/floor.png` etc., + /** Either a URL path (legacy panels pass `/icons/floor.webp` etc., * rendered via next/image) OR a React node (registry-driven * inspector renders `` from * `def.presentation.icon`). */ diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index e046fbf2..a5076dd7 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -62,9 +62,22 @@ export function ParametricInspector({ const handleUpdate = useCallback( (patch: Partial) => { if (!selectedId) return - useScene.getState().updateNode(selectedId, patch) + const scene = useScene.getState() + const node = scene.nodes[selectedId] + if (parametrics?.derive && node) { + const next = { ...node, ...patch } as AnyNode + patch = { ...patch, ...parametrics.derive(next, patch) } + } + // Bundle the edited node + any reconcile follow-ups into ONE + // updateNodes call so a single inspector edit is a single undo step. + const updates: { id: AnyNodeId; data: Partial }[] = [{ id: selectedId, data: patch }] + if (parametrics?.reconcile && node) { + const next = { ...node, ...patch } as AnyNode + updates.push(...parametrics.reconcile(node as AnyNode, next)) + } + scene.updateNodes(updates) }, - [selectedId], + [selectedId, parametrics], ) const clearSelection = useCallback(() => { diff --git a/packages/editor/src/components/ui/sidebar/icon-rail.tsx b/packages/editor/src/components/ui/sidebar/icon-rail.tsx index eb6d9eab..9718fb55 100644 --- a/packages/editor/src/components/ui/sidebar/icon-rail.tsx +++ b/packages/editor/src/components/ui/sidebar/icon-rail.tsx @@ -22,13 +22,13 @@ interface IconRailProps { const sitePanel: { id: PanelId; iconSrc: string; label: string } = { id: 'site', - iconSrc: '/icons/level.png', + iconSrc: '/icons/level.webp', label: 'Site', } const settingsPanel: { id: PanelId; iconSrc: string; label: string } = { id: 'settings', - iconSrc: '/icons/settings.png', + iconSrc: '/icons/settings.webp', label: 'Settings', } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx index 576e4874..c71fa6ed 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -85,7 +85,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({ expanded={expanded} hasChildren={children.length > 0} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx index 408b8d69..764a50c1 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx @@ -57,7 +57,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/roof.png" + src="/icons/roof.webp" width={14} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx index af37aaf1..230cc724 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx @@ -51,7 +51,7 @@ export const ColumnTreeNode = memo(function ColumnTreeNode({ expanded={false} hasChildren={false} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx index 60c9d589..dd88e7fb 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx @@ -57,7 +57,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({ hasChildren={false} icon={ - + } isHovered={isHovered} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx index aadf0025..3cc1f354 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx @@ -63,7 +63,7 @@ export const DormerTreeNode = memo(function DormerTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/roof.png" + src="/icons/roof.webp" width={14} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx index 085eb5ab..80dc790c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx @@ -49,7 +49,7 @@ export const ElevatorTreeNode = memo(function ElevatorTreeNode({ expanded={false} hasChildren={false} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx index c6635e83..86f5a7be 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -43,7 +43,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({ expanded={false} hasChildren={false} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx index 64f8343b..6e378b50 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx @@ -57,7 +57,7 @@ export const GutterTreeNode = memo(function GutterTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/roof.png" + src="/icons/roof.webp" width={14} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index 7d02ab61..bb5209eb 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -356,13 +356,13 @@ const ReferenceItem = memo(function ReferenceItem({ Scan ) : ( Guide )} Structure
@@ -1035,7 +1035,7 @@ const LayerToggle = memo(function LayerToggle() { 'mb-1 h-6 w-6 transition-all', activeTab !== 'furnish' && 'opacity-50 grayscale', )} - src="/icons/couch.png" + src="/icons/couch.webp" /> Furnish
@@ -1072,7 +1072,7 @@ const LayerToggle = memo(function LayerToggle() { 'mb-1 h-6 w-6 transition-all', activeTab !== 'zones' && 'opacity-50 grayscale', )} - src="/icons/kitchen.png" + src="/icons/kitchen.webp" /> Zones
@@ -1413,7 +1413,7 @@ const BuildingItem = memo(function BuildingItem({ 'h-5 w-5 object-contain transition-all', !isBuildingActive && 'opacity-60 grayscale', )} - src="/icons/building.png" + src="/icons/building.webp" /> {building.name || 'Building'}
@@ -1569,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel 'h-5 w-5 object-contain transition-all', phase !== 'site' && 'opacity-60 grayscale', )} - src="/icons/site-flag.png" + src="/icons/site-flag.webp" /> {siteNode.name || 'Site'}
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx index 594623f8..15b9704c 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx @@ -15,13 +15,13 @@ import { import { TreeNodeActions } from './tree-node-actions' const CATEGORY_ICONS: Record = { - door: '/icons/door.png', - window: '/icons/window.png', - furniture: '/icons/couch.png', - appliance: '/icons/appliance.png', - kitchen: '/icons/kitchen.png', - bathroom: '/icons/bathroom.png', - outdoor: '/icons/tree.png', + door: '/icons/door.webp', + window: '/icons/window.webp', + furniture: '/icons/couch.webp', + appliance: '/icons/appliance.webp', + kitchen: '/icons/kitchen.webp', + bathroom: '/icons/bathroom.webp', + outdoor: '/icons/tree.webp', } interface ItemTreeNodeProps { @@ -88,7 +88,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({ const handleStartEditing = useCallback(() => setIsEditing(true), []) const handleStopEditing = useCallback(() => setIsEditing(false), []) - const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png' + const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.webp' const snapTarget = resolveNodeSnapTarget(node) const defaultName = asset?.name || 'Item' const hasChildren = children.length > 0 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx index 550decc7..6e99e4da 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx @@ -40,7 +40,7 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined const icon = presentation?.icon - const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png' + const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp' const snapTarget = resolveNodeSnapTarget(node) const defaultName = node?.name || presentation?.label || 'Node' diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index 78759be4..eb23ca4d 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -98,7 +98,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({ expanded={expanded} hasChildren={segments.length > 0} icon={ - + } isDropTarget={isValidDropTarget && isDropTarget} isHovered={isHovered || isDropTarget} @@ -230,7 +230,7 @@ function RoofSegmentTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/roof.png" + src="/icons/roof.webp" width={14} /> } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx index a54fc2b8..ffab4a76 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx @@ -96,7 +96,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({ expanded={expanded} hasChildren={hasChildren} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx index d26854cd..627dc8dd 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -55,7 +55,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({ expanded={false} hasChildren={false} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx index 088f683e..3bde6886 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx @@ -57,7 +57,7 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/roof.png" + src="/icons/roof.webp" width={14} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx index d973df1b..118c9241 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx @@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({ alt="" className="object-contain" height={14} - src="/icons/spawn-point.png" + src="/icons/spawn-point.webp" width={14} /> } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx index 0a345c23..2f5114de 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx @@ -98,7 +98,7 @@ export const StairTreeNode = memo(function StairTreeNode({ expanded={expanded} hasChildren={segments.length > 0} icon={ - + } isDropTarget={isValidDropTarget && isDropTarget} isHovered={isHovered || isDropTarget} @@ -206,7 +206,7 @@ function StairSegmentTreeNode({ alt="" className="object-contain opacity-60" height={14} - src="/icons/stairs.png" + src="/icons/stairs.webp" width={14} /> } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx index e040ccba..2cdbe70e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx @@ -79,7 +79,7 @@ export const WallTreeNode = memo(function WallTreeNode({ expanded={expanded} hasChildren={children.length > 0} icon={ - + } isHovered={isHovered} isLast={isLast} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx index 1f16a139..8e230e2e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx @@ -57,7 +57,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({ hasChildren={false} icon={ - + } isHovered={isHovered} diff --git a/packages/editor/src/components/ui/snap-target-badge.tsx b/packages/editor/src/components/ui/snap-target-badge.tsx index a0ce4ac9..4c318cd9 100644 --- a/packages/editor/src/components/ui/snap-target-badge.tsx +++ b/packages/editor/src/components/ui/snap-target-badge.tsx @@ -6,9 +6,9 @@ export type SnapTarget = 'wall' | 'ceiling' | 'roof' export type SnapTargetBadgeSize = 'tile' | 'tree' const SNAP_TARGET_ICONS: Record = { - wall: '/icons/wall.png', - ceiling: '/icons/ceiling.png', - roof: '/icons/roof.png', + wall: '/icons/wall.webp', + ceiling: '/icons/ceiling.webp', + roof: '/icons/roof.webp', } const SNAP_TARGET_LABELS: Record = { diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 6cf2bf84..9e1cc312 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -67,19 +67,19 @@ const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', s const wallModeConfig = { up: { icon: (props: any) => ( - Full Height + Full Height ), label: 'Full Height', }, cutaway: { icon: (props: any) => ( - Cutaway + Cutaway ), label: 'Cutaway', }, down: { icon: (props: any) => ( - Low + Low ), label: 'Low', }, @@ -481,7 +481,7 @@ export const ViewerOverlay = ({ Scans )} @@ -502,7 +502,7 @@ export const ViewerOverlay = ({ Guides )} @@ -608,7 +608,7 @@ export const ViewerOverlay = ({ Orbit Left @@ -623,7 +623,7 @@ export const ViewerOverlay = ({ Orbit Right @@ -638,7 +638,7 @@ export const ViewerOverlay = ({ Top View
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 6e4d4695..b5f170ab 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -30,6 +30,16 @@ export const useKeyboard = ({ return } + // True while a door/window is being placed: either a fresh clone is moving + // (preset / duplicate path) or a door/window build tool is armed. The + // placement tool owns R/T then (flip the draft before commit), so the + // global selection-based R/T handler must stand down to avoid double-firing. + const isPlacingOpening = () => { + const ed = useEditor.getState() + if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true + return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window') + } + const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -171,11 +181,16 @@ export const useKeyboard = ({ } } } - } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { + } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // Doors use R to flip side (front ↔ back, rotation += π); their // open/close toggle lives on E. Windows still use R to toggle // their open/closed state. + // + // Skipped entirely while a door/window placement is active + // (`isPlacingOpening`): the placement tool owns R then (flip the draft + // before commit), and the user can have a node selected at the same + // time — without this guard both would fire (double flip + sfx). const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] @@ -225,7 +240,7 @@ export const useKeyboard = ({ sfxEmitter.emit('sfx:item-rotate') } } - } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) { + } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) { // Rotate selected node counter-clockwise const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index f9371842..1ef26d77 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -12,7 +12,34 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' -export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill' +// 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 +// of a bare `` without the full `` shell. +// - `NodeArrowHandles` renders the selected node's registry resize/rotate/move +// handles. +// - `MoveTool` runs the kind-owned mover once a translate handle arms +// `useEditor.movingNode`. +// - `ToolManager` mounts the active registry build tool (wall / door / window / +// …) for interactive placement when `useEditor` is in build mode with a +// tool, plus the snap/alignment guide layers. Mount it only while a tool is +// active to avoid its select-mode boundary editors. +// - `Grid` is the interactive drafting plane: it raycasts the pointer and +// emits the `grid:move` / `grid:click` events the build tools consume (the +// wall tool is driven entirely by them; door/window use them for free-follow +// alongside the viewer's `wall:*` mesh events). Without it the tools mount +// but their cursor never tracks the pointer. Mount it while a tool is active. +// All read `useViewer` selection + `useEditor` state, and cooperate with host +// camera controls via the `useViewer.inputDragging` / `useEditor.movingNode` +// flags. Tools place onto `useViewer.selection.levelId`, so the host must set a +// building + level selection first. +export { Grid } from './components/editor/grid' +export { + DimensionPill, + type DimensionPillPart, + formatMeasurement, + MeasurementPill, +} from './components/editor/measurement-pill' +export { NodeArrowHandles } from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, @@ -35,6 +62,7 @@ export { type FencePlanPoint, snapFenceDraftPoint, } from './components/tools/fence/fence-drafting' +export { MoveTool } from './components/tools/item/move-tool' // Placement-math helpers — shared by kind-owned placement tools in // `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, // item drop) so kinds don't reach into editor internals. @@ -96,6 +124,7 @@ export { DEFAULT_STAIR_TYPE, DEFAULT_STAIR_WIDTH, } from './components/tools/stair/stair-defaults' +export { ToolManager } from './components/tools/tool-manager' export { createWallOnCurrentLevel, getSegmentGridStep, @@ -299,6 +328,11 @@ export type { WorkspaceMode, } from './store/use-editor' export { default as useEditor } from './store/use-editor' +export { + default as useOpeningGuides, + type OpeningGuide3D, + type OpeningGuideVec3, +} from './store/use-opening-guides' export { type PaletteView, type PaletteViewProps, diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 377d49cd..61297d99 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => { }) describe('canDirectMoveNode', () => { - test('excludes floorplan-only move targets from 3D direct move', () => { + // Accepts kinds with a 3D-mountable move tool (`movable` or + // `affordanceTools.move`); floorplan-only movers (zone) are excluded. + test('rejects floorplan-only move targets (no 3D tool mounts)', () => { const kind = 'direct-move-floorplan-only-test' registerTestDefinition(kind, { floorplanMoveTarget: {} as never }) expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) }) - test('excludes bespoke move tools from 3D direct move', () => { + test('accepts kinds with a bespoke move tool', () => { const kind = 'direct-move-bespoke-tool-test' registerTestDefinition(kind, { affordanceTools: { @@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => { } as never, }) - expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true) }) test('accepts nodes with the generic movable capability', () => { @@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => { expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true) }) + + test('rejects kinds with no registered move path', () => { + const kind = 'direct-move-none-test' + registerTestDefinition(kind, {}) + + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + }) }) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 54564e57..db2649a7 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -4,6 +4,7 @@ import { createSceneApi, DEFAULT_ANGLE_STEP, type HandleDescriptor, + hasRegistry3DMoveTool, nodeRegistry, type SceneApi, useScene, @@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean { } export function canDirectMoveNode(node: AnyNode): boolean { - return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined + // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that + // mounts in 3D — distinct from `isRegistryMovable`, which also accepts + // floorplan-only movers (zone) for the 2D plan. + return hasRegistry3DMoveTool(node.type) } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index ca58f0fd..64c28b31 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -106,6 +106,15 @@ export type StructureTool = | 'dormer' | 'gutter' | 'downspout' + | 'duct-segment' + | 'duct-fitting' + | 'duct-terminal' + | 'hvac-equipment' + | 'lineset' + | 'liquid-line' + | 'pipe-segment' + | 'pipe-fitting' + | 'pipe-trap' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' @@ -292,6 +301,14 @@ type EditorState = { */ activeHandleDrag: { nodeId: AnyNodeId; label: string } | null setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void + /** + * World axis the R/T keyboard rotation turns around, for kinds with + * full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the + * kind's tool / keyboard actions read it, and the floating action + * menu surfaces it in a pill above the selected node. + */ + rotationAxis: 'x' | 'y' | 'z' + cycleRotationAxis: () => 'x' | 'y' | 'z' curvingWall: WallNode | null setCurvingWall: (wall: WallNode | null) => void curvingFence: FenceNode | null @@ -347,6 +364,10 @@ type EditorState = { toggleFloorplanOpen: () => void isFloorplanHovered: boolean setFloorplanHovered: (hovered: boolean) => void + // Toggleable DWV riser-diagram (plumbing isometric) overlay. + isRiserOpen: boolean + setRiserOpen: (open: boolean) => void + toggleRiserOpen: () => void navigationSyncPose: NavigationSyncPose | null publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void floorplanSelectionTool: FloorplanSelectionTool @@ -368,6 +389,11 @@ type EditorState = { // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean setAllowUndergroundCamera: (enabled: boolean) => void + // Development-only debug overlay: draw each wall's opening-snap hit area + // (the capsule of points within the snap radius of its centerline). Lets us + // see why a door/window snaps where it does. + show2dVoronoi: boolean + setShow2dVoronoi: (enabled: boolean) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null @@ -661,6 +687,11 @@ export function selectSiteFloorplanContext() { }) } +// Stashes the view mode the user was in before entering capture, so we can +// restore it on exit. Snapshot capture always frames in 3D — the 2D/split +// floorplan panes render nothing meaningful for a thumbnail. +let viewModeBeforeCapture: ViewMode | null = null + const useEditor = create()( persist( (set, get) => ({ @@ -802,6 +833,13 @@ const useEditor = create()( setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), activeHandleDrag: null, setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }), + rotationAxis: 'y', + cycleRotationAxis: () => { + const order = ['y', 'x', 'z'] as const + const next = order[(order.indexOf(get().rotationAxis as 'y' | 'x' | 'z') + 1) % 3]! + set({ rotationAxis: next }) + return next + }, curvingWall: null, setCurvingWall: (wall) => set({ curvingWall: wall }), curvingFence: null, @@ -911,7 +949,35 @@ const useEditor = create()( setCaptureMode: (next) => { const resolved: CaptureMode = typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next - set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' }) + const entering = resolved.mode !== 'idle' + set((state) => { + if (entering) { + // Force 3D for the shot. Remember the prior mode only on the first + // entry (viewMode is already '3d' on re-entry), so we restore the + // user's real choice — not the forced '3d' — when capture ends. + if (state.viewMode !== '3d') { + viewModeBeforeCapture = state.viewMode + return { + captureMode: resolved, + isCaptureMode: true, + viewMode: '3d', + isFloorplanOpen: false, + } + } + return { captureMode: resolved, isCaptureMode: true } + } + const restore = viewModeBeforeCapture + viewModeBeforeCapture = null + if (restore && restore !== '3d') { + return { + captureMode: resolved, + isCaptureMode: false, + viewMode: restore, + isFloorplanOpen: true, + } + } + return { captureMode: resolved, isCaptureMode: false } + }) }, viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode, setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }), @@ -926,6 +992,9 @@ const useEditor = create()( }), isFloorplanHovered: false, setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }), + isRiserOpen: false, + setRiserOpen: (open) => set({ isRiserOpen: open }), + toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })), navigationSyncPose: null, publishNavigationSyncPose: (pose) => set((state) => ({ @@ -952,6 +1021,8 @@ const useEditor = create()( set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }), allowUndergroundCamera: false, setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), + show2dVoronoi: false, + setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, setFirstPersonMode: (enabled) => { diff --git a/packages/editor/src/store/use-opening-guides.ts b/packages/editor/src/store/use-opening-guides.ts new file mode 100644 index 00000000..6b9cf2c8 --- /dev/null +++ b/packages/editor/src/store/use-opening-guides.ts @@ -0,0 +1,41 @@ +// Ephemeral store for the 3D opening proximity/alignment guides published by the +// door/window move + placement tools during a drag — the wall-plane counterpart +// of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are +// already transformed into the move tool's render frame — the same building-local +// frame as the drag cursor (ToolManager's group) — so the renderer stays dumb. +// Producers clear on commit, cancel, leave, and unmount. + +import { create } from 'zustand' + +export type OpeningGuideVec3 = [number, number, number] + +// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`, +// `spacing:0`, …) so the renderer can key by semantic role: as the guide set +// churns each drag tick, a slot that persists keeps its React element — and its +// drei `` portal — mounted instead of remounting when the list shape +// shifts under index keys. +export type OpeningGuide3D = + // A measured line + distance pill: sill (floor → bottom edge), head (top edge + // → wall top), or along-wall edge-to-edge proximity. + | { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } + // A dashed line connecting two openings that share a sill / centre / top. + | { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 } + // A Figma-style "=" badge marking one gap in an equal-spacing run. + | { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number } + +type OpeningGuidesState = { + guides: OpeningGuide3D[] + set(guides: OpeningGuide3D[]): void + clear(): void +} + +const useOpeningGuides = create((set) => ({ + guides: [], + set: (guides) => set({ guides }), + // No-op when already empty so the common no-guide hover frame (fallback + // cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify + // subscribers — the layer would re-render to the same nothing every tick. + clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)), +})) + +export default useOpeningGuides diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts index c012b7da..cce9d27c 100644 --- a/packages/editor/src/store/use-placement-preview.ts +++ b/packages/editor/src/store/use-placement-preview.ts @@ -18,14 +18,22 @@ type PlacementPreviewState = { /** Transient preview node, already positioned + rotated at the (snapped, * aligned) cursor. `null` when no placement is active. */ node: AnyNode | null - set(node: AnyNode | null): void + /** Optional synthetic parent for the preview's `def.floorplan` context. + * Door / window glyph builders need `ctx.parent` to be a wall to draw their + * real symbol (swing arc / panes); off any real wall we hand them a + * synthetic wall segment centred at the cursor so the floating ghost shows + * the faithful blueprint symbol instead of a bare rectangle. `null` for + * self-contained kinds (column / elevator). */ + parentNode: AnyNode | null + set(node: AnyNode | null, parentNode?: AnyNode | null): void clear(): void } const usePlacementPreview = create((set) => ({ node: null, - set: (node) => set({ node }), - clear: () => set({ node: null }), + parentNode: null, + set: (node, parentNode = null) => set({ node, parentNode }), + clear: () => set({ node: null, parentNode: null }), })) export default usePlacementPreview diff --git a/packages/nodes/src/box-vent/definition.ts b/packages/nodes/src/box-vent/definition.ts index ba67a52c..f085a7d2 100644 --- a/packages/nodes/src/box-vent/definition.ts +++ b/packages/nodes/src/box-vent/definition.ts @@ -220,7 +220,7 @@ export const boxVentDefinition: NodeDefinition = { presentation: { label: 'Box Vent', description: 'Small louvered exhaust vent that sits on a roof slope.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 120, }, diff --git a/packages/nodes/src/box-vent/panel.tsx b/packages/nodes/src/box-vent/panel.tsx index aeacb39e..1d89804e 100644 --- a/packages/nodes/src/box-vent/panel.tsx +++ b/packages/nodes/src/box-vent/panel.tsx @@ -164,7 +164,7 @@ export default function BoxVentPanel() { return ( { +const BoxVentPreview = ({ node, invalid }: { node: BoxVentNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildBoxVentGeometry(node), [node.width, node.depth, node.height, node.hoodOverhang, node.style], @@ -24,17 +26,17 @@ const BoxVentPreview = ({ node }: { node: BoxVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.85, metalness: 0.05, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 98be50e5..26f01e84 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -75,6 +75,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { // every parametric field, including the per-style ones. Listing them // explicitly keeps the dep array tight (vs. `[node]` which would // also fire on `name` / `visible` flips). + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildBoxVentGeometry(node), [ diff --git a/packages/nodes/src/box-vent/tool.tsx b/packages/nodes/src/box-vent/tool.tsx index ee424c8e..23c8af63 100644 --- a/packages/nodes/src/box-vent/tool.tsx +++ b/packages/nodes/src/box-vent/tool.tsx @@ -127,11 +127,11 @@ const BoxVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.6, 0.4, 0.6]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/building/definition.ts b/packages/nodes/src/building/definition.ts index 629684f0..0b0522b7 100644 --- a/packages/nodes/src/building/definition.ts +++ b/packages/nodes/src/building/definition.ts @@ -50,7 +50,7 @@ export const buildingDefinition: NodeDefinition = { presentation: { label: 'Building', description: 'A building container holding one or more levels.', - icon: { kind: 'url', src: '/icons/building.png' }, + icon: { kind: 'url', src: '/icons/building.webp' }, paletteSection: 'site', paletteOrder: 6, }, diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts index 118c0aba..8183f36a 100644 --- a/packages/nodes/src/ceiling/definition.ts +++ b/packages/nodes/src/ceiling/definition.ts @@ -163,7 +163,7 @@ export const ceilingDefinition: NodeDefinition = { presentation: { label: 'Ceiling', description: 'A polygon-bounded ceiling surface that hosts ceiling-mounted items.', - icon: { kind: 'url', src: '/icons/ceiling.png' }, + icon: { kind: 'url', src: '/icons/ceiling.webp' }, paletteSection: 'structure', paletteOrder: 40, }, diff --git a/packages/nodes/src/ceiling/move-tool.tsx b/packages/nodes/src/ceiling/move-tool.tsx index 8ac04216..d6aaab1d 100644 --- a/packages/nodes/src/ceiling/move-tool.tsx +++ b/packages/nodes/src/ceiling/move-tool.tsx @@ -9,6 +9,7 @@ import { polygonAnchors, resolveAlignment, sceneRegistry, + snapScalar, useLiveTransforms, useScene, } from '@pascal-app/core' @@ -36,10 +37,10 @@ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from * mesh's X/Z position on rebuild (`mesh.position.x = 0`, * `mesh.position.z = 0`) so the visual transitions smoothly. * - * 0.5m grid snap (matches legacy). + * Snaps to the editor's configured grid step (Shift bypasses). */ function snap(value: number) { - return Math.round(value * 2) / 2 + return snapScalar(value, useEditor.getState().gridSnapStep) } /** Figma-style alignment-snap threshold (meters), matching the other tools. */ diff --git a/packages/nodes/src/ceiling/panel.tsx b/packages/nodes/src/ceiling/panel.tsx index 67eceeff..ebbd3a66 100644 --- a/packages/nodes/src/ceiling/panel.tsx +++ b/packages/nodes/src/ceiling/panel.tsx @@ -147,7 +147,7 @@ export function CeilingPanel() { return ( { if (!(cursorRef.current && gridCursorRef.current)) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridX = Math.round(rawPoint[0] * 2) / 2 - const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap + ? rawPoint + : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const ceilingY = event.localPosition[1] + CEILING_HEIGHT diff --git a/packages/nodes/src/chimney/definition.ts b/packages/nodes/src/chimney/definition.ts index 01296c64..f68d397c 100644 --- a/packages/nodes/src/chimney/definition.ts +++ b/packages/nodes/src/chimney/definition.ts @@ -409,7 +409,7 @@ export const chimneyDefinition: NodeDefinition = { presentation: { label: 'Chimney', description: 'Vertical masonry stack on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/chimney/panel.tsx b/packages/nodes/src/chimney/panel.tsx index 2f8e7a3f..a01e7371 100644 --- a/packages/nodes/src/chimney/panel.tsx +++ b/packages/nodes/src/chimney/panel.tsx @@ -339,7 +339,7 @@ export default function ChimneyPanel() { // Match the current store node against the preset table so the return ( { +const ChimneyPreview = ({ + node, + segment, + invalid, +}: { + node: ChimneyNode + segment?: RoofSegmentNode + invalid?: boolean +}) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const effectiveSegment = segment ?? RoofSegmentSchema.parse({}) + + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geo = useMemo( - () => buildChimneyGeometry(node, segment), + () => buildChimneyGeometry(node, effectiveSegment), [ - segment.wallHeight, - segment.pitch, - segment.roofType, - segment.width, - segment.depth, + effectiveSegment.wallHeight, + effectiveSegment.pitch, + effectiveSegment.roofType, + effectiveSegment.width, + effectiveSegment.depth, node.width, node.depth, node.heightAboveRidge, @@ -68,16 +95,10 @@ const ChimneyPreview = ({ node, segment }: { node: ChimneyNode; segment: RoofSeg return ( - { - /* preview should not intercept the cursor */ - }} - /> - {geo.cap && {}} />} - {geo.flues && {}} />} - {geo.cricket && {}} />} + {}} /> + {geo.cap && {}} />} + {geo.flues && {}} />} + {geo.cricket && {}} />} ) } diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index 16aaa2f6..a52d46d8 100644 --- a/packages/nodes/src/chimney/renderer.tsx +++ b/packages/nodes/src/chimney/renderer.tsx @@ -77,24 +77,16 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { }, [node, segment]) // Segment brushes for the body trim. Building these is non-trivial - // (4 CSG-ready Brush instances per segment), so memoise by the shape - // fields that drive their geometry. A chimney slider drag changes - // `node.*` but not these, so the cached brushes survive the drag — - // previously each frame rebuilt all four. - const segmentBrushes = useMemo( - () => (segment ? getRoofSegmentBrushes(segment) : null), - [ - segment?.roofType, - segment?.width, - segment?.depth, - segment?.wallHeight, - segment?.pitch, - segment?.wallThickness, - segment?.deckThickness, - segment?.overhang, - segment?.shingleThickness, - ], - ) + // (4 CSG-ready Brush instances per segment). `segment` comes from a + // `useScene` selector, so it only re-identifies when the segment's own + // data changes — depend on it directly (as the `geo` memo above does) + // and the brushes rebuild exactly when the host roof reshapes, incl. + // the gambrel / mansard / dutch-hip width-ratio fields that + // `getRoofSegmentBrushes` reads. A chimney slider drag changes `node`, + // not `segment`, so the cache still survives the drag. Enumerating + // individual fields here previously omitted those ratios and left the + // trim CSG-ing against a stale roof outline. + const segmentBrushes = useMemo(() => (segment ? getRoofSegmentBrushes(segment) : null), [segment]) useEffect( () => () => { if (segmentBrushes) { diff --git a/packages/nodes/src/chimney/tool.tsx b/packages/nodes/src/chimney/tool.tsx index f69424ac..19a7ada0 100644 --- a/packages/nodes/src/chimney/tool.tsx +++ b/packages/nodes/src/chimney/tool.tsx @@ -144,12 +144,12 @@ const ChimneyTool = () => { <> } onInvalidTarget={() => { setSegmentXform(null) setHitLocal(null) setPreviewSegment(null) }} - size={[1, 2.5, 1]} /> {activeBuildingId && segmentXform && hitLocal && previewSegment && ( // Outer group mirrors the real renderer's `position={segment.position} diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 56ae5e5d..f4f901c9 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -386,7 +386,7 @@ export const columnDefinition: NodeDefinition = { presentation: { label: 'Column', description: 'A parametric column with configurable cross-section, base, and capital.', - icon: { kind: 'url', src: '/icons/column.png' }, + icon: { kind: 'url', src: '/icons/column.webp' }, paletteSection: 'structure', paletteOrder: 70, }, diff --git a/packages/nodes/src/column/panel.tsx b/packages/nodes/src/column/panel.tsx index c181b5ad..b8329b8d 100644 --- a/packages/nodes/src/column/panel.tsx +++ b/packages/nodes/src/column/panel.tsx @@ -313,7 +313,7 @@ export default function ColumnPanel() { return ( = { presentation: { label: 'Cupola', description: 'Louvered roof lantern with a dome or pyramid cap and optional finial.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/cupola/panel.tsx b/packages/nodes/src/cupola/panel.tsx index a3ecc88a..ec6f68af 100644 --- a/packages/nodes/src/cupola/panel.tsx +++ b/packages/nodes/src/cupola/panel.tsx @@ -137,7 +137,7 @@ export default function CupolaPanel() { return ( { +const CupolaPreview = ({ node, invalid }: { node: CupolaNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildCupolaGeometry(node), [node.width, node.depth, node.height, node.roofStyle, node.finial], @@ -20,17 +22,17 @@ const CupolaPreview = ({ node }: { node: CupolaNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.7, metalness: 0.1, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 2352ff5b..357c6956 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -53,6 +53,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { : undefined, ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildCupolaGeometry(node), [node.width, node.depth, node.height, node.roofStyle, node.finial], diff --git a/packages/nodes/src/cupola/tool.tsx b/packages/nodes/src/cupola/tool.tsx index 8fb8e928..2e137394 100644 --- a/packages/nodes/src/cupola/tool.tsx +++ b/packages/nodes/src/cupola/tool.tsx @@ -119,11 +119,11 @@ const CupolaTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.8, 1.2, 0.8]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts index 4846413c..58eef5b3 100644 --- a/packages/nodes/src/door/definition.ts +++ b/packages/nodes/src/door/definition.ts @@ -6,6 +6,7 @@ import type { RoofSegmentNode, WallNode, } from '@pascal-app/core' +import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime' import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host' import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut' import { scaleHandleHeight } from './door-math' @@ -21,6 +22,9 @@ const SIDE_HANDLE_OFFSET = 0.24 const HEIGHT_HANDLE_OFFSET = 0.24 const MIN_DOOR_HEIGHT = 0.5 const MIN_DOOR_WIDTH = 0.3 +// How far the move cross floats off the wall face (+Z, the door's facing +// normal) so it's grabbable instead of buried in the leaf/frame. +const MOVE_HANDLE_LIFT = 0.12 function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number { if (!door.wallId) return Number.POSITIVE_INFINITY @@ -55,6 +59,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor return readWallLength(n, scene) }, currentValue: (n) => n.width, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newWidth) => { // Anchored edge stays fixed in wall-local coords. Door rotation is // applied by the inner ride group (the renderer mounts a nested @@ -97,6 +102,7 @@ function doorHeightHandle(): HandleDescriptor { return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom) }, currentValue: (n) => n.height, + onDrag: (node) => publishOpeningResizeGuides(node, false), apply: (initial, newHeight) => { const bottom = initial.position[1] - initial.height / 2 // Scale the handle so it tracks the door instead of staying glued to a @@ -114,7 +120,26 @@ function doorHeightHandle(): HandleDescriptor { } } +// Press-drag move grip at the door centre, standing in the wall face. Routes +// through the same move tool as the floating Move button (3D +// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto +// another wall — but `engageMoveDrag` commits on release, with no second click. +function doorMoveHandle(): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const doorHandles: HandleDescriptor[] = [ + doorMoveHandle(), doorWidthHandle('left'), doorWidthHandle('right'), doorHeightHandle(), @@ -232,7 +257,7 @@ export const doorDefinition: NodeDefinition = { presentation: { label: 'Door', description: 'A door cut into a wall. Animated open/close state.', - icon: { kind: 'url', src: '/icons/door.png' }, + icon: { kind: 'url', src: '/icons/door.webp' }, paletteSection: 'structure', paletteOrder: 50, }, diff --git a/packages/nodes/src/door/door-math.ts b/packages/nodes/src/door/door-math.ts index 15a1b2dd..b5517c9d 100644 --- a/packages/nodes/src/door/door-math.ts +++ b/packages/nodes/src/door/door-math.ts @@ -1,12 +1,4 @@ -import { - type AnyNodeId, - type DoorNode, - getScaledDimensions, - type ItemNode, - useScene, - type WallNode, - type WindowNode, -} from '@pascal-app/core' +import type { WallNode } from '@pascal-app/core' /** * Keep the door handle at the same relative height when the door is resized: @@ -64,63 +56,7 @@ export function clampToWall( return { clampedX, clampedY } } -/** - * Checks if a proposed door position overlaps any existing wall children. - * Handles item, window, and door types. - */ -export function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = getScaledDimensions(item) - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 - childTop = win.position[1] + win.height / 2 - } else if (child.type === 'door') { - const door = child as DoorNode - childLeft = door.position[0] - door.width / 2 - childRight = door.position[0] + door.width / 2 - childBottom = door.position[1] - door.height / 2 - childTop = door.position[1] + door.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} +// Wall-child overlap is shared by door + window placement (one source of +// truth in `shared/wall-attach-target.ts`). Re-exported here so existing +// `./door-math` importers don't change. +export { hasWallChildOverlap } from '../shared/wall-attach-target' diff --git a/packages/nodes/src/door/floorplan-move.ts b/packages/nodes/src/door/floorplan-move.ts index d488a791..f61bcc76 100644 --- a/packages/nodes/src/door/floorplan-move.ts +++ b/packages/nodes/src/door/floorplan-move.ts @@ -3,18 +3,18 @@ import { type DoorNode, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, } from '@pascal-app/core' -import { snapToHalf } from '@pascal-app/editor' +import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' -import { - getRoofHostedOpeningLevelId, - getRoofHostedOpeningPlanPoint, -} from '../shared/roof-opening-host' +import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, + resolveOpeningPlacement, snapLocalXToNeighbors, } from '../shared/wall-attach-target' import { clampToWall, hasWallChildOverlap } from './door-math' @@ -38,16 +38,10 @@ import { clampToWall, hasWallChildOverlap } from './door-math' export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { // Snapshot of the door's "valid" state at move-start — used by // canCommit to decide whether the current snapped position is OK. - const startLevelId = (() => { - // Wall-hosted: the wall's parent is the level. Roof-hosted: walk - // segment → roof → level. Cached at start because the parent chain - // doesn't change during a move. - const nodes = useScene.getState().nodes - const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) - if (roofLevelId) return roofLevelId - const wall = nodes[node.parentId as AnyNodeId] - return wall ? (wall.parentId as AnyNodeId | null) : null - })() + // The level that owns the wall-snap candidates — resolves the wall-hosted, + // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). + // Cached at start because the parent chain doesn't change during a move. + const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) const originalWall = node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined @@ -57,6 +51,14 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) ? projectWallLocalPointToPlan(originalWall, node.position[0]) : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, + // Absolute: query the wall snap with the TRUE cursor, not the door's + // original wall position plus a grab delta. A wall-hosted opening always + // belongs to the wall nearest the cursor (the user's rule), and the 3D + // move snaps on the wall literally under the ray — relative mode would + // anchor the search to the old wall and resist hopping to a closer one + // across a thin gap, picking the "far" wall the user reported. It also + // makes the 2D Voronoi overlay (classified by cursor) predict the snap. + mode: 'absolute', }) // Track the last successful placement so `commit()` can write it @@ -72,13 +74,114 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) roofFace: undefined } | null = null + // R flips the door's facing (front ↔ back) mid-placement. `apply` re-derives + // the wall-facing side every move, so the flip is a persistent XOR applied on + // top of the wall hit, plus a π rotation offset (matching the committed R). + let flipped = false + // Remember the last apply args so the overlay's R keydown can re-run `apply` + // (which has no event of its own) to show the flip immediately. + let lastApply: { + planPoint: readonly [number, number] + modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } + } | null = null + // Whether the cursor is currently over a wall. Off-wall the door free-follows + // the cursor as a ghost (like the 3D move) and is NOT committable — a door + // needs a wall. Starts true so a click before any move keeps the door put. + let onWall = true + // Shift force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. Read in `canCommit` so a Shift- + // held commit over a collision lands instead of reverting. + let forcePlace = false + + // Move SFX — parity with the 3D `MoveDoorTool`: ONE soft `sfx:grid-snap` click + // per grid step, identical free-following or sliding on a wall, keyed on the + // RAW cursor (not the snapped along-wall value). No separate floor→wall cue — + // that distinct sound was the "double" the user heard. 2D `apply` runs once per + // pointermove, so the step-key dedup is sufficient (no per-frame guard needed). + const STEP_M = 0.1 + let lastStepKey: string | null = null + const tickGridStep = (...coords: number[]) => { + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key !== lastStepKey) { + lastStepKey = key + triggerSFX('sfx:grid-snap') + } + } + + // Off-wall: float the faithful door symbol at the cursor (via a synthetic + // wall fed to the placement-preview layer) and hide the real node, so the + // ghost follows the cursor in 2D instead of the door staying frozen on its + // old wall. Mirrors the fresh-placement free-follow. + const freeFollow = (planPoint: readonly [number, number]) => { + onWall = false + lastValid = null + if ((useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: false }) + } + const half = node.width / 2 + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + // Reflect the R-flip on the floating ghost so its swing-arc faces the side + // that will be committed (the synthetic wall is plan-X aligned, so a back + // facing is a π yaw; the symbol builder also reads `side`). + const ghostSide: DoorNode['side'] = flipped + ? node.side === 'front' + ? 'back' + : 'front' + : node.side + const ghost = { + ...node, + side: ghostSide, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, node.position[1], 0] as [number, number, number], + rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], + visible: true, + } as DoorNode + usePlacementPreview.getState().set(ghost, wall) + } + const session: FloorplanMoveTargetSession = { affectedIds: [node.id as AnyNodeId], + flipSide() { + flipped = !flipped + if (lastApply) this.apply(lastApply) + }, apply({ planPoint, modifiers }) { + lastApply = { planPoint, modifiers } + forcePlace = modifiers.shiftKey === true + // Drop any stale live transform left by the 3D `MoveDoorTool` (it + // publishes one on every wall hover). The 2D registry layer renders + // door/window from `useLiveTransforms` IN PREFERENCE to the scene node, + // but this 2D move writes the scene node — so a leftover 3D entry would + // freeze the symbol on its wall and the slide wouldn't show. Only the + // 2D path runs during an opening move (the panel gates the 3D tool's + // events off via `!isOpeningMoveActive`), so nothing re-adds it. Guarded + // on existence: `clear` always allocates a new Map + re-renders. + if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) { + useLiveTransforms.getState().clear(node.id as AnyNodeId) + } const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) - if (!hit) return // pointer off any wall — keep door at last valid position + if (!hit) { + // Off any wall — free-follow the cursor (not committable). Click per grid + // cell as the ghost slides over open floor. + tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) + freeFollow(resolvedPlanPoint) + return + } + // Back on a wall — drop the free-follow ghost + reveal the real node. + onWall = true + usePlacementPreview.getState().clear() + if ((nodes[node.id as AnyNodeId] as DoorNode | undefined)?.visible === false) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: true }) + } // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends); it competes with — and wins over — the 0.5m @@ -97,10 +200,19 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) const snappedLocalX = neighborX ?? (modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)) const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height) + // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`, + // not the snapped value) so the wall slide ticks at the same cadence as the + // off-wall ghost — the same SFX, no separate snap cue. + tickGridStep(hit.localX) + + // Apply the R-flip on top of the wall-derived side. + const side: DoorNode['side'] = flipped ? (hit.side === 'front' ? 'back' : 'front') : hit.side + const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) + lastValid = { position: [clampedX, clampedY, 0], - rotation: [0, hit.itemRotation, 0], - side: hit.side, + rotation: [0, itemRotation, 0], + side, parentId: hit.wall.id, wallId: hit.wall.id, // Re-anchoring to a wall ends any roof-segment hosting; the @@ -122,11 +234,17 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) ]) }, canCommit() { + // Off-wall the door is free-following in mid-air — not placeable. The + // overlay then reverts to the pre-move snapshot (door returns to its + // original wall), matching the 3D move where an open-floor click commits + // nothing. + if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined if (!live || live.type !== 'door') return false - // Block commit if the door overlaps any other wall child at its - // current position. The 3D port has the same guard. - const overlapping = hasWallChildOverlap( + // Block commit if the door overlaps another wall child — UNLESS Shift + // force-places (same `placeable` rule as the 3D move + the shared + // `resolveOpeningPlacement`). + const collides = hasWallChildOverlap( live.parentId as string, live.position[0], live.position[1], @@ -134,7 +252,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) live.height, live.id, ) - return !overlapping + return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { // Own the atomic write so the overlay takes the deterministic diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index 8f18bf88..69152bf4 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -3,6 +3,7 @@ import { collectAlignmentAnchors, DoorNode, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -13,7 +14,6 @@ import { type WallEvent, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, consumePlacementDragRelease, EDITOR_LAYER, @@ -25,16 +25,22 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' +import { resolveOpeningPlacement } from '../shared/wall-attach-target' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' +import DoorPreview from './preview' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -46,6 +52,40 @@ const edgeMaterial = new LineBasicNodeMaterial({ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { const cursorGroupRef = useRef(null!) + // The door preview ghost. Shown for the WHOLE move so the user always sees a + // translucent door tinted by placement state — red off-wall or colliding, + // green on a valid wall — exactly like the free-follow ghost. The real node + // stays hidden until commit (the wall still cuts its hole from the node data, + // so the opening reads correctly behind the ghost). `null` = not previewing + // (committed / torn down). See the matching `DoorPreview` tint. + const [ghostPose, setGhostPose] = useState<{ + position: [number, number, number] + rotationY: number + tint: 'valid' | 'invalid' + // The door's facing side at the cursor. R-flip changes it mid-placement and + // the door geometry's swing/hinge depends on it, so the ghost must rebuild + // with the LIVE side — otherwise the preview shows the pre-flip orientation + // while commit places the flipped one. + side: DoorNode['side'] + } | null>(null) + + // Ghost preview node: the moving door with a zeroed transform + the live + // facing side. `updateDoorMesh` bakes `position`/`rotation` into the mesh (the + // `` wrapper already places it, so we zero those to avoid a double + // offset) and reads `side` for the swing/hinge direction — so the ghost + // matches exactly what commit will place, including an R-flip. Falls back to + // the moving node's own side when no pose is active. + const ghostSide = ghostPose?.side ?? movingDoorNode.side + const ghostNode = useMemo( + () => ({ + ...movingDoorNode, + side: ghostSide, + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }), + [movingDoorNode, ghostSide], + ) + const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -71,6 +111,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: movingDoorNode.roofSegmentId, roofFace: movingDoorNode.roofFace, metadata: movingDoorNode.metadata, + // Free-follow hides the node (visible:false); every revert path must + // restore the original visibility or an existing door cancelled over open + // floor would stay invisible. + visible: movingDoorNode.visible, } if (!isNew) { @@ -82,12 +126,52 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => let currentHostId: string | null = movingDoorNode.parentId let dragAnchor: { wallId: string; rawX: number; startX: number } | null = null let committed = false + // Off-wall free-follow: when the cursor is over empty floor (no wall under + // the ray) the door is parented to the level and tracks the cursor like an + // item node. `freeFollowing` distinguishes that state so the placement + // commit no-ops in open space (a door needs a wall). `lastMeshEventTime` + // defers the floor handler whenever a wall/roof mesh event owns the same + // pointermove (shared DOM timeStamp) — that's the only thing that snaps. + let freeFollowing = false + let lastMeshEventTime = -1 + // Last open-floor cursor point (level-local X/Z), so an R-flip or Shift change + // while free-following can re-run the ghost at the same spot with the new + // facing/tint — no pointer move required. + let lastFloorPoint: [number, number] | null = null + // Live Shift state (force-place). Tracked here so the preview tint can be + // re-evaluated when Shift is pressed/released with the pointer stationary — + // the stored WallEvent carries a STALE shiftKey from the last move. + let shiftHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click each time the door crosses a + // grid step — identical whether free-following over open floor or sliding + // along a wall, so the two feel the same (the user's ask). Always keyed on + // the RAW cursor position (continuous ~0.1m cadence), never the snapped + // along-wall value, so the wall slide ticks at the same rate as the ghost. + // Two guards prevent a doubled/flammed cue: `lastStepKey` (emit only when + // the quantized cell changes) AND `lastTickFrame` (at most one tick per DOM + // pointermove — a wall mesh can emit `wall:move` more than once per move, and + // the grid + wall paths can both run). No separate snap cue: a distinct + // floor→wall sound was the "double" the user heard. + const STEP_M = 0.1 + let lastStepKey: string | null = null + let lastTickFrame = -1 + const tickGridStep = (frame: number, ...coords: number[]) => { + if (frame === lastTickFrame) return + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key === lastStepKey) return + lastStepKey = key + lastTickFrame = frame + triggerSFX('sfx:grid-snap') + } + // The door's chosen facing side. R flips it mid-placement (front ↔ back, + // same as the committed-selected R flip) so the user can reorient before + // committing. Initialised from the moving node's side. + let sideOverride: DoorNode['side'] = movingDoorNode.side let lastTarget: { wallNode: WallEvent['node'] wallId: string side: DoorNode['side'] itemRotation: number - cursorRotation: number clampedX: number clampedY: number valid: boolean @@ -125,6 +209,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + setGhostPose(null) } // Alignment candidates — anchors of every OTHER alignable object (the @@ -149,13 +235,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const getPlacementOrientation = (event: WallEvent) => { const faceSide = getSideFromNormal(event.normal) - const side = movingDoorNode.side ?? faceSide + const side = sideOverride ?? faceSide const rotationOffset = side !== faceSide ? Math.PI : 0 return { side, itemRotation: calculateItemRotation(event.normal) + rotationOffset, - cursorRotation: - calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset, } } @@ -167,7 +251,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } if (event.node.parentId !== getLevelId()) return - const { side, itemRotation, cursorRotation } = getPlacementOrientation(event) + const { side, itemRotation } = getPlacementOrientation(event) const rawLocalX = event.localPosition[0] if (!dragAnchor || dragAnchor.wallId !== event.node.id) { @@ -183,8 +267,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => rawLocalX: targetLocalX, width: movingDoorNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + // Alt still hard-disables alignment (no guides). Shift = free-place: + // land at the raw cursor but keep showing the alignment guides. + bypass: event.nativeEvent?.altKey === true, + freePlace: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, @@ -207,7 +293,6 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: event.node.id, side, itemRotation, - cursorRotation, clampedX, clampedY, valid, @@ -216,6 +301,16 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const applyPreview = (target: NonNullable) => { + // Same click as the off-wall ghost: one grid-snap tick per grid step, + // keyed on the RAW cursor along-wall position (not the snapped clampedX, + // whose ~0.5m jumps would tick at a different cadence). Per-frame guard + // collapses any duplicate wall events on the same pointermove. + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + // Keep the REAL node hidden and show a tinted ghost in the wall opening — + // green when placeable, red when it collides — the same translucent ghost + // the free-follow uses, so validity reads at a glance. The node position is + // still written (so the wall cuts the hole at the right spot) but + // `visible:false` keeps the pale solid mesh from competing with the ghost. if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingDoorNode.id, { position: [target.clampedX, target.clampedY, 0], @@ -225,6 +320,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) markHostDirty(currentHostId) currentHostId = target.wallId @@ -242,25 +338,60 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => }) markHostDirtyThrottled(target.wallId) - updateCursor( - wallLocalToWorld( + // Position the tinted ghost at the wall opening (world frame), facing the + // wall normal + the live side (so an R-flip shows correctly). The + // wireframe cursor is no longer used on a wall. Tint comes from the SHARED + // placement decision — green when placeable (incl. Shift force-place over a + // collision), red otherwise — the SAME `placeable` the commit gate uses. + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + const placement = resolveOpeningPlacement({ + collides: !target.valid, + forcePlace: shiftHeld, + }) + // The committed door is a CHILD of the wall mesh (group yaw = -wallAngle) + // with wall-local `itemRotation` (0 front / π back). The ghost is a + // scene-root world-space group, so its world yaw must be + // `-wallAngle + itemRotation` to face the same way as commit. + // `cursorRotation` (the old symmetric-wireframe yaw) is π off here. + const wallAngle = Math.atan2( + target.wallNode.end[1] - target.wallNode.start[1], + target.wallNode.end[0] - target.wallNode.start[0], + ) + setGhostPose({ + position: wallLocalToWorld( target.wallNode, target.clampedX, target.clampedY, getLevelYOffset(), getSlabElevation(target.event), ), - target.cursorRotation, - target.valid, - ) + rotationY: target.itemRotation - wallAngle, + tint: placement.tint, + side: target.side, + }) + + publishOpeningGuidesForWallEvent({ + wall: target.wallNode, + movingId: movingDoorNode.id, + centerS: target.clampedX, + centerY: target.clampedY, + width: movingDoorNode.width, + height: movingDoorNode.height, + // Doors sit on the floor — no sill/head or vertical alignment guides. + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), + }) } const onWallEnter = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveMoveTarget(event) if (!target) { onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) @@ -268,6 +399,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onWallMove = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -286,20 +418,17 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) event.stopPropagation() } - const onWallClick = (event: WallEvent) => { + // Promote the moving door into its committed wall placement. Shared by the + // direct wall-mesh click and the floor proximity click. + const commitToWall = (target: NonNullable) => { if (committed) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - if (event.node.parentId !== getLevelId()) return - - const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return committed = true let placedId: string @@ -320,6 +449,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => parentId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + // The moving node is hidden during free-follow; the committed door + // must be visible regardless of the pre-commit free-follow state. + visible: true, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -333,6 +465,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -344,6 +477,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: target.wallId, roofSegmentId: undefined, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== target.wallId) { @@ -360,30 +494,116 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() + } + + const onWallClick = (event: WallEvent) => { + if (committed) return + if (!isValidWallSideFace(event.normal)) return + if (isCurvedWall(event.node)) return + if (event.node.parentId !== getLevelId()) return + + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + // Shift force-places: commit even when the door overlaps another opening. + // The preview keeps its red invalid tint as a warning; Shift just lifts the + // commit block. Read shift from THIS event so it's never stale at commit. + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return + commitToWall(target) event.stopPropagation() } const onWallLeave = () => { + // The cursor left the wall mesh. Don't snap back to the origin/original + // here — the floor proximity handler (onGridMove) takes over on the same + // pointermove: it either snaps to a nearby wall or free-follows the + // cursor. The wireframe outline + live transform are cleared so the + // free-follow path can re-establish them. Reverting the node is left to + // onGridMove's free-follow / cancel / commit, so the door never blinks + // back to the building origin between a wall and open floor. hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) + } + + // Reveal the real door node + drop the ghost. Used by the roof-face path, + // which previews with the real mesh (the ghost-tint flow is wall-specific). + const revealRealNode = () => { + setGhostPose(null) + const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined + if (live && live.visible === false) { + useScene.getState().updateNode(movingDoorNode.id, { visible: true }) } - currentHostId = original.parentId - useScene.getState().updateNode(movingDoorNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, + } + + // Free-follow: over open floor there's no wall to host the door, so instead + // of dragging the real (pale, near-invisible-on-grid) node around we hide it + // and float a red translucent ghost at the cursor — same treatment the raw + // `DoorTool` build path uses. The node still re-parents to the level so a + // later wall-snap / commit has a clean base, but stays `visible:false` until + // a wall is hovered. + const freeFollowAt = (localX: number, localZ: number, frame: number) => { + freeFollowing = true + lastTarget = null + lastRoofEvent = null + // Click per grid cell as the ghost slides over open floor (X+Z) — the + // same `tickGridStep` the on-wall slide uses, so both feel identical. + tickGridStep(frame, localX, localZ) + hideCursor() + useLiveTransforms.getState().clear(movingDoorNode.id) + const levelId = getLevelId() + const y = movingDoorNode.height / 2 + // Keep the R-flip visible while free-following: face the chosen side + // (back = rotated π) instead of forcing 0, so an R press isn't undone on + // the next mousemove. + const yaw = sideOverride === 'back' ? Math.PI : 0 + if (currentHostId !== levelId) { + if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) + useScene.getState().updateNode(movingDoorNode.id, { + position: [localX, y, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + currentHostId = levelId + } else { + useScene.getState().updateNode(movingDoorNode.id, { + position: [localX, y, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + visible: false, + }) + } + // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted so + // it stands on the floor, matching the door's chosen facing (sideOverride + // carries the R-flip so the ghost swing direction matches commit). + setGhostPose({ + position: [localX, getLevelYOffset() + y, localZ], + rotationY: yaw, + tint: 'invalid', + side: sideOverride, }) - if (original.parentId) markHostDirty(original.parentId) + } + + const onGridMove = (event: GridEvent) => { + if (committed) return + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler owns this exact pointermove (shared DOM + // timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise + // the cursor is over open floor — free-follow it. + if (event.nativeEvent?.timeStamp === lastMeshEventTime) return + + // No proximity magnet: in 3D the wall side faces are big raycast targets, + // so snapping engages only when the cursor ray actually hovers a wall + // (`onWallMove`). Over open floor the door just follows the cursor. + const [x, , z] = event.localPosition + lastFloorPoint = [x, z] + freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -406,16 +626,23 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onRoofHover = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofMoveTarget(event) if (!target) { onRoofLeave() return } // Wall-frame drag anchor / live transform don't apply on a roof face. + freeFollowing = false dragAnchor = null lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingDoorNode.id) + // Opening guides are wall-specific; clear them when over a roof face. + clearOpeningGuides3D() + // On a roof face the real mesh is the preview — drop the free-follow ghost + // and reveal the node. + revealRealNode() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingDoorNode.id, { position: target.position, @@ -425,6 +652,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => wallId: undefined, roofSegmentId: target.segment.id, roofFace: target.face.id, + visible: true, }) markHostDirty(currentHostId) currentHostId = target.segment.id @@ -442,7 +670,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target too (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return committed = true const segmentId = target.segment.id @@ -464,6 +694,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: segmentId, roofFace: target.face.id, parentId: segmentId, + visible: true, }) useScene.getState().createNode(node, segmentId as AnyNodeId) placedId = node.id @@ -477,6 +708,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -489,6 +721,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: segmentId, roofFace: target.face.id, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== segmentId) { @@ -509,26 +742,13 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => } const onRoofLeave = () => { + // Mirror onWallLeave: don't revert to origin here — onGridMove takes + // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingDoorNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) - } - currentHostId = original.parentId - useScene.getState().updateNode(movingDoorNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { @@ -546,6 +766,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } @@ -556,13 +777,74 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return - if (lastTarget) { - onWallClick(lastTarget.event) + // Free-following over open floor can't commit (no wall). A wall hover + // target commits via commitToWall; a roof face via onRoofClick. Shift + // force-places over a colliding wall target (the tint stays red as a + // warning); read shift from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { + commitToWall(lastTarget) return } if (lastRoofEvent) onRoofClick(lastRoofEvent) } + // R flips the door's facing side mid-placement (front ↔ back), like the + // committed-selected R flip — usable before commit, whether snapped to a + // wall or free-following. Re-applies the preview so the flip shows live. + // No-op on a roof-segment face (those host front-only; nothing to flip). + const onKeyDown = (e: KeyboardEvent) => { + if (committed) return + if (e.key !== 'r' && e.key !== 'R') return + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return + } + // Ignore OS key-repeat so a held R doesn't flip many times per press. + if (e.repeat) return + e.preventDefault() + // ALWAYS toggle the persistent flip intent — never a no-op. (The old gate + // dropped R before the first pointermove, so initial-placement R needed a + // second press.) Then re-render whatever preview is current so the flip + // shows live and matches what commit will write. + sideOverride = sideOverride === 'front' ? 'back' : 'front' + triggerSFX('sfx:item-rotate') + if (lastTarget) { + // On a wall: re-resolve with the flipped side and re-preview. + const next = resolveMoveTarget(lastTarget.event) + if (next) { + lastTarget = next + applyPreview(next) + } + } else if (lastFloorPoint) { + // Free-following: re-run at the same spot so the floating ghost rebuilds + // with the flipped side (its swing/hinge geometry depends on `side`). + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) + } else { + // No preview yet (R pressed before the first pointermove at initial + // placement): flip the hidden node so the FIRST preview/commit already + // reflects the chosen side. + useScene.getState().updateNode(movingDoorNode.id, { + side: sideOverride, + rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], + }) + } + } + + // Shift toggles force-place. Track it live and re-run the on-wall preview so + // the tint flips green↔red the instant Shift is pressed/released, even with + // the pointer stationary — the ghost and the commit gate read the same + // `placeable`. (Commit gates still read shift fresh from their own event.) + const onShiftToggle = (e: KeyboardEvent) => { + if (e.key !== 'Shift') return + const held = e.type === 'keydown' + if (held === shiftHeld) return + shiftHeld = held + if (!committed && lastTarget) applyPreview(lastTarget) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -571,8 +853,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onShiftToggle) + window.addEventListener('keyup', onShiftToggle) return () => { const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as @@ -593,12 +879,21 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } + } else if (current && current.visible === false) { + // Safety net: a fresh (isNew) clone isn't marked `isTransient`, so the + // branch above skips it. If we unmount mid-free-follow it would be left + // hidden — reveal it so it never becomes an invisible orphan. (The + // `place-preset` movingNode subscription deletes a truly-cancelled + // clone separately.) + useScene.getState().updateNode(movingDoorNode.id, { visible: true }) } useLiveTransforms.getState().clear(movingDoorNode.id) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) @@ -608,8 +903,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keydown', onShiftToggle) + window.removeEventListener('keyup', onShiftToggle) } }, [movingDoorNode, exitMoveMode]) @@ -623,11 +922,26 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => boxGeo.dispose() return geo }, [movingDoorNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {/* Placement ghost shown for the whole move (the real pale node stays + hidden): red off-wall / colliding, green on a valid wall. Uses the + moving node's own dimensions so the ghost matches its type. */} + {ghostPose && ( + + + + )} + ) } diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index e778743b..c4a5f52f 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -516,7 +516,7 @@ export default function DoorPanel() { return ( { + const mesh = useMemo(() => { + const m = buildDoorPreviewMesh(node) + m.layers.set(EDITOR_LAYER) + return m + }, [node.width, node.height, node.frameDepth, node.openingShape, node.doorType, node.leafCount]) + + // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; + // its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid]) + + // Geometry is freshly built per `mesh` and owned here — dispose it only + // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. + useEffect( + () => () => { + mesh.traverse((obj) => { + const m = obj as { geometry?: { dispose: () => void } } + m.geometry?.dispose() + }) + }, + [mesh], + ) + + return +} + +export default DoorPreview diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 0aa4c8c1..3e5fc2c7 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -11,6 +11,7 @@ import { spatialGridManager, useScene, type WallEvent, + type WallNode, } from '@pascal-app/core' import { calculateCursorRotation, @@ -22,9 +23,13 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -33,6 +38,7 @@ import { } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' +import DoorPreview from './preview' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -45,31 +51,76 @@ const FALLBACK_WIDTH = 0.9 const FALLBACK_HEIGHT = 2.1 const roofFallbackPoint = new Vector3() +// What currently owns the cursor frame: a wall/roof mesh hover, or null when +// the cursor is over open floor (the grid handler then free-follows). +type HostKind = 'wall' | 'roof' | null + /** * Door tool — places DoorNodes on walls and on roof-segment wall faces * (the generated base walls under a roof, including coplanar gable ends). * Doors always sit at floor level (clampedY = height/2 — segment base for * roof-hosted doors). + * + * The ghost follows the cursor everywhere (like moving an item): over open + * floor it floats as an invalid (unplaceable) ghost; the moment the cursor + * ray hovers a wall (or roof-segment face) the real draft snaps onto it. + * Snapping engages only on an actual mesh hover — no proximity magnet — since + * the wall side faces are big raycast targets. */ const DoorTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) + // Off-host floating ghost: the real door geometry follows the cursor over + // the grid (tinted invalid). Mutually exclusive with the on-host draft — + // when a draft + wireframe is shown this is null and vice-versa. `side` + // carries the R-flip so the floating ghost faces the side that will be + // committed (its swing/hinge geometry depends on `side`). + const [fallbackPose, setFallbackPose] = useState<{ + position: [number, number, number] + rotationY: number + side: DoorNode['side'] + } | null>(null) + + // Ghost preview node — zeroed transform + the live facing side (rebuilds on R). + const ghostStub = useMemo( + () => + DoorNode.parse({ + position: [0, 0, 0], + rotation: [0, 0, 0], + side: fallbackPose?.side ?? 'front', + }), + [fallbackPose?.side], + ) + useEffect(() => { useScene.temporal.getState().pause() + let hostKind: HostKind = null + // timeStamp of the most recent wall/roof mesh event. A wall/roof hover and + // the grid raycast from the SAME pointermove share the source DOM event's + // timeStamp, so the grid handler can detect "a mesh handler already owns + // this frame" without depending on event order or on a leave firing (node + // events are suppressed during a camera drag, so a sticky boolean would + // strand the draft after an orbit; a per-frame timestamp self-heals). + let lastMeshEventTime = -1 + // R flips the door's facing side mid-placement (front ↔ back). On a wall + // the chosen side is `getSideFromNormal(normal)` flipped by `sideFlip`; + // re-applied to the last wall hover so the flip shows live before commit. + let sideFlip = false + let lastWallEvent: WallEvent | null = null + // Last open-floor cursor point (level-local X/Z) so an R-flip while + // free-following can re-render the floating ghost with the new facing. + let lastFloorPoint: [number, number, number] | null = null + const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } - const getSlabElevation = (wallEvent: WallEvent) => - spatialGridManager.getSlabElevationForWall( - wallEvent.node.parentId ?? '', - wallEvent.node.start, - wallEvent.node.end, - ) + const getSlabElevationForWall = (wall: WallNode) => + spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -86,17 +137,23 @@ const DoorTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + setFallbackPose(null) } // Alignment candidates — anchors of every alignable object; refreshed // after each placement. A door aligns by the plan position of its centre. let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // On-host cursor: the green/red wireframe outline tracks a live draft. + // Showing it always clears the off-host floating ghost (they never + // coexist — a draft means the cursor is on a valid host). const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, ) => { + setFallbackPose(null) const group = cursorGroupRef.current if (!group) return group.visible = true @@ -105,237 +162,158 @@ const DoorTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const showFallbackCursor = (event: GridEvent) => { - if (draftRef.current) return - const [x, y, z] = event.localPosition - updateCursor([x, y + FALLBACK_HEIGHT / 2, z], 0, false) + // Off-host fallback: hide the wireframe outline and float the real door + // geometry (tinted invalid) at the cursor so the armed tool is visible. + // `sideFlip` (R) flips the facing: a back facing is a π yaw on the floating + // ghost, and the door swing/hinge geometry reads `side`. + const showGhostAt = (position: [number, number, number]) => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + setFallbackPose({ + position, + rotationY: sideFlip ? Math.PI : 0, + side: sideFlip ? 'back' : 'front', + }) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) - useAlignmentGuides.getState().clear() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z]) } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z], 0, false) - useAlignmentGuides.getState().clear() + showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2, z]) } - const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - const levelId = getLevelId() - if (!levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (event.node.parentId !== levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - destroyDraft() - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const width = 0.9 - const height = 2.1 + // Settle a wall target: alignment snap → clamp → overlap check. Pure read. + const resolveWallPlacement = ( + wall: WallNode, + rawLocalX: number, + width: number, + height: number, + bypass: boolean, + bypassSnap: boolean, + ignoreId?: string, + ) => { + // bypassSnap is set by Shift (see callers). Shift = free-place: land at the + // raw cursor but keep the along-wall guides visible. bypass (Alt) still + // hard-disables alignment. const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], + wallNode: wall, + rawLocalX, width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + bypass: bypass && !bypassSnap, + freePlace: bypassSnap, }) - - const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) - - const node = DoorNode.parse({ - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, - metadata: { isTransient: true }, - }) - - useScene.getState().createNode(node, event.node.id as AnyNodeId) - draftRef.current = node - - const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, - ) - event.stopPropagation() + const { clampedX, clampedY } = clampToWall(wall, localX, width, height) + const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + return { clampedX, clampedY, valid } } - const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (event.node.parentId !== getLevelId()) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - + // Shared create/update path for the wall draft — used by the direct + // wall-mesh hover and the floor proximity snap. Reuses the existing draft + // (reparenting only on an actual wall change to avoid churning the host's + // children array, which flashes 0-vertex wall geometry in WebGPU). + const applyWallTarget = (args: { + wall: WallNode + rawLocalX: number + side: 'front' | 'back' + itemRotation: number + cursorRotationY: number + bypass: boolean + bypassSnap: boolean + }) => { + const { wall, rawLocalX, side, itemRotation, cursorRotationY, bypass, bypassSnap } = args const width = draftRef.current?.width ?? 0.9 const height = draftRef.current?.height ?? 2.1 - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) - - // Draft may be null after a successful placement (the click handler - // deletes it and relies on the wall rebuild → pointer-enter cascade to - // recreate it). Recreate it here on the first subsequent move so the - // preview is ready for the next click without requiring a leave/enter. if (!draftRef.current) { - const levelId = getLevelId() - if (levelId && event.node.parentId === levelId) { - const node = DoorNode.parse({ - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, - metadata: { isTransient: true }, - }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) - draftRef.current = node - } + const node = DoorNode.parse({ + position: [0, height / 2, 0], + rotation: [0, itemRotation, 0], + side, + wallId: wall.id, + parentId: wall.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, wall.id as AnyNodeId) + draftRef.current = node } - if (draftRef.current) { - // Update the scene store on every move so the 2D floor plan - // stays in sync (it re-renders from `node.position`). Only - // forward `parentId` / `wallId` when the wall actually changed - // — otherwise the reparent path churns the host wall's - // `children` array every tick, which re-renders the wall and - // briefly draws its 0-vertex placeholder geometry (WebGPU then - // flags "Vertex buffer slot 0 ... was not set"). - const isSameWall = event.node.id === draftRef.current.parentId - if (isSameWall) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - }) - markHostDirty(event.node.id) - } else { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - // The draft may arrive from a roof-segment face hover. - roofSegmentId: undefined, - roofFace: undefined, - }) - } - } - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, + const { clampedX, clampedY, valid } = resolveWallPlacement( + wall, + rawLocalX, width, height, - draftRef.current?.id, + bypass, + bypassSnap, + draftRef.current.id, ) + if (wall.id === draftRef.current.parentId) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + }) + markHostDirty(wall.id) + } else { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: wall.id, + wallId: wall.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, + roofFace: undefined, + }) + } + updateCursor( wallLocalToWorld( - event.node, + wall, clampedX, clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevationForWall(wall), ), - cursorRotation, + cursorRotationY, valid, ) - event.stopPropagation() + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: false, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevationForWall(wall), + }) + } + return { clampedX, clampedY, valid } } - const onWallClick = (event: WallEvent) => { - if (!draftRef.current) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - if (event.node.parentId !== getLevelId()) return - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: draftRef.current.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - draftRef.current.width, - draftRef.current.height, - ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - draftRef.current.width, - draftRef.current.height, - draftRef.current.id, - ) - if (!valid) return - + // Promote the draft into a permanent door. Shared by the wall-mesh click + // and the floor proximity click. + const commitDoorAtWall = ( + wall: WallNode, + clampedX: number, + clampedY: number, + side: 'front' | 'back', + itemRotation: number, + ) => { const draft = draftRef.current + if (!draft) return draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -344,18 +322,17 @@ const DoorTool: React.FC = () => { const state = useScene.getState() const doorCount = Object.values(state.nodes).filter((n) => { if (n.type !== 'door') return false - const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined - return wall?.parentId === levelId + const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined + return w?.parentId === levelId }).length - const name = `Door ${doorCount + 1}` const node = DoorNode.parse({ - name, + name: `Door ${doorCount + 1}`, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, - wallId: event.node.id, - parentId: event.node.id, + wallId: wall.id, + parentId: wall.id, width: draft.width, height: draft.height, doorCategory: draft.doorCategory, @@ -380,19 +357,110 @@ const DoorTool: React.FC = () => { panicBarHeight: draft.panicBarHeight, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + } + // ── Direct wall-mesh hover ────────────────────────────────────── + const onWallHover = (event: WallEvent) => { + hostKind = 'wall' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + destroyDraft() + showWallFallbackCursor(event) + return + } + lastWallEvent = event + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const flipOffset = sideFlip ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + flipOffset + const cursorRotation = + calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + applyWallTarget({ + wall: event.node, + rawLocalX: event.localPosition[0], + side, + itemRotation, + cursorRotationY: cursorRotation, + bypass, + bypassSnap, + }) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + return + } + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0) + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + const { clampedX, clampedY, valid } = resolveWallPlacement( + event.node, + event.localPosition[0], + draftRef.current.width, + draftRef.current.height, + bypass, + bypassSnap, + draftRef.current.id, + ) + // Shift force-places over a collision (the draft stays red as a warning). + if (!valid && !bypassSnap) return + + commitDoorAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() } const onWallLeave = () => { + if (hostKind !== 'wall') return + lastWallEvent = null destroyDraft() hideCursor() + hostKind = null + } + + // ── Floor free-follow ─────────────────────────────────────────── + // Over open floor the ghost follows the cursor like a moving item. It does + // NOT snap from proximity — snapping engages only when the cursor ray + // actually hovers a wall (onWallHover) or roof face (onRoofHover). + const onGridFreeFollow = (event: GridEvent) => { + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler processed this exact pointermove (R3F + the + // grid raycast share the source DOM event's timeStamp) — it owns the + // frame and has snapped the draft, so skip the floor follow this tick. + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastMeshEventTime) return + // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft + // and free-follow the cursor with the invalid (unplaceable) ghost. + hostKind = null + lastWallEvent = null + const [x, y, z] = event.localPosition + lastFloorPoint = [x, y + FALLBACK_HEIGHT / 2, z] + destroyDraft() + showGhostAt(lastFloorPoint) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -414,6 +482,8 @@ const DoorTool: React.FC = () => { } const onRoofHover = (event: RoofEvent) => { + hostKind = 'roof' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofTarget(event) if (!target) { // On the roof but not over a placeable wall face (slope, soffit, @@ -444,6 +514,8 @@ const DoorTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -451,11 +523,14 @@ const DoorTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return const { segment, face, position } = target const draft = draftRef.current draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -508,59 +583,100 @@ const DoorTool: React.FC = () => { } const onRoofLeave = () => { - if (!draftRef.current?.roofSegmentId) return + if (hostKind !== 'roof') return destroyDraft() hideCursor() + hostKind = null } const onCancel = () => { destroyDraft() hideCursor() + hostKind = null } - emitter.on('wall:enter', onWallEnter) - emitter.on('wall:move', onWallMove) + // R flips the door's facing side mid-placement (front ↔ back), like the + // committed-selected R flip. ALWAYS toggles the persistent flip intent — + // never a no-op (the old `!lastWallEvent` guard dropped R off-wall and before + // the first wall hover, so it "needed two presses"). Then re-renders whatever + // preview is current so the flip shows live and matches commit. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'r' && e.key !== 'R') return + if (e.repeat) return + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return + e.preventDefault() + sideFlip = !sideFlip + triggerSFX('sfx:item-rotate') + if (lastWallEvent) { + // On a wall: re-resolve + re-preview with the flipped side. + onWallHover(lastWallEvent) + } else if (lastFloorPoint) { + // Off-wall: re-render the floating ghost (showGhostAt reads `sideFlip`). + showGhostAt(lastFloorPoint) + } + // else: no preview yet — `sideFlip` is set, so the first hover/follow uses it. + } + + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) - emitter.on('grid:move', showFallbackCursor) + emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() - emitter.off('wall:enter', onWallEnter) - emitter.off('wall:move', onWallMove) + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) - emitter.off('grid:move', showFallbackCursor) + emitter.off('grid:move', onGridFreeFollow) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) } }, []) - // Cursor geometry: door outline. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: door outline. Static dims, so build it once and dispose on + // unmount rather than reallocating (and orphaning) an EdgesGeometry on every + // re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {fallbackPose && ( + + + + )} + ) } diff --git a/packages/nodes/src/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index a392d67a..2f7dd7c0 100644 --- a/packages/nodes/src/dormer/csg-geometry.ts +++ b/packages/nodes/src/dormer/csg-geometry.ts @@ -325,7 +325,6 @@ export function generateDormerGeometry( const dormerBrushes = getRoofSegmentBrushes(virtualSegment) if (!dormerBrushes) { - // biome-ignore lint/suspicious/noConsole: keep diagnostic — fallback path. console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.') return buildDormerFallbackGeometry(dormer) } @@ -472,7 +471,6 @@ export function generateDormerGeometry( remapRoofShellFaces(resultGeo, virtualSegment) splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX) } catch (e) { - // biome-ignore lint/suspicious/noConsole: dormer CSG can throw; keep diagnostic. console.error('[dormer] CSG failed, falling back to silhouette:', e) if (dormerSolid) { try { @@ -492,7 +490,6 @@ export function generateDormerGeometry( // dormer is at least visible. const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0 if (triCount === 0) { - // biome-ignore lint/suspicious/noConsole: keep diagnostic — empty CSG. console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.') return buildDormerFallbackGeometry(dormer) } diff --git a/packages/nodes/src/dormer/definition.ts b/packages/nodes/src/dormer/definition.ts index ce929a78..17e9581d 100644 --- a/packages/nodes/src/dormer/definition.ts +++ b/packages/nodes/src/dormer/definition.ts @@ -452,7 +452,7 @@ export const dormerDefinition: NodeDefinition = { presentation: { label: 'Dormer', description: 'House-shaped protrusion on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 125, }, diff --git a/packages/nodes/src/dormer/panel-position-section.tsx b/packages/nodes/src/dormer/panel-position-section.tsx index c36e0629..4833b75c 100644 --- a/packages/nodes/src/dormer/panel-position-section.tsx +++ b/packages/nodes/src/dormer/panel-position-section.tsx @@ -41,6 +41,7 @@ export function DormerPositionSection({ const segmentId = segment?.id const roofChildrenKey = (roof?.children ?? []).join(',') + // biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids. const worldXform = useMemo(() => { const dormerObj = sceneRegistry.nodes.get(selectedId) let worldX = 0 @@ -79,7 +80,6 @@ export function DormerPositionSection({ if (Number.isFinite(lo_x)) bounds = { minX: lo_x, maxX: hi_x, minZ: lo_z, maxZ: hi_z } } return { worldX, worldZ, worldRotation, bounds } - // biome-ignore lint/correctness/useExhaustiveDependencies: roofChildrenKey is the stable signature of `roof.children`; intentionally omitting `roof` (object identity) in favor of the joined ids. }, [selectedId, px, py, pz, nodeRotation, segmentId, roofChildrenKey]) const worldX_now = worldXform.worldX diff --git a/packages/nodes/src/dormer/panel.tsx b/packages/nodes/src/dormer/panel.tsx index be6d1676..dd19c16d 100644 --- a/packages/nodes/src/dormer/panel.tsx +++ b/packages/nodes/src/dormer/panel.tsx @@ -107,7 +107,7 @@ export default function DormerPanel() { }, [node, selectedId, setMovingNode, setSelection]) const handleDuplicate = useCallback(() => { - if (!(node && node.roofSegmentId)) return + if (!node?.roofSegmentId) return triggerSFX('sfx:item-pick') // Deep clone and strip the id so the move tool's onClick branch // (`isNew || !node.id`) takes the "create fresh" path. Setting @@ -159,7 +159,7 @@ export default function DormerPanel() { return ( { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const DormerPreview = ({ node, invalid }: { node: DormerNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geo = useMemo( () => buildDormerGhostGeometry(node), [node.width, node.depth, node.height, node.roofHeight, node.roofType, node.wallSkirtHeight], @@ -20,7 +34,7 @@ const DormerPreview = ({ node }: { node: DormerNode }) => { useEffect(() => () => geo.dispose(), [geo]) - return {}} /> + return {}} /> } export default DormerPreview diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 36cf6bc2..360215de 100644 --- a/packages/nodes/src/dormer/renderer.tsx +++ b/packages/nodes/src/dormer/renderer.tsx @@ -59,6 +59,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { // shingle, 4=Gable wall. Walls take the 'wall' role, the deck side and // shingle take 'roof'. When textures are off, every slot snaps to its // role colour regardless of explicit paint (the render-modes invariant). + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const material = useMemo(() => { const wallRole = () => createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme) const roofRole = () => createSurfaceRoleMaterial('roof', colorPreset, undefined, sceneTheme) @@ -111,6 +112,7 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { [colorPreset, sceneTheme], ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo(() => { if (!segment) return null if (isLiveDrag) return buildDormerFallbackGeometry(node) diff --git a/packages/nodes/src/dormer/tool.tsx b/packages/nodes/src/dormer/tool.tsx index fdbdd98a..d677c6a5 100644 --- a/packages/nodes/src/dormer/tool.tsx +++ b/packages/nodes/src/dormer/tool.tsx @@ -73,8 +73,8 @@ const DormerTool = () => { <> } onInvalidTarget={clearPreview} - size={[1.8, 1.8, 1.4]} /> {activeBuildingId && segmentXform && hitLocal && ( diff --git a/packages/nodes/src/dormer/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index 4c7734cb..10060e1d 100644 --- a/packages/nodes/src/dormer/window-assembly.tsx +++ b/packages/nodes/src/dormer/window-assembly.tsx @@ -28,6 +28,7 @@ const DormerWindowAssembly = ({ frameMaterial: THREE.Material glassMaterial: THREE.Material }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const skirtWin = useMemo( () => getDormerSkirtWindowDims(node), [ @@ -45,6 +46,7 @@ const DormerWindowAssembly = ({ const winShape: DormerWindowShape = node.windowShape const resolvedRadii: [number, number, number, number] = [...node.windowCornerRadii] + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const winGeo = useMemo( () => buildDormerWindowGeometries( @@ -101,6 +103,7 @@ const DormerWindowAssembly = ({ ) useEffect(() => () => sillGeo?.dispose(), [sillGeo]) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const exposed = useMemo( () => getDormerExposedFaces(node, segment), [ @@ -142,7 +145,6 @@ const DormerWindowAssembly = ({ {winGeo.glassPanes.map((pane, i) => ( = { presentation: { label: 'Downspout', description: 'Vertical drop pipe from a gutter outlet to the ground.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/downspout/preview.tsx b/packages/nodes/src/downspout/preview.tsx index 596ebecb..28efe27c 100644 --- a/packages/nodes/src/downspout/preview.tsx +++ b/packages/nodes/src/downspout/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildDownspoutGeometry } from './geometry' import type { DownspoutRouting } from './routing' import type { DownspoutNode } from './schema' @@ -19,10 +20,13 @@ import type { DownspoutNode } from './schema' const DownspoutPreview = ({ node, routing, + invalid, }: { node: DownspoutNode routing?: DownspoutRouting | null + invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildDownspoutGeometry(node, routing), [ @@ -40,17 +44,17 @@ const DownspoutPreview = ({ const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.7, metalness: 0.2, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.FrontSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index cc0cb6e6..8f071dda 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -101,6 +101,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { // that actually move the jog or the collar bore, so the pipe geometry // only rebuilds when one of those changes (not on every override-merge // render). Resolves to null when the gutter has no outlet. + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const routing = useMemo( () => effectiveGutter && effectiveSegment @@ -117,6 +118,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { ], ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildDownspoutGeometry(node, routing), [ diff --git a/packages/nodes/src/downspout/tool.tsx b/packages/nodes/src/downspout/tool.tsx index 7356f1d0..e9ede870 100644 --- a/packages/nodes/src/downspout/tool.tsx +++ b/packages/nodes/src/downspout/tool.tsx @@ -167,8 +167,8 @@ const DownspoutTool = () => { <> } onInvalidTarget={() => setTarget(null)} - size={[0.2, 2.5, 0.2]} validTarget="gutter" /> {activeBuildingId && target && ( diff --git a/packages/nodes/src/duct-fitting/definition.ts b/packages/nodes/src/duct-fitting/definition.ts new file mode 100644 index 00000000..72a102d7 --- /dev/null +++ b/packages/nodes/src/duct-fitting/definition.ts @@ -0,0 +1,134 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { rotateFittingNode } from '../shared/fitting-rotation' +import { buildDuctFittingFloorplan } from './floorplan' +import { buildDuctFittingGeometry } from './geometry' +import { ductFittingParametrics } from './parametrics' +import { getDuctFittingPorts } from './ports' +import { DuctFittingNode } from './schema' + +/** + * Phase 2 of the HVAC node system — duct fittings (elbow / tee / reducer) + * and the first kind to expose typed ports (`def.ports`). + * + * Composition: `def.geometry` only, same as duct-segment. Ports are the + * architectural payload: placement tools snap onto them, and a later + * slice walks them to build the supply/return system graph. + */ +export const ductFittingDefinition: NodeDefinition = { + kind: 'duct-fitting', + schemaVersion: 1, + schema: DuctFittingNode, + category: 'utility', + distributionRole: 'fitting', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + fittingType: 'elbow', + shape: 'round', + width: 14, + height: 8, + shape2: 'round', + width2: 14, + height2: 8, + angle: 90, + branchAngle: 90, + diameter: 6, + diameter2: 6, + ductMaterial: 'sheet-metal', + system: 'supply', + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + // `cursorAttached`: a fitting is a small connector — an offset- + // preserving drag reads as the mesh trailing the mouse, so pin its + // origin to the cursor instead. + movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true }, + duplicable: true, + deletable: true, + }, + + parametrics: ductFittingParametrics, + + geometry: buildDuctFittingGeometry, + geometryKey: (n) => + JSON.stringify([ + n.fittingType, + // The mitered elbow + flange profiles swap width/height roles based + // on where world-up sits in the local frame, so orientation is a + // geometry input. + n.rotation, + n.shape, + n.width, + n.height, + n.shape2, + n.width2, + n.height2, + n.angle, + n.branchAngle, + n.diameter, + n.diameter2, + n.ductMaterial, + n.system, + ]), + + ports: getDuctFittingPorts, + + floorplan: buildDuctFittingFloorplan, + + // R/T rotate a selected fitting ±45° around the shared active axis. + // The default editor rotate only knows Y; fittings need X/Z for + // risers, so this overrides it. Alt-cycling of the axis + the axis + // badge live in `./selection.tsx`. + keyboardActions: { + r: { + appliesTo: (node) => node.type === 'duct-fitting', + run: (node) => rotateFittingNode(node, 1), + }, + t: { + appliesTo: (node) => node.type === 'duct-fitting', + run: (node) => rotateFittingNode(node, -1), + }, + axisCycling: true, + }, + + // Alt-cycles the active rotation axis while a fitting is selected. + // Editor-only (drives `useEditor.rotationAxis`), so it mounts via the + // editor's SelectionAffordanceManager rather than `def.system`. + affordanceTools: { + selection: () => import('./selection'), + // Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a + // translucent copy of the fitting (built from its real geometry, at its + // own rotation, so an elbow / riser stays properly aligned) follows the + // cursor and only lands on the commit click. Takes priority over + // `capabilities.movable` in the MoveTool dispatcher. + move: () => import('./move-tool'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place fitting' }, + { key: 'Hover a duct end', label: 'Snap onto the run' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Duct Fitting', + description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.', + icon: { kind: 'url', src: '/icons/duct-fitting.webp' }, + paletteSection: 'structure', + paletteOrder: 91, + }, + + mcp: { + description: + 'A duct fitting (elbow, tee, reducer, or square-to-round transition) with typed connection ports. Position is level-local meters; rotation is an XYZ euler in radians.', + }, +} diff --git a/packages/nodes/src/duct-fitting/floorplan.ts b/packages/nodes/src/duct-fitting/floorplan.ts new file mode 100644 index 00000000..da703456 --- /dev/null +++ b/packages/nodes/src/duct-fitting/floorplan.ts @@ -0,0 +1,69 @@ +import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import { getDuctFittingPorts } from './ports' +import type { DuctFittingNode } from './schema' + +const SUPPLY_COLOR = '#d4825a' +const RETURN_COLOR = '#5a8ad4' +const BODY_COLOR = '#9ca3af' + +/** + * Floor-plan symbol for a duct fitting: one stub line per port from the + * junction center out to the collar (drawn at each collar's real + * diameter), plus a junction circle. Ports are computed in level-local + * 3D and projected to plan, so a rotated or riser-turned fitting shows + * its true plan footprint; a vertical port collapses onto the junction + * circle, which is exactly how it should read from above. + */ +export function buildDuctFittingFloorplan( + node: DuctFittingNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const [cx, , cz] = node.position + const ports = getDuctFittingPorts(node) + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const accent = node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR + const bodyStroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR + + const children: FloorplanGeometry[] = [] + for (const port of ports) { + const px = port.position[0] + const pz = port.position[2] + // Vertical port — projects onto the junction itself; skip the stub. + if (Math.hypot(px - cx, pz - cz) < 1e-4) continue + children.push({ + kind: 'line', + x1: cx, + y1: cz, + x2: px, + y2: pz, + stroke: bodyStroke, + strokeWidth: port.diameter * INCHES_TO_METERS, + strokeLinecap: 'round', + opacity: showSelectedChrome ? 0.95 : 0.8, + }) + } + + children.push({ + kind: 'circle', + cx, + cy: cz, + r: (node.diameter * INCHES_TO_METERS) / 2 + 0.015, + fill: bodyStroke, + stroke: accent, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + opacity: 0.95, + }) + + if (showSelectedChrome) { + children.push({ + kind: 'move-handle', + point: [cx, cz], + }) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/duct-fitting/geometry.ts b/packages/nodes/src/duct-fitting/geometry.ts new file mode 100644 index 00000000..3aa4d7a0 --- /dev/null +++ b/packages/nodes/src/duct-fitting/geometry.ts @@ -0,0 +1,463 @@ +import { + BufferGeometry, + CylinderGeometry, + DoubleSide, + Euler, + Float32BufferAttribute, + Group, + Mesh, + type MeshStandardMaterial, + SphereGeometry, + TorusGeometry, + Vector3, +} from 'three' +import { + buildOvalSection, + buildRectSection, + buildSection, + createDuctMaterial, + INCHES_TO_METERS, +} from '../duct-segment/geometry' +import { localFittingPorts } from './ports' +import type { DuctFittingNode } from './schema' + +const RADIAL_SEGMENTS = 24 +const UP = new Vector3(0, 1, 0) + +/** + * Mitered rectangular elbow as ONE closed solid — the way sheet-metal + * square elbows are actually folded. The rect profile sweeps from the + * inlet face to the outlet face through a single miter ring lying on + * the corner's bisector plane (the classic 2D miter-join offset: + * join(u) = (wA + wB) · u / (1 + wA·wB)), so the two legs meet in a + * crisp seam instead of interpenetrating boxes. + * + * Local frame: legs in the XZ plane (ports convention) so the fold hinge + * is always local Y. `sweepM` is the profile dimension carried through the + * bend (in the XZ bend plane); `cheekM` is the dimension that stays + * constant along the hinge. Which physical dimension (width vs height) + * plays each role depends on the elbow's world orientation and is decided + * by the caller — a floor turn folds about vertical (cheek = height), + * a wall riser folds about horizontal (cheek = width). + * + * Non-indexed triangles → flat face normals for the folded-metal look; + * the closed solid renders double-sided so winding never makes a face + * vanish. + */ +/** + * Stadium (flat-oval) outline in profile (u, v) coordinates: u-extent + * `uM`, v-extent `vM`, semicircular caps of the smaller dimension. The + * caps land on whichever axis is longer, so a riser-rotated profile + * (swapped roles) stays a valid stadium. + */ +function stadiumOutline(uM: number, vM: number, samplesPerCap = 10): Array<[number, number]> { + const pts: Array<[number, number]> = [] + const r = Math.min(uM, vM) / 2 + const s = (Math.max(uM, vM) - Math.min(uM, vM)) / 2 + const cap = (cu: number, cv: number, startA: number) => { + for (let i = 0; i <= samplesPerCap; i++) { + const a = startA + (Math.PI * i) / samplesPerCap + pts.push([cu + r * Math.cos(a), cv + r * Math.sin(a)]) + } + } + if (uM >= vM) { + cap(s, 0, -Math.PI / 2) + cap(-s, 0, Math.PI / 2) + } else { + cap(0, s, 0) + cap(0, -s, Math.PI) + } + return pts +} + +function buildMiteredElbow( + inletPos: Vector3, + outletPos: Vector3, + sweepM: number, + cheekM: number, + profileShape: 'rect' | 'oval', + material: MeshStandardMaterial, +): Mesh { + const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction + const travelOut = outletPos.clone().normalize() // junction → outlet + const wA = new Vector3().crossVectors(UP, travelIn).normalize() + const wB = new Vector3().crossVectors(UP, travelOut).normalize() + // Elbow turns are ≤ 90°, so wA·wB ≥ 0 and the join never degenerates. + const miterScale = 1 / (1 + wA.dot(wB)) + const wJoin = new Vector3().addVectors(wA, wB) + + const hw = sweepM / 2 + const hh = cheekM / 2 + const corners: Array<[number, number]> = + profileShape === 'oval' + ? stadiumOutline(sweepM, cheekM) + : [ + [hw, hh], + [-hw, hh], + [-hw, -hh], + [hw, -hh], + ] + const n = corners.length + const ring = (center: Vector3, uAxis: Vector3, scale = 1): Vector3[] => + corners.map(([u, v]) => + center + .clone() + .addScaledVector(uAxis, u * scale) + .addScaledVector(UP, v), + ) + + const inletRing = ring(inletPos, wA) + const miterRing = ring(new Vector3(0, 0, 0), wJoin, miterScale) + const outletRing = ring(outletPos, wB) + + const positions: number[] = [] + const tri = (a: Vector3, b: Vector3, c: Vector3) => + positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z) + const quad = (a: Vector3, b: Vector3, c: Vector3, d: Vector3) => { + tri(a, b, c) + tri(a, c, d) + } + const skin = (from: Vector3[], to: Vector3[]) => { + for (let k = 0; k < n; k++) { + const k2 = (k + 1) % n + quad(from[k]!, to[k]!, to[k2]!, from[k2]!) + } + } + skin(inletRing, miterRing) + skin(miterRing, outletRing) + // End caps — triangle fans so any convex profile closes. + for (let k = 1; k < n - 1; k++) { + tri(inletRing[0]!, inletRing[k]!, inletRing[k + 1]!) + tri(outletRing[k + 1]!, outletRing[k]!, outletRing[0]!) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + const solidMaterial = material.clone() + solidMaterial.side = DoubleSide + const mesh = new Mesh(geometry, solidMaterial) + mesh.name = `fitting-elbow-${profileShape}` + return mesh +} + +/** + * Square-to-round loft between a rect ring at `xRect` and a round ring + * at `xRound`, both centered on the local X axis (the straight-through + * run). Profiles are sampled at matching polar angles — the rect point + * is the ray's intersection with the rectangle boundary — so the skin + * twists nowhere. Non-indexed triangles + computed normals give the + * faceted gore look of a real shop-made square-to-round. + */ +function buildRectToRoundLoft( + xRect: number, + xRound: number, + widthM: number, + heightM: number, + radius: number, + material: MeshStandardMaterial, +): Mesh { + const hw = widthM / 2 + const hh = heightM / 2 + const rectRing: Vector3[] = [] + const roundRing: Vector3[] = [] + for (let i = 0; i < RADIAL_SEGMENTS; i++) { + const theta = (2 * Math.PI * i) / RADIAL_SEGMENTS + const cz = Math.cos(theta) + const sy = Math.sin(theta) + // Scale the unit ray until it hits the rectangle boundary. Width + // spans local Z and height local Y — the same axes buildRectSection + // gives a +X run. + const t = 1 / Math.max(Math.abs(cz) / hw, Math.abs(sy) / hh) + rectRing.push(new Vector3(xRect, t * sy, t * cz)) + roundRing.push(new Vector3(xRound, radius * sy, radius * cz)) + } + + const positions: number[] = [] + const tri = (a: Vector3, b: Vector3, c: Vector3) => + positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z) + for (let i = 0; i < RADIAL_SEGMENTS; i++) { + const j = (i + 1) % RADIAL_SEGMENTS + tri(rectRing[i]!, roundRing[i]!, roundRing[j]!) + tri(rectRing[i]!, roundRing[j]!, rectRing[j]!) + } + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + const solidMaterial = material.clone() + solidMaterial.side = DoubleSide + const mesh = new Mesh(geometry, solidMaterial) + mesh.name = 'fitting-transition-loft' + return mesh +} + +/** + * Pure geometry builder for a duct fitting, in the fitting's LOCAL frame — + * `` applies `node.position` / `node.rotation`. + * + * Strategy: one cylinder stub per port from the junction center outward + * (reusing the segment builder's `buildSection`), a sphere at the + * junction, and a slightly-oversized crimp collar ring at each port + * opening so fittings read as sheet-metal junctions rather than bare + * tube ends. + * + * The reducer is special-cased: instead of equal stubs + sphere it draws + * a short inlet stub, a tapered cone, and a short outlet stub inline. + * + * Non-round shapes (elbow / tee): run legs carry the fitting's + * width × height profile — rect prisms or flat-oval stadiums — matching + * the trunk they join; a tee's branch leg carries its own `shape2` + * profile (width2 × height2, or round at `diameter2`). The profile's + * height rides local +Y — for the horizontal-plane orientations trunks + * are drawn in, that's world-vertical. + */ +export function buildDuctFittingGeometry(node: DuctFittingNode): Group { + const group = new Group() + const material = createDuctMaterial(node) + const radiusMain = (node.diameter * INCHES_TO_METERS) / 2 + const ports = localFittingPorts(node) + const widthM = node.width * INCHES_TO_METERS + const heightM = node.height * INCHES_TO_METERS + // The elbow folds about its local Y. Width spans the XZ bend plane and + // height rides the hinge ONLY when local Y is world-vertical (a floor + // turn). For a riser the node is rotated so local Y lands horizontal — + // then it's width that runs along the hinge, so the roles swap. Pick by + // where world-up sits in the fitting's local frame. + const hingeWorld = UP.clone().applyEuler( + new Euler(node.rotation[0], node.rotation[1], node.rotation[2]), + ) + const hingeIsVertical = Math.abs(hingeWorld.y) >= Math.SQRT1_2 + + if (node.fittingType === 'reducer') { + const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2 + const inlet = ports[0]! + const outlet = ports[1]! + const taperHalf = Math.abs(inlet.position.x) / 3 + const stubA = buildSection( + inlet.position, + new Vector3(-taperHalf, 0, 0), + radiusMain, + material, + 'fitting-stub-inlet', + ) + if (stubA) group.add(stubA) + const cone = new Mesh( + new CylinderGeometry(radiusOut, radiusMain, taperHalf * 2, RADIAL_SEGMENTS, 1, false), + material, + ) + cone.name = 'fitting-taper' + cone.quaternion.setFromUnitVectors(UP, new Vector3(1, 0, 0)) + group.add(cone) + const stubB = buildSection( + new Vector3(taperHalf, 0, 0), + outlet.position, + radiusOut, + material, + 'fitting-stub-outlet', + ) + if (stubB) group.add(stubB) + } else if (node.fittingType === 'transition') { + // Square-to-round: rect stub on the inlet, lofted gore body through + // the junction, round stub on the outlet. Same inline layout as the + // reducer, with the taper replaced by the loft. + const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2 + const inlet = ports[0]! + const outlet = ports[1]! + const taperHalf = Math.abs(inlet.position.x) / 3 + const stubA = buildRectSection( + inlet.position, + new Vector3(-taperHalf, 0, 0), + widthM, + heightM, + material, + 'fitting-stub-inlet', + ) + if (stubA) group.add(stubA) + group.add(buildRectToRoundLoft(-taperHalf, taperHalf, widthM, heightM, radiusOut, material)) + const stubB = buildSection( + new Vector3(taperHalf, 0, 0), + outlet.position, + radiusOut, + material, + 'fitting-stub-outlet', + ) + if (stubB) group.add(stubB) + } else if (node.shape !== 'round' && node.fittingType === 'elbow') { + // One mitered solid — no stubs, no junction blob. Oval profiles + // sweep the same way; the ring is a stadium instead of 4 corners. + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + group.add( + buildMiteredElbow( + inlet.position, + outlet.position, + hingeIsVertical ? widthM : heightM, + hingeIsVertical ? heightM : widthM, + node.shape, + material, + ), + ) + } else if (node.shape !== 'round' && node.fittingType === 'tee') { + // Straight rect / oval run inlet→outlet (one prism — nothing to + // miter) plus a branch leg tapping its side. The branch carries its + // own profile: rect or oval at width2 × height2, round at diameter2. + // + // Same orientation swap as the elbow: the run prism and branch stub + // are built on the `rectSectionAxes` basis, whose height rides local + // +Y. That's world-vertical only when the tee's local Y stays vertical + // (a flat tap off a horizontal trunk). When the tee is rotated so + // local Y lands horizontal, width and height roles swap so the + // physical height keeps reading as the vertical face — without this a + // tee drawn along the perpendicular axis looks squished. + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + const width2M = node.width2 * INCHES_TO_METERS + const height2M = node.height2 * INCHES_TO_METERS + const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection + const run = buildRunSection( + inlet.position, + outlet.position, + hingeIsVertical ? widthM : heightM, + hingeIsVertical ? heightM : widthM, + material, + 'fitting-run', + ) + if (run) group.add(run) + const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection + const stub = + node.shape2 !== 'round' + ? buildBranchSection( + new Vector3(0, 0, 0), + branch.position, + hingeIsVertical ? width2M : height2M, + hingeIsVertical ? height2M : width2M, + material, + 'fitting-stub-branch', + ) + : buildSection( + new Vector3(0, 0, 0), + branch.position, + (branch.diameter * INCHES_TO_METERS) / 2, + material, + 'fitting-stub-branch', + ) + if (stub) group.add(stub) + } else if (node.shape !== 'round' && node.fittingType === 'cross') { + // Straight rect / oval run inlet→outlet plus two opposed branch legs + // (±Z) carrying the branch profile — both halves of the run that + // passed through, same size at `width2 × height2` / `diameter2`. Same + // orientation swap as the tee / elbow so the cross stays upright when + // rotated so its local Y lands horizontal. + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const width2M = node.width2 * INCHES_TO_METERS + const height2M = node.height2 * INCHES_TO_METERS + const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection + const run = buildRunSection( + inlet.position, + outlet.position, + hingeIsVertical ? widthM : heightM, + hingeIsVertical ? heightM : widthM, + material, + 'fitting-run', + ) + if (run) group.add(run) + const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection + for (const id of ['branch', 'branch2'] as const) { + const branch = ports.find((p) => p.id === id)! + const stub = + node.shape2 !== 'round' + ? buildBranchSection( + new Vector3(0, 0, 0), + branch.position, + hingeIsVertical ? width2M : height2M, + hingeIsVertical ? height2M : width2M, + material, + `fitting-stub-${id}`, + ) + : buildSection( + new Vector3(0, 0, 0), + branch.position, + (branch.diameter * INCHES_TO_METERS) / 2, + material, + `fitting-stub-${id}`, + ) + if (stub) group.add(stub) + } + } else { + for (const port of ports) { + const stub = buildSection( + new Vector3(0, 0, 0), + port.position, + (port.diameter * INCHES_TO_METERS) / 2, + material, + `fitting-stub-${port.id}`, + ) + if (stub) group.add(stub) + } + const junction = new Mesh(new SphereGeometry(radiusMain * 1.02, RADIAL_SEGMENTS, 12), material) + junction.name = 'fitting-junction' + group.add(junction) + } + + // Joint trim at each opening. Round legs get a crimp-collar torus just + // proud of the stub; rect legs get a drive-cleat flange — the thin + // raised rim (TDC/S-cleat) real sheet-metal trunk joints wear where a + // section meets a fitting. The plate is centered on the collar plane so + // the rim reads as the seam between fitting and duct. Run legs + // (inlet/outlet) are rect when `shape` is rect; a rect tee's branch is + // rect when `shape2` is rect. Reducers ignore shape. + // Which profile a leg's opening carries: a transition's inlet is its + // rect end regardless of `shape`; reducers are always round; otherwise + // the run legs follow `shape` and a tee's branch follows `shape2` + // (only meaningful when the run itself is non-round). + const legShape = (portId: string): 'round' | 'rect' | 'oval' => { + if (node.fittingType === 'transition') return portId === 'inlet' ? 'rect' : 'round' + if (node.fittingType === 'reducer' || node.shape === 'round') return 'round' + return portId === 'branch' || portId === 'branch2' ? node.shape2 : node.shape + } + // The flange's profile must match the leg it caps: the branch carries + // its own width2 × height2; elbow legs swap width/height roles when the + // fold hinge lies horizontal (riser elbows) — same choice as the + // mitered solid above. + const rectLegProfile = (portId: string): [number, number] => { + if (portId === 'branch' || portId === 'branch2') { + const width2M = node.width2 * INCHES_TO_METERS + const height2M = node.height2 * INCHES_TO_METERS + return hingeIsVertical ? [width2M, height2M] : [height2M, width2M] + } + if (!hingeIsVertical) return [heightM, widthM] + return [widthM, heightM] + } + const FLANGE_LIP_M = 0.02 + const FLANGE_THICK_M = 0.012 + for (const port of ports) { + const profile = legShape(port.id) + if (profile !== 'round') { + const [w, h] = rectLegProfile(port.id) + const start = port.position.clone().addScaledVector(port.direction, -FLANGE_THICK_M / 2) + const end = port.position.clone().addScaledVector(port.direction, FLANGE_THICK_M / 2) + const buildFlange = profile === 'oval' ? buildOvalSection : buildRectSection + const flange = buildFlange( + start, + end, + w + FLANGE_LIP_M * 2, + h + FLANGE_LIP_M * 2, + material, + `fitting-flange-${port.id}`, + ) + if (flange) group.add(flange) + continue + } + const radius = (port.diameter * INCHES_TO_METERS) / 2 + const collar = new Mesh(new TorusGeometry(radius, radius * 0.12, 8, RADIAL_SEGMENTS), material) + collar.name = `fitting-collar-${port.id}` + collar.position.copy(port.position) + collar.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), port.direction) + group.add(collar) + } + + return group +} diff --git a/packages/nodes/src/duct-fitting/index.ts b/packages/nodes/src/duct-fitting/index.ts new file mode 100644 index 00000000..b1509f23 --- /dev/null +++ b/packages/nodes/src/duct-fitting/index.ts @@ -0,0 +1,4 @@ +export { ductFittingDefinition } from './definition' +export { buildDuctFittingGeometry } from './geometry' +export { getDuctFittingPorts } from './ports' +export { DuctFittingNode } from './schema' diff --git a/packages/nodes/src/duct-fitting/move-tool.tsx b/packages/nodes/src/duct-fitting/move-tool.tsx new file mode 100644 index 00000000..f378d622 --- /dev/null +++ b/packages/nodes/src/duct-fitting/move-tool.tsx @@ -0,0 +1,286 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + DuctFittingNode, + emitter, + type GridEvent, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useState } from 'react' +import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' +import { buildDuctFittingGeometry } from './geometry' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** World-space size + centre offset of `box` after the fitting's euler + * rotation — the footprint box that wraps the oriented geometry. */ +function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } { + const euler = new Euler(rotation[0], rotation[1], rotation[2]) + const min = box.min + const max = box.max + const corners: Vec3[] = [ + [min.x, min.y, min.z], + [max.x, min.y, min.z], + [min.x, max.y, min.z], + [min.x, min.y, max.z], + [max.x, max.y, min.z], + [max.x, min.y, max.z], + [min.x, max.y, max.z], + [max.x, max.y, max.z], + ] + const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] + const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY] + const v = new Vector3() + for (const c of corners) { + v.set(c[0], c[1], c[2]).applyEuler(euler) + lo[0] = Math.min(lo[0], v.x) + lo[1] = Math.min(lo[1], v.y) + lo[2] = Math.min(lo[2], v.z) + hi[0] = Math.max(hi[0], v.x) + hi[1] = Math.max(hi[1], v.y) + hi[2] = Math.max(hi[2], v.z) + } + return { + size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]], + offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2], + } +} + +/** + * Ghost-preview duplicate / move tool for duct fittings (elbow / tee / + * reducer / transition). + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is + * inserted into the scene until the commit click. A translucent copy of the + * fitting (built from its real geometry, at its own `rotation`, so an elbow + * / riser stays properly aligned) rides the cursor inside a footprint + * bounding box — the same affordance other items get — and Figma-style + * alignment guides snap the box edges to nearby geometry. The commit click + * calls `createNode`; Esc discards. + * + * **Move** (existing fitting): the real node is hidden while the ghost + box + * track the cursor; commit writes the new `position` and reveals it. + * + * Wired via `def.affordanceTools.move`. + */ +export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const fitting = node as DuctFittingNode + const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3 + const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3 + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [cursorPos, setCursorPos] = useState(originalPosition) + + // Translucent stand-in built from the fitting's real geometry. Rotation is + // a geometry input (it decides the elbow's profile roles), so the ghost + // matches what lands. Rebuilt only if the source changes. + const ghost = useMemo(() => { + const group = buildDuctFittingGeometry(fitting) + group.traverse((obj) => { + const mesh = obj as Mesh + if ((mesh as { isMesh?: boolean }).isMesh) { + mesh.material = new MeshBasicMaterial({ + color: GHOST_COLOR, + transparent: true, + opacity: GHOST_OPACITY, + depthTest: false, + }) + mesh.renderOrder = 999 + } + obj.layers.set(EDITOR_LAYER) + }) + return group + }, [fitting]) + + // Footprint box that wraps the oriented geometry (size + centre offset), + // measured once from the ghost. + const bounds = useMemo(() => { + const box = new Box3().setFromObject(ghost) + if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 } + return rotatedBounds(box, rotation) + }, [ghost, rotation]) + + useEffect(() => { + return () => { + ghost.traverse((obj) => { + const mesh = obj as Mesh + if ((mesh as { isMesh?: boolean }).isMesh) { + mesh.geometry?.dispose?.() + const mat = mesh.material as Material | Material[] + if (Array.isArray(mat)) for (const m of mat) m.dispose?.() + else mat?.dispose?.() + } + }) + } + }, [ghost]) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2] + const [ox, , oz] = bounds.offset + + useScene.temporal.getState().pause() + let committed = false + let hasMoved = false + const activatedAt = Date.now() + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing fitting: hide its 3D MESH imperatively (NOT the + // store `visible` flag — the 2D floor plan skips `visible:false` nodes, + // so a store hide makes it vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + let lastPos: Vec3 = originalPosition + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + const snap = bypass ? (v: number) => v : snapToGridStep + let x = snap(event.localPosition[0]) + let z = snap(event.localPosition[2]) + + // Alignment: snap the footprint box edges onto nearby geometry and + // publish guides (Alt / Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: x + ox - hx, + maxX: x + ox + hx, + minZ: z + oz - hz, + maxZ: z + oz + hz, + } + const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + x += dx + z += dz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + + const next: Vec3 = [x, originalPosition[1], z] + if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap') + lastPos = next + hasMoved = true + setCursorPos(next) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAt < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMoved) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = DuctFittingNode.parse({ + ...(node as Record), + position: lastPos, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + useScene.getState().updateNode(nodeId, { position: lastPos } as Partial) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [bounds, isNew, node, originalPosition]) + + return ( + + + + + ) +} + +export default MoveDuctFittingTool diff --git a/packages/nodes/src/duct-fitting/parametrics.ts b/packages/nodes/src/duct-fitting/parametrics.ts new file mode 100644 index 00000000..62ca78fe --- /dev/null +++ b/packages/nodes/src/duct-fitting/parametrics.ts @@ -0,0 +1,293 @@ +import { + type AnyNode, + type AnyNodeId, + type DuctSegmentNode, + type ParametricDescriptor, + useScene, +} from '@pascal-app/core' +import { Vector3 } from 'three' +import { + ductPortDiameterIn, + equivalentDiameterIn, + ovalEquivalentDiameterIn, + rollToContinueAcrossElbow, +} from '../duct-segment/geometry' +import { getDuctFittingPorts } from './ports' +import type { DuctFittingNode } from './schema' + +/** Schema bounds for `diameter` / `diameter2`. */ +const clampDiameter = (d: number) => Math.min(48, Math.max(2, d)) + +/** A duct endpoint sitting this close to a collar counts as mated. */ +const MATE_TOL_M = 0.03 + +type DuctMate = { duct: DuctSegmentNode; endIndex: number } + +/** + * Ducts whose endpoint sits ON one of the fitting's collars, keyed by + * port id. Auto-minted joints place duct ends exactly on the collar, so + * a tight distance check is enough — no connectivity graph yet. + */ +function matedDucts(fitting: DuctFittingNode): Map { + const mates = new Map() + const ports = getDuctFittingPorts(fitting) + for (const node of Object.values(useScene.getState().nodes)) { + if (node.type !== 'duct-segment') continue + const duct = node as DuctSegmentNode + for (const endIndex of [0, duct.path.length - 1]) { + const p = duct.path[endIndex] + if (!p) continue + for (const port of ports) { + if (mates.has(port.id)) continue + const dx = p[0] - port.position[0] + const dy = p[1] - port.position[1] + const dz = p[2] - port.position[2] + if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) { + mates.set(port.id, { duct, endIndex }) + } + } + } + } + return mates +} + +export const ductFittingParametrics: ParametricDescriptor = { + // 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 + // fitting lands flush instead of at schema defaults. The tee branch + // follows its own mated duct (or the run shape when nothing is mated); + // `shape2` stays editable afterwards for mixed taps. Rect profiles + // also write their area-equivalent round size back into `diameter` / + // `diameter2`, which drive leg lengths + advertised ports — without + // this the legs keep the stale round size. + derive: (next, patch) => { + const out: Partial = {} + if ('shape' in patch && next.fittingType !== 'reducer') { + // `next` still carries the pre-edit diameters, so its ports sit + // where the mated ducts end — size off the actual neighbours. + const mates = matedDucts(next) + const run = (mates.get('inlet') ?? mates.get('outlet'))?.duct + if (next.shape !== 'round' && run?.shape === next.shape) { + out.width = run.width + out.height = run.height + } else if (next.shape === 'round' && run && run.shape !== 'rect') { + // Oval runs present their area-equivalent round size. + out.diameter = clampDiameter(ductPortDiameterIn(run)) + } + if (next.fittingType === 'tee' || next.fittingType === 'cross') { + // A cross's two branches share one profile — size off whichever + // branch leg has a duct mated (both halves are the same run). + const branchDuct = (mates.get('branch') ?? mates.get('branch2'))?.duct + out.shape2 = branchDuct?.shape ?? next.shape + if (branchDuct && branchDuct.shape !== 'round') { + out.width2 = branchDuct.width + out.height2 = branchDuct.height + } else if (branchDuct) { + out.diameter2 = clampDiameter(ductPortDiameterIn(branchDuct)) + } + } + } + // Non-round legs write their area-equivalent round size back into the + // diameters (leg lengths + advertised ports). A transition's inlet is + // always the rect end regardless of `shape`. + const runShape = next.fittingType === 'transition' ? 'rect' : next.shape + if (runShape !== 'round' && next.fittingType !== 'reducer') { + const equivalent = runShape === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn + out.diameter = clampDiameter(equivalent(out.width ?? next.width, out.height ?? next.height)) + } + const shape2 = out.shape2 ?? next.shape2 + if ((next.fittingType === 'tee' || next.fittingType === 'cross') && shape2 !== 'round') { + const equivalent2 = shape2 === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn + out.diameter2 = clampDiameter( + equivalent2(out.width2 ?? next.width2, out.height2 ?? next.height2), + ) + } + return out + }, + + // Resizing a fitting moves its collars (leg lengths follow the + // diameters) — re-trim each mated duct's endpoint onto the collar's + // new position so metal keeps meeting metal instead of overlapping + // one neighbour and gapping off another. + reconcile: (prev, next) => { + const updates: Array<{ id: AnyNodeId; data: Partial }> = [] + const newPorts = new Map(getDuctFittingPorts(next).map((p) => [p.id, p])) + const mates = matedDucts(prev) + for (const [portId, mate] of mates) { + const target = newPorts.get(portId) + if (!target) continue + const end = mate.duct.path[mate.endIndex] + if (!end) continue + const data: Partial = {} + const dx = end[0] - target.position[0] + const dy = end[1] - target.position[1] + const dz = end[2] - target.position[2] + if (dx * dx + dy * dy + dz * dz >= 1e-12) { + const path = mate.duct.path.map((p) => [...p] as [number, number, number]) + path[mate.endIndex] = [...target.position] + data.path = path + } + // Steep rect / oval runs also re-derive their cross-section roll + // so a riser's profile stays continuous through the fitting (same + // continuity the draw tool computes; runs flipped to rect after + // drawing never got it). Horizontal runs are left alone — their + // 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) { + 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 }) + } + return updates + }, + groups: [ + { + label: 'Fitting', + fields: [ + { + key: 'fittingType', + kind: 'enum', + options: ['elbow', 'tee', 'cross', 'reducer', 'transition'], + display: 'segmented', + }, + { + key: 'angle', + kind: 'number', + unit: '°', + min: 15, + max: 90, + step: 15, + visibleIf: (n) => n.fittingType === 'elbow', + }, + { + key: 'branchAngle', + kind: 'number', + unit: '°', + min: 45, + max: 135, + step: 15, + visibleIf: (n) => n.fittingType === 'tee', + }, + { + key: 'system', + kind: 'enum', + options: ['supply', 'return'], + display: 'segmented', + }, + ], + }, + { + label: 'Connections', + fields: [ + { + key: 'shape', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + // Reducers are always round; a transition's ends are fixed + // (rect inlet, round outlet) so there's nothing to pick. + visibleIf: (n) => n.fittingType !== 'reducer' && n.fittingType !== 'transition', + }, + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 4, + max: 24, + step: 1, + // Hidden when the run legs are rect / oval (transition's inlet + // always is) — `diameter` is then derived as the area equivalent. + visibleIf: (n) => + n.fittingType === 'reducer' || (n.fittingType !== 'transition' && n.shape === 'round'), + }, + { + key: 'width', + kind: 'number', + unit: 'in', + min: 4, + max: 60, + step: 1, + visibleIf: (n) => + n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'), + }, + { + key: 'height', + kind: 'number', + unit: 'in', + min: 3, + max: 40, + step: 1, + visibleIf: (n) => + n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'), + }, + { + key: 'shape2', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + visibleIf: (n) => n.fittingType === 'tee' || n.fittingType === 'cross', + }, + { + key: 'diameter2', + kind: 'number', + unit: 'in', + min: 4, + max: 24, + step: 1, + visibleIf: (n) => + n.fittingType !== 'elbow' && + (n.fittingType !== 'tee' || n.shape2 === 'round') && + (n.fittingType !== 'cross' || n.shape2 === 'round'), + }, + { + key: 'width2', + kind: 'number', + unit: 'in', + min: 4, + max: 60, + step: 1, + visibleIf: (n) => + (n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round', + }, + { + key: 'height2', + kind: 'number', + unit: 'in', + min: 3, + max: 40, + step: 1, + visibleIf: (n) => + (n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round', + }, + { + key: 'ductMaterial', + kind: 'enum', + options: ['sheet-metal', 'flex', 'duct-board'], + }, + ], + }, + { + label: 'Placement', + fields: [ + { key: 'position', kind: 'vec3' }, + { key: 'rotation', kind: 'vec3' }, + ], + }, + ], +} diff --git a/packages/nodes/src/duct-fitting/ports.ts b/packages/nodes/src/duct-fitting/ports.ts new file mode 100644 index 00000000..f5b2cd66 --- /dev/null +++ b/packages/nodes/src/duct-fitting/ports.ts @@ -0,0 +1,147 @@ +import type { NodePort } from '@pascal-app/core' +import { Euler, Vector3 } from 'three' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { DuctFittingNode } from './schema' + +/** + * 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 + * proportionally longer collars, with a floor so 4" fittings stay + * grabbable. + */ +export function fittingLegLength(diameterInches: number): number { + const radius = (diameterInches * INCHES_TO_METERS) / 2 + return Math.max(0.14, radius * 2.5) +} + +type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number } + +/** + * Ports in the fitting's LOCAL frame (origin at the junction center, + * before `position`/`rotation`). Shared by `def.ports` (which transforms + * them to level-local) and the geometry builder (which draws a stub per + * port). + * + * Conventions documented on the schema: elbow inlet -X / outlet turned + * `angle`° in XZ; tee run along X with the branch at `branchAngle`° off + * the +X outlet axis (90° → +Z square tee, 45° → downstream lateral, + * 135° → upstream lateral); reducer -X → +X. + */ +export function localFittingPorts(node: DuctFittingNode): LocalPort[] { + const main = fittingLegLength(node.diameter) + if (node.fittingType === 'elbow') { + const theta = (node.angle * Math.PI) / 180 + const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta)) + return [ + { + id: 'inlet', + position: new Vector3(-main, 0, 0), + direction: new Vector3(-1, 0, 0), + diameter: node.diameter, + }, + { + id: 'outlet', + position: outDir.clone().multiplyScalar(main), + direction: outDir, + diameter: node.diameter, + }, + ] + } + if (node.fittingType === 'tee') { + const branch = fittingLegLength(node.diameter2) + // Branch leans `branchAngle`° off the +X outlet axis in XZ: 90° is a + // square tap (+Z), shallower angles sweep the branch downstream + // toward the outlet so the lateral merges with the run's flow, and + // angles past 90° lean it upstream toward the inlet (cos goes + // negative, swinging the collar to -X). + const phi = (node.branchAngle * Math.PI) / 180 + const branchDir = new Vector3(Math.cos(phi), 0, Math.sin(phi)) + return [ + { + id: 'inlet', + position: new Vector3(-main, 0, 0), + direction: new Vector3(-1, 0, 0), + diameter: node.diameter, + }, + { + id: 'outlet', + position: new Vector3(main, 0, 0), + direction: new Vector3(1, 0, 0), + diameter: node.diameter, + }, + { + id: 'branch', + position: branchDir.clone().multiplyScalar(branch), + direction: branchDir, + diameter: node.diameter2, + }, + ] + } + if (node.fittingType === 'cross') { + // Four-way junction: run inlet -X / outlet +X at the run profile, + // two opposed branches square to the run along ±Z at the branch + // profile. Both branches share `diameter2` (one drawn run passes + // straight through, so its two halves are the same size). + const branch = fittingLegLength(node.diameter2) + return [ + { + id: 'inlet', + position: new Vector3(-main, 0, 0), + direction: new Vector3(-1, 0, 0), + diameter: node.diameter, + }, + { + id: 'outlet', + position: new Vector3(main, 0, 0), + direction: new Vector3(1, 0, 0), + diameter: node.diameter, + }, + { + id: 'branch', + position: new Vector3(0, 0, branch), + direction: new Vector3(0, 0, 1), + diameter: node.diameter2, + }, + { + id: 'branch2', + position: new Vector3(0, 0, -branch), + direction: new Vector3(0, 0, -1), + diameter: node.diameter2, + }, + ] + } + // reducer / transition: straight-through, inlet at `diameter` (the + // transition's rect end advertises its area-equivalent round size), + // outlet at `diameter2`. + return [ + { + id: 'inlet', + position: new Vector3(-main, 0, 0), + direction: new Vector3(-1, 0, 0), + diameter: node.diameter, + }, + { + id: 'outlet', + position: new Vector3(main, 0, 0), + direction: new Vector3(1, 0, 0), + diameter: node.diameter2, + }, + ] +} + +/** `def.ports` — local ports transformed into level-local space. */ +export function getDuctFittingPorts(node: DuctFittingNode): NodePort[] { + const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2]) + const offset = new Vector3(node.position[0], node.position[1], node.position[2]) + return localFittingPorts(node).map((port) => { + const position = port.position.clone().applyEuler(euler).add(offset) + const direction = port.direction.clone().applyEuler(euler).normalize() + return { + id: port.id, + position: [position.x, position.y, position.z] as const, + direction: [direction.x, direction.y, direction.z] as const, + diameter: port.diameter, + system: node.system, + } + }) +} diff --git a/packages/nodes/src/duct-fitting/schema.ts b/packages/nodes/src/duct-fitting/schema.ts new file mode 100644 index 00000000..0c3e44b7 --- /dev/null +++ b/packages/nodes/src/duct-fitting/schema.ts @@ -0,0 +1 @@ +export { DuctFittingNode } from '@pascal-app/core' diff --git a/packages/nodes/src/duct-fitting/selection.tsx b/packages/nodes/src/duct-fitting/selection.tsx new file mode 100644 index 00000000..8b396b67 --- /dev/null +++ b/packages/nodes/src/duct-fitting/selection.tsx @@ -0,0 +1,43 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import { cycleRotationAxis } from '../shared/fitting-rotation' + +/** + * Selection-time rotation support for placed fittings, mounted by the + * editor's SelectionAffordanceManager (`def.affordanceTools.selection`). + * The R/T rotation itself lives in `def.keyboardActions` (the editor's + * keyboard hook dispatches it); this contributes the piece that hook + * can't: **Alt cycles the active rotation axis** while a single fitting + * is selected. The axis lives on `useEditor.rotationAxis`, which the + * floating action menu reads to show the axis pill above the selected + * fitting — so this component renders nothing. + */ +const DuctFittingSelectionAffordance = () => { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const hasSelectedFitting = useScene((s) => { + if (selectedIds.length !== 1) return false + return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting' + }) + + useEffect(() => { + if (!hasSelectedFitting) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Alt' || e.repeat) return + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + e.preventDefault() + 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) + return () => window.removeEventListener('keydown', onKeyDown) + }, [hasSelectedFitting]) + + return null +} + +export default DuctFittingSelectionAffordance diff --git a/packages/nodes/src/duct-fitting/tool.tsx b/packages/nodes/src/duct-fitting/tool.tsx new file mode 100644 index 00000000..a66af593 --- /dev/null +++ b/packages/nodes/src/duct-fitting/tool.tsx @@ -0,0 +1,253 @@ +'use client' + +import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Euler, Quaternion, Vector3 } from 'three' +import { + AXIS_VECTORS, + cycleRotationAxis, + getRotationAxis, + ROTATE_STEP_RAD, +} from '../shared/fitting-rotation' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { + collectScenePorts, + DUCT_PORT_SYSTEMS, + findNearestPortXZ, + type ScenePort, +} from '../shared/ports' +import { ductFittingDefinition } from './definition' +import { buildDuctFittingGeometry } from './geometry' +import { localFittingPorts } from './ports' + +/** Snap radius (meters, XZ) for mating onto an existing port. */ +const PORT_SNAP_RADIUS_M = 0.5 +const PREVIEW_OPACITY = 0.55 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +type Placement = { + position: [number, number, number] + rotation: [number, number, number] + snapPort: ScenePort | null +} + +/** + * Resolve where the fitting would land for a cursor at `raw`: + * - Near an existing port → mate: orientation aligns the inlet onto + * the port (plus the user's manual R/T rotation, pivoting around + * the inlet collar so it stays on the port while the body sweeps). + * - Otherwise → grid-snapped free placement on the floor, manual + * rotation only. + */ +function resolvePlacement( + raw: [number, number, number], + previewNode: DuctFittingNode, + gridStep: number, + manualQuat: Quaternion, +): Placement { + const port = findNearestPortXZ( + raw, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) + if (port) { + const direction = new Vector3(...port.direction).normalize() + // Local +X must map onto the port's outward direction so the inlet + // (local -X) faces back into the run it's joining. Manual rotation + // composes in the world frame on top of the mate orientation. + const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction) + const final = manualQuat.clone().multiply(mate) + const inlet = localFittingPorts(previewNode)[0]! + const inletWorldOffset = inlet.position.clone().applyQuaternion(final) + const position = new Vector3(...port.position).sub(inletWorldOffset) + const euler = new Euler().setFromQuaternion(final) + return { + position: [position.x, position.y, position.z], + rotation: [euler.x, euler.y, euler.z], + snapPort: port, + } + } + const euler = new Euler().setFromQuaternion(manualQuat) + return { + position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)], + rotation: [euler.x, euler.y, euler.z], + snapPort: null, + } +} + +/** + * Click-place tool for duct fittings (elbow / tee / reducer). + * + * A translucent ghost of the fitting follows the cursor. Within snap + * range of any scene port (duct run ends, other fittings' collars) the + * ghost jumps onto the port — position AND orientation — so one click + * mates the fitting onto the run. + * + * Rotation while placing: **R / T** turn the ghost ±45° around the + * active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge + * above the ghost shows the current axis. When snapped to a port the + * rotation pivots around the inlet collar so the joint stays mated. + * Handlers run in the capture phase so R doesn't also spin whatever + * node happens to be selected. + */ +const DuctFittingTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [placement, setPlacement] = useState(null) + const axis = useEditor((s) => s.rotationAxis) + // Accumulated manual rotation from R/T presses. Ref (not state) so the + // emitter callbacks always read the latest without re-subscribing; a + // placement recompute is triggered explicitly after each change. + const manualQuatRef = useRef(new Quaternion()) + // Last raw cursor position so a key press can recompute the placement + // without waiting for the next mouse move. + const lastRawRef = useRef<[number, number, number] | null>(null) + + // Ghost matches exactly what a click creates (the kind's defaults). + const previewNode = useMemo( + () => DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting' }), + [], + ) + const ghost = useMemo(() => { + const group = buildDuctFittingGeometry(previewNode) + group.traverse((child) => { + // Overlay layer keeps the placement ghost out of the ink / SSGI + // buffers and the thumbnail export, like every other tool preview. + child.layers.set(EDITOR_LAYER) + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const recompute = () => { + const raw = lastRawRef.current + if (!raw) return + setPlacement( + resolvePlacement( + raw, + previewNode, + useEditor.getState().gridSnapStep, + manualQuatRef.current, + ), + ) + } + + const onMove = (event: GridEvent) => { + lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] + recompute() + } + + const onClick = (event: GridEvent) => { + lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] + const { position, rotation } = resolvePlacement( + lastRawRef.current, + previewNode, + useEditor.getState().gridSnapStep, + manualQuatRef.current, + ) + const fitting = DuctFittingNode.parse({ + ...ductFittingDefinition.defaults(), + name: 'Duct fitting', + position, + rotation, + }) + useScene.getState().createNode(fitting, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [fitting.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key === 'r' || key === 'R' || key === 't' || key === 'T') { + // Capture-phase + stopPropagation so the editor's selection-rotate + // R handler doesn't also fire while the placement tool owns R. + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 + const turn = new Quaternion().setFromAxisAngle( + AXIS_VECTORS[getRotationAxis()], + steps * ROTATE_STEP_RAD, + ) + manualQuatRef.current = turn.multiply(manualQuatRef.current) + triggerSFX('sfx:item-rotate') + recompute() + } else if (key === 'Alt' && !e.repeat) { + e.preventDefault() + e.stopPropagation() + cycleRotationAxis() + } + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + } + }, [activeLevelId, previewNode]) + + if (!activeLevelId || !placement) return null + + return ( + + {/* Same ground ring + vertical line + tool-icon badge the duct draw + tool shows in 3D (icon resolved from the active `duct-fitting` + structure-tools entry). In 2D the floorplan overlay draws this for + every tool; in 3D each tool renders its own. */} + + + + + {/* Rotation HUD — active axis + key hints, pinned above the ghost. */} + + {/* Same pill shell as DimensionPill so the placement HUD matches + the drawing / dragging readouts. */} +
+ Axis {axis.toUpperCase()} + + · + + R/T rotate + + · + + ⌥ axis +
+ + {/* Port-snap halo so the user sees the click will mate, not free-place. */} + {placement.snapPort && ( + + + + + )} +
+ ) +} + +export default DuctFittingTool diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts new file mode 100644 index 00000000..9c5e74af --- /dev/null +++ b/packages/nodes/src/duct-segment/definition.ts @@ -0,0 +1,188 @@ +import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core' +import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { buildDuctSegmentFloorplan } from './floorplan' +import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry' +import { ductSegmentParametrics } from './parametrics' +import { DuctSegmentNode } from './schema' + +/** + * Phase 1 of the HVAC node system — round duct segment as a polyline. + * + * Composition: `def.geometry` only. No custom renderer, no per-frame + * system. The framework's `` mounts an empty + * group; `` calls `buildDuctSegmentGeometry` whenever + * the node is dirty and swaps in the cylinder+sphere meshes. + * + * Deferred to later slices: + * - Placement tool (polyline draw UX). + * - Fittings (elbow / tee / reducer) — needs typed ports first. + * - Terminals (registers / diffusers) — needs surface-snapping. + * - Equipment (furnace / air-handler / condenser). + * - Floor-plan rendering. + * - Move / endpoint handles. + * + * The node can be created programmatically today via + * `DuctSegmentNode.parse({ path: [...] })` + `useScene.createNode(...)`. + */ +/** R / T roll step (radians) — 45°, matching the fitting rotate. */ +const ROLL_STEP_RAD = Math.PI / 4 + +/** + * R / T roll a selected rect / oval run's cross-section ±45° around its + * drawn line, so a rectangular trunk can be turned on its side after + * placement. Round runs look identical at any roll, so the action gates + * itself off for them (`appliesTo`) and the editor's default rotation — + * a no-op for a node with no `rotation` field — takes over harmlessly. + */ +function rollDuctSegment(node: AnyNode, steps: 1 | -1): void { + const duct = node as DuctSegmentNode + useScene.getState().updateNode(duct.id, { roll: duct.roll + steps * ROLL_STEP_RAD }) +} + +export const ductSegmentDefinition: NodeDefinition = { + kind: 'duct-segment', + schemaVersion: 1, + schema: DuctSegmentNode, + category: 'utility', + distributionRole: 'run', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [3, 0, 0], + ], + shape: 'rect', + diameter: 6, + width: 14, + height: 8, + ductMaterial: 'flex', + seamDetail: false, + insulated: false, + insulationR: 0.5, + system: 'supply', + roll: 0, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: ductSegmentParametrics, + + // R / T roll a selected rect / oval run ±45° around its drawn line. + // `appliesTo` lets round runs fall through to the editor's default + // (harmless — duct-segment has no `rotation` field). + keyboardActions: { + r: { + appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round', + run: (node) => rollDuctSegment(node, 1), + }, + t: { + appliesTo: (node) => node.type === 'duct-segment' && node.shape !== 'round', + run: (node) => rollDuctSegment(node, -1), + }, + }, + + geometry: buildDuctSegmentGeometry, + geometryKey: (n) => + JSON.stringify([ + n.path, + n.shape, + n.diameter, + n.width, + n.height, + n.roll, + n.ductMaterial, + n.seamDetail, + n.insulated, + n.insulationR, + n.system, + ]), + + // Open run ends as typed ports — directions point outward along the + // path tangent so fittings mate flush. Path coords are already + // level-local, so no transform is needed. + ports: (n) => { + if (n.path.length < 2) return [] + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { + id: 'start', + position: first, + direction: unit(first, second), + diameter: ductPortDiameterIn(n), + system: n.system, + }, + { + id: 'end', + position: last, + direction: unit(last, prev), + diameter: ductPortDiameterIn(n), + system: n.system, + }, + ] + }, + + floorplan: buildDuctSegmentFloorplan, + + // 2D selection-time path-point handles — the floor-plan twin of the 3D + // `affordanceTools.selection` handles. The builder emits an + // `endpoint-handle` per path vertex; this drags the matching point. + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('duct-segment'), + }, + + // Selection-time path-point handles (drag to edit a committed run). + // Editor-only UI (reads gridSnapStep, renders DimensionPill), so it + // mounts via the editor's SelectionAffordanceManager — not `def.system`, + // which the viewer package mounts for the read-only route. + affordanceTools: { + selection: () => import('./selection'), + // Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a + // translucent copy of the run follows the cursor and only lands on the + // commit click — nothing is inserted into the scene before that. + move: () => import('./move-tool'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start segment' }, + { key: 'Click again', label: 'Place it (locked to 45°)' }, + { key: 'Shift', label: 'Free angle' }, + { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, + { key: '[ / ]', label: 'Duct diameter down / up' }, + { key: 'Q', label: 'Round / rect trunk' }, + { key: 'C', label: 'Ceiling / floor height' }, + { key: 'Esc', label: 'Cancel start point' }, + ], + + presentation: { + label: 'Duct', + description: 'HVAC duct run — polyline of round, rect, or flat-oval sections.', + icon: { kind: 'url', src: '/icons/duct.webp' }, + paletteSection: 'structure', + paletteOrder: 90, + }, + + mcp: { + description: + 'An HVAC duct run defined as a polyline — round (branches), rect (trunks/plenums), or flat-oval (tight joist bays). Supply or return, with configurable size, material (incl. spiral seam), and external insulation.', + }, +} diff --git a/packages/nodes/src/duct-segment/floorplan.ts b/packages/nodes/src/duct-segment/floorplan.ts new file mode 100644 index 00000000..26b9cd9f --- /dev/null +++ b/packages/nodes/src/duct-segment/floorplan.ts @@ -0,0 +1,102 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from './geometry' +import type { DuctSegmentNode } from './schema' + +const SUPPLY_CENTERLINE = '#d4825a' +const RETURN_CENTERLINE = '#5a8ad4' +const BODY_COLOR = '#9ca3af' + +/** + * Floor-plan representation of a duct run: the path drawn at the duct's + * real width (plan-unit stroke so it scales with zoom), with a dashed + * centerline tinted by system — orange for supply, blue for return, the + * same hues the 3D tint uses. Vertical risers collapse to a point in + * plan; consecutive duplicate plan points are dropped so they don't + * render zero-length artifacts. + */ +export function buildDuctSegmentFloorplan( + node: DuctSegmentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + // Project to plan, dropping consecutive duplicates (risers). `indexMap[k]` + // is the original path index plan point k came from, so the drag handle + // edits the right vertex. + const points: FloorplanPoint[] = [] + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + // Plan width: rect / oval runs draw at their actual width; round at diameter. + const diameterM = (node.shape === 'round' ? node.diameter : node.width) * INCHES_TO_METERS + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const centerline = node.system === 'supply' ? SUPPLY_CENTERLINE : RETURN_CENTERLINE + + // A pure riser (single plan point) still gets a marker: a circle at + // the duct's diameter so the vertical run is visible in plan. + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'group', + children: [ + { + kind: 'circle', + cx: p[0], + cy: p[1], + r: diameterM / 2, + fill: BODY_COLOR, + stroke: showSelectedChrome && palette ? palette.selectedStroke : centerline, + strokeWidth: 0.02, + opacity: 0.9, + }, + ], + } + } + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points, + stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR, + strokeWidth: diameterM, + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.8, + }, + { + kind: 'polyline', + points, + stroke: centerline, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + strokeDasharray: '5 4', + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: 0.9, + }, + ] + + // Selection chrome: one draggable handle per path vertex (2D twin of the + // 3D selection handles). Routes to the shared `move-path-point` affordance. + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/duct-segment/geometry.ts b/packages/nodes/src/duct-segment/geometry.ts new file mode 100644 index 00000000..313d4354 --- /dev/null +++ b/packages/nodes/src/duct-segment/geometry.ts @@ -0,0 +1,466 @@ +import { + BoxGeometry, + CatmullRomCurve3, + CylinderGeometry, + ExtrudeGeometry, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + Quaternion, + Shape, + SphereGeometry, + TubeGeometry, + Vector3, +} from 'three' +import type { DuctSegmentNode } from './schema' + +export const INCHES_TO_METERS = 0.0254 +// Insulation wraps the duct in a roughly uniform shell. A strictly physical +// mapping (fiberglass ≈ R-3.2 per inch) makes low R-values nearly invisible +// at screen scale — R-1 would add only ~8 mm over a 15 cm duct. So the shell +// uses a perceptual mapping: a visible base jacket as soon as insulation is +// non-zero, plus a clear per-R increment. Anchored so R-8 still lands near +// the real-world ~3" jacket. +const INSULATION_BASE_IN = 0.5 +const INSULATION_INCHES_PER_R = 0.3125 +function pickInsulationThickness(r: number): number { + if (r <= 0) return 0 + return (INSULATION_BASE_IN + r * INSULATION_INCHES_PER_R) * INCHES_TO_METERS +} + +// Supply/return tint — kept only for the spiral seam ridge accent; the duct +// body itself is plain white (see createDuctMaterial). +const SUPPLY_COLOR = '#d4825a' +const RETURN_COLOR = '#5a8ad4' + +const RADIAL_SEGMENTS = 24 + +const UP = new Vector3(0, 1, 0) + +/** + * Area-equivalent round diameter (inches) for a rect cross-section — + * what a rect trunk advertises on its ports so round fittings / branches + * mate at a sensible size. + */ +export function equivalentDiameterIn(widthIn: number, heightIn: number): number { + return 2 * Math.sqrt((widthIn * heightIn) / Math.PI) +} + +/** + * Area-equivalent round diameter (inches) for a flat-oval cross-section: + * a rectangle of (width − height) × height plus the two semicircular caps. + */ +export function 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) +} + +/** The diameter (inches) a duct segment presents at its ports. */ +export function ductPortDiameterIn(node: { + shape?: 'round' | 'rect' | 'oval' + diameter: number + width?: number + height?: number +}): 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 +} + +/** + * Cross-section axes for a rect run along `dir`, rolled `roll` radians + * about the run direction. At roll 0: width is the horizontal axis + * (UP × dir) and height the vertical one — vertical runs, where that + * cross product degenerates, fall back to world X/Z. `roll` rotates the + * pair in the plane perpendicular to `dir`, letting a riser carry the + * orientation of the run it turned off instead of the bare fallback. + */ +export function rectSectionAxes(dir: Vector3, roll = 0): { width: Vector3; height: Vector3 } { + const d = dir.clone().normalize() + const xBase = new Vector3().crossVectors(UP, d) + if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0) + xBase.normalize() + const zBase = new Vector3().crossVectors(xBase, d) + const c = Math.cos(roll) + const s = Math.sin(roll) + const width = xBase.clone().multiplyScalar(c).addScaledVector(zBase, s) + const height = xBase.clone().multiplyScalar(-s).addScaledVector(zBase, c) + return { width, height } +} + +/** + * Roll (radians) that keeps a rect cross-section continuous across an + * elbow: the dimension lying along the joint's hinge — the bend-plane + * normal `portDir × newDir`, perpendicular to both legs — must stay on + * the same physical face on the new run as on the source run. Returns 0 + * for an in-plane (degenerate-normal) joint, so horizontal turns keep + * the natural width-horizontal orientation. + */ +export function rollToContinueAcrossElbow( + sourceDir: Vector3, + sourceRoll: number, + portDir: Vector3, + newDir: Vector3, +): number { + const n = new Vector3().crossVectors(portDir, newDir) + if (n.lengthSq() < 1e-8) return 0 + n.normalize() + const src = rectSectionAxes(sourceDir, sourceRoll) + const carriesWidth = Math.abs(src.width.dot(n)) >= Math.abs(src.height.dot(n)) + const d = newDir.clone().normalize() + const xBase = new Vector3().crossVectors(UP, d) + if (xBase.lengthSq() < 1e-8) xBase.set(1, 0, 0) + xBase.normalize() + const zBase = new Vector3().crossVectors(xBase, d) + // Place the hinge-aligned face on the same axis the source carries it. + return carriesWidth + ? Math.atan2(n.dot(zBase), n.dot(xBase)) + : Math.atan2(-n.dot(xBase), n.dot(zBase)) +} + +/** + * Rect box spanning `start`→`end`. Orientation comes from `rectSectionAxes` + * (width horizontal, height vertical by default; `roll` reorients a riser + * to stay continuous through its elbow). Quaternion from an explicit basis + * — the minimal-rotation `setFromUnitVectors` used for cylinders would roll + * the cross-section on axis-aligned runs. + */ +export function buildRectSection( + start: Vector3, + end: Vector3, + widthM: number, + heightM: number, + material: MeshStandardMaterial, + name: string, + roll = 0, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + + const { width: x, height: z } = rectSectionAxes(dir, roll) + + const geom = new BoxGeometry(widthM, length, heightM) + const mesh = new Mesh(geom, material) + mesh.name = name + mesh.position.copy(start).addScaledVector(dir, length / 2) + mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))) + return mesh +} + +/** + * Flat-oval (stadium) profile in the XY plane: width along X, height + * along Y, flat top/bottom joined by semicircular end caps of the height. + * Degenerates to a circle when width ≤ height. + */ +function stadiumShape(widthM: number, heightM: number): Shape { + const r = Math.min(widthM, heightM) / 2 + const straight = Math.max(0, widthM - heightM) / 2 + const shape = new Shape() + shape.absarc(straight, 0, r, -Math.PI / 2, Math.PI / 2, false) + shape.absarc(-straight, 0, r, Math.PI / 2, (3 * Math.PI) / 2, false) + shape.closePath() + return shape +} + +/** + * Centered flat-oval prism with the same local axes as the rect box + * (X = width, Y = run length, Z = height), so sections and previews + * orient it with the `rectSectionAxes` basis. + */ +export function createOvalSectionGeometry( + widthM: number, + heightM: number, + lengthM: number, +): ExtrudeGeometry { + const geom = new ExtrudeGeometry(stadiumShape(widthM, heightM), { + depth: lengthM, + bevelEnabled: false, + curveSegments: RADIAL_SEGMENTS / 2, + }) + geom.translate(0, 0, -lengthM / 2) + geom.rotateX(-Math.PI / 2) + return geom +} + +/** + * Flat-oval section spanning `start`→`end` — the oval counterpart of + * `buildRectSection`, sharing its orientation basis and roll semantics. + */ +export function buildOvalSection( + start: Vector3, + end: Vector3, + widthM: number, + heightM: number, + material: MeshStandardMaterial, + name: string, + roll = 0, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + + const { width: x, height: z } = rectSectionAxes(dir, roll) + + const mesh = new Mesh(createOvalSectionGeometry(widthM, heightM, length), material) + mesh.name = name + mesh.position.copy(start).addScaledVector(dir, length / 2) + mesh.quaternion.copy(new Quaternion().setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))) + return mesh +} + +/** + * Cylinder spanning `start`→`end` at `radius`. Shared by the segment and + * fitting builders — fittings are just short sections + a junction. + */ +export function buildSection( + start: Vector3, + end: Vector3, + radius: number, + material: MeshStandardMaterial, + name: string, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + + // Capped, front-side-only — ducts should read as solid metal tubes, + // not hollow open-ended shells. + const geom = new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false) + const mesh = new Mesh(geom, material) + mesh.name = name + mesh.position.copy(start).addScaledVector(dir, length / 2) + mesh.quaternion.setFromUnitVectors(UP, dir) + return mesh +} + +/** + * Helical ridge wound around the cylinder spanning `start`→`end` at the + * given `pitch` (meters of run per turn) and `ridge` tube radius. The + * ridge sits centered on the body surface, so half its thickness reads + * as raised. Two construction details share this: the spiral duct's + * lock seam (long pitch, thin ridge) and the flex duct's wire helix + * (tight pitch, fat ridge → corrugated look). + */ +function buildHelixRidge( + start: Vector3, + end: Vector3, + radius: number, + pitch: number, + ridge: number, + material: MeshStandardMaterial, + name: string, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + + const turns = length / pitch + const { width: u, height: v } = rectSectionAxes(dir) + const samples = Math.min(4096, Math.max(8, Math.ceil(turns * 12))) + const pts: Vector3[] = [] + for (let i = 0; i <= samples; i++) { + const t = i / samples + const theta = 2 * Math.PI * turns * t + pts.push( + start + .clone() + .addScaledVector(dir, t * length) + .addScaledVector(u, radius * Math.cos(theta)) + .addScaledVector(v, radius * Math.sin(theta)), + ) + } + const geom = new TubeGeometry(new CatmullRomCurve3(pts), samples, ridge, 6, false) + const mesh = new Mesh(geom, material) + mesh.name = name + return mesh +} + +/** + * Helix parameters for a construction material's body detail, or null + * for materials with a smooth body. Spiral: the machine seam keeps a + * roughly constant helix angle, so pitch scales with the diameter. + * Flex: the wire helix is tight and reads as corrugation; its pitch + * also follows the diameter but is clamped much lower. + */ +function helixRidgeFor( + ductMaterial: DuctAppearance['ductMaterial'], + radius: number, +): { pitch: number; ridge: number; color: string } | null { + if (ductMaterial === 'spiral') { + return { + pitch: Math.min(0.3, Math.max(0.08, radius * 1.2)), + ridge: Math.min(0.006, Math.max(0.002, radius * 0.06)), + color: '#9b9b9b', + } + } + if (ductMaterial === 'flex') { + return { + pitch: Math.min(0.06, Math.max(0.025, radius * 0.5)), + ridge: Math.min(0.009, Math.max(0.004, radius * 0.12)), + color: '#737373', + } + } + return null +} + +type DuctAppearance = { + ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board' + system: 'supply' | 'return' +} + +function getSystemTint(node: DuctAppearance): string { + return node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR +} + +/** + * Standard duct body material — a plain white matte finish so runs and + * fittings read like walls / other building elements rather than tinted + * metal. Shared with the fitting builder so connected runs and junctions + * look like one piece. + */ +export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial { + return new MeshStandardMaterial({ + color: '#ffffff', + metalness: 0, + roughness: 0.7, + }) +} + +/** + * Pure geometry builder for a round duct segment polyline. + * + * Strategy: + * - For every consecutive pair of path points, build a cylinder of the + * duct's inner diameter. + * - Drop a sphere of the same radius at every interior joint to cap the + * corner smoothly (no mitering yet — fittings come in a later slice). + * - When insulation is non-zero, repeat the same pattern at a larger + * radius using a translucent shell material. + * + * All children are returned in level-local meters; the framework's + * `` handles the node-level transform (currently + * identity since the schema has no position field — the path itself is + * absolute within the level). + */ +export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const isRect = node.shape === 'rect' + const isOval = node.shape === 'oval' + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const widthM = node.width * INCHES_TO_METERS + const heightM = node.height * INCHES_TO_METERS + const ductMaterial = createDuctMaterial(node) + + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + const addRun = ( + half: number, + rectW: number, + rectH: number, + material: MeshStandardMaterial, + namePrefix: string, + endInsetM = 0, + ) => { + for (let i = 0; i < points.length - 1; i++) { + // Loop bounds + min(2) on the schema guarantee both points exist. + let a = points[i] as Vector3 + let b = points[i + 1] as Vector3 + // Pull the run's open ends in so this shell's end faces never sit + // coplanar with the duct's own end caps (z-fighting). Clamped so + // a short section can't invert. + if (endInsetM > 0) { + const dir = new Vector3().subVectors(b, a) + const length = dir.length() + if (length < 1e-6) continue + dir.divideScalar(length) + const inset = Math.min(endInsetM, length * 0.25) + if (i === 0) a = a.clone().addScaledVector(dir, inset) + if (i === points.length - 2) b = b.clone().addScaledVector(dir, -inset) + } + const mesh = isRect + ? buildRectSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll) + : isOval + ? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll) + : buildSection(a, b, half, material, `${namePrefix}-section-${i}`) + if (mesh) group.add(mesh) + } + // Joint caps at interior points only (skip first and last — they're + // open ends; equipment / terminal / fitting collars cap them). Rect + // joints are cubes spanning the cross-section (oval joints the same + // prism in stadium profile); round joints spheres. + for (let i = 1; i < points.length - 1; i++) { + const joint = isRect + ? new Mesh(new BoxGeometry(rectW, rectH, rectW), material) + : isOval + ? new Mesh(createOvalSectionGeometry(rectW, rectH, rectW), material) + : new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material) + joint.name = `${namePrefix}-joint-${i}` + joint.position.copy(points[i] as Vector3) + group.add(joint) + } + } + + addRun(radius, widthM, heightM, ductMaterial, 'duct') + + // Construction body detail: spiral winds its lock seam, flex its wire + // helix (tight pitch — reads as corrugation) over each round section. + // These are round-body details, so rect / oval runs render smooth. + const helix = + node.shape === 'round' && node.seamDetail ? helixRidgeFor(node.ductMaterial, radius) : null + if (helix) { + const ridgeMaterial = new MeshStandardMaterial({ + color: helix.color, + metalness: node.ductMaterial === 'flex' ? 0.1 : 0.7, + roughness: node.ductMaterial === 'flex' ? 0.85 : 0.35, + emissive: getSystemTint(node), + emissiveIntensity: 0.08, + }) + for (let i = 0; i < points.length - 1; i++) { + const seam = buildHelixRidge( + points[i] as Vector3, + points[i + 1] as Vector3, + radius, + helix.pitch, + helix.ridge, + ridgeMaterial, + `duct-seam-${i}`, + ) + if (seam) group.add(seam) + } + } + + const insulationThickness = node.insulated ? pickInsulationThickness(node.insulationR) : 0 + if (insulationThickness > 0) { + const insulationMaterial = new MeshStandardMaterial({ + color: '#f0e4c8', + roughness: 1, + metalness: 0, + transparent: true, + opacity: 0.25, + }) + addRun( + radius + insulationThickness, + widthM + insulationThickness * 2, + heightM + insulationThickness * 2, + insulationMaterial, + 'duct-insulation', + 0.01, + ) + } + + return group +} diff --git a/packages/nodes/src/duct-segment/index.ts b/packages/nodes/src/duct-segment/index.ts new file mode 100644 index 00000000..4587f5d8 --- /dev/null +++ b/packages/nodes/src/duct-segment/index.ts @@ -0,0 +1,3 @@ +export { ductSegmentDefinition } from './definition' +export { buildDuctSegmentGeometry } from './geometry' +export { DuctSegmentNode } from './schema' diff --git a/packages/nodes/src/duct-segment/move-tool.tsx b/packages/nodes/src/duct-segment/move-tool.tsx new file mode 100644 index 00000000..8b81a516 --- /dev/null +++ b/packages/nodes/src/duct-segment/move-tool.tsx @@ -0,0 +1,330 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + DuctSegmentNode, + emitter, + type GridEvent, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { Matrix4, Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' +import { rectSectionAxes } from './geometry' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 +const IN_TO_M = 0.0254 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +function pathCenterXZ(path: readonly Vec3[]): [number, number] { + let x = 0 + let z = 0 + for (const p of path) { + x += p[0] + z += p[2] + } + const n = path.length || 1 + return [x / n, z / n] +} + +/** Half the run's cross-section (meters) — the box / footprint padding. */ +function runRadiusM(duct: DuctSegmentNode): number { + if (duct.shape === 'round') return (duct.diameter * IN_TO_M) / 2 + return (Math.max(duct.width, duct.height) * IN_TO_M) / 2 +} + +/** The run's vertical box extent (meters). */ +function runHeightM(duct: DuctSegmentNode): number { + return (duct.shape === 'round' ? duct.diameter : duct.height) * IN_TO_M +} + +/** XZ bounds of a path padded by the run's radius. */ +function pathAabb(path: readonly Vec3[], r: number): Aabb2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const p of path) { + if (p[0] < minX) minX = p[0] + if (p[0] > maxX) maxX = p[0] + if (p[2] < minZ) minZ = p[2] + if (p[2] > maxZ) maxZ = p[2] + } + return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r } +} + +/** + * Ghost-preview duplicate / move tool for duct runs. + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is + * inserted into the scene until the commit click. A translucent ghost of + * the run (cylinders / boxes matching its profile) rides the cursor inside + * a footprint bounding box — the same affordance other items get — and + * Figma-style alignment guides snap the box's edges to nearby geometry. The + * next grid click calls `createNode`; Esc discards. + * + * **Move** (existing run): the real node is hidden while the same ghost + + * box tracks the cursor; the commit click writes the translated `path` and + * reveals it, Esc reveals it unchanged. + * + * Wired via `def.affordanceTools.move`. + */ +export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const duct = node as DuctSegmentNode + const originalPathRef = useRef(duct.path.map((p) => [...p] as Vec3)) + + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [previewPath, setPreviewPath] = useState(originalPathRef.current) + const previewPathRef = useRef(originalPathRef.current) + const hasMovedRef = useRef(false) + const activatedAtRef = useRef(Date.now()) + const prevSnapRef = useRef<[number, number] | null>(null) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const originalPath = originalPathRef.current + const [centerX, centerZ] = pathCenterXZ(originalPath) + const r = runRadiusM(duct) + const baseAabb = pathAabb(originalPath, r) + + useScene.temporal.getState().pause() + let committed = false + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing run: hide its 3D MESH imperatively (NOT the store + // `visible` flag — the 2D floor plan skips `visible:false` nodes, so a + // store hide makes the run vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + const setPreview = (path: Vec3[]) => { + previewPathRef.current = path + setPreviewPath(path) + } + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + const snap = bypass ? (v: number) => v : snapToGridStep + let dx = snap(event.localPosition[0] - centerX) + let dz = snap(event.localPosition[2] - centerZ) + + // Figma-style alignment: snap the run's footprint box edges onto + // nearby geometry and publish the guides (Alt / Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: baseAabb.minX + dx, + maxX: baseAabb.maxX + dx, + minZ: baseAabb.minZ + dz, + maxZ: baseAabb.maxZ + dz, + } + const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + dx += sdx + dz += sdz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + + const cur: [number, number] = [centerX + dx, centerZ + dz] + if ( + !bypass && + (!prevSnapRef.current || + prevSnapRef.current[0] !== cur[0] || + prevSnapRef.current[1] !== cur[1]) + ) { + triggerSFX('sfx:grid-snap') + } + prevSnapRef.current = cur + hasMovedRef.current = true + setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMovedRef.current) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + const finalPath = previewPathRef.current + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = DuctSegmentNode.parse({ + ...(node as Record), + path: finalPath, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [duct, isNew, node]) + + const segments: Array<{ a: Vec3; b: Vec3 }> = [] + for (let i = 0; i < previewPath.length - 1; i++) { + segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! }) + } + + // Footprint box spanning the whole run (axis-aligned), drawn around the + // ghost the same way items get one. Recomputed from the live preview path. + const r = runRadiusM(duct) + const box = pathAabb(previewPath, r) + const boxY = previewPath[0]?.[1] ?? 0 + + return ( + + {segments.map((seg, i) => ( + + ))} + + + ) +} + +/** Translucent stand-in for one duct section — mirrors the draw tool's + * `PreviewSegment` so the ghost matches what actually lands. */ +function GhostSegment({ a, b, duct }: { a: Vec3; b: Vec3; duct: DuctSegmentNode }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + + if (duct.shape !== 'round') { + const w = duct.width * IN_TO_M + const h = duct.height * IN_TO_M + return ( + { + if (!m) return + const { width: x, height: z } = rectSectionAxes(dir, duct.roll) + m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)) + }} + > + + + + ) + } + + const radius = (duct.diameter * IN_TO_M) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default MoveDuctSegmentTool diff --git a/packages/nodes/src/duct-segment/parametrics.ts b/packages/nodes/src/duct-segment/parametrics.ts new file mode 100644 index 00000000..76776b5e --- /dev/null +++ b/packages/nodes/src/duct-segment/parametrics.ts @@ -0,0 +1,173 @@ +import { type DuctFittingNode, type ParametricDescriptor, useScene } from '@pascal-app/core' +import { Vector3 } from 'three' +import { getDuctFittingPorts } from '../duct-fitting/ports' +import { rollToContinueAcrossElbow } from './geometry' +import type { DuctSegmentNode } from './schema' + +/** A run endpoint sitting this close to a collar counts as mated. */ +const MATE_TOL_M = 0.03 + +function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +/** + * Cross-section roll that keeps this run continuous through a fitting + * mated at either endpoint — the same continuity the draw tool computes + * for freshly drawn risers (`rollToContinueAcrossElbow`), recovered here + * for runs whose shape is flipped to rect AFTER they were drawn. Without + * it a riser falls back to the world-axis orientation and its profile + * lands 90° off the elbow it rises from. Returns null when no fitting is + * mated (roll 0 — the natural horizontal orientation — is correct). + */ +function rollFromMatedFitting(duct: DuctSegmentNode): number | null { + if (duct.path.length < 2) return null + const first = duct.path[0]! + const last = duct.path[duct.path.length - 1]! + const ends = [ + { point: first, away: duct.path[1]! }, + { point: last, away: duct.path[duct.path.length - 2]! }, + ] + const tol2 = MATE_TOL_M * MATE_TOL_M + for (const node of Object.values(useScene.getState().nodes)) { + if (node.type !== 'duct-fitting') continue + const fitting = node as DuctFittingNode + if (fitting.fittingType === 'reducer') continue + const ports = getDuctFittingPorts(fitting) + for (const end of ends) { + const mated = ports.find((p) => dist2(end.point, p.position) <= tol2) + if (!mated) continue + // The leg on the far side of the junction is the source the + // profile must stay continuous with: an elbow's other run leg, or + // the tee's run when this duct is the branch. + const source = ports.find((p) => p.id !== mated.id && p.id !== 'branch') + if (!source) continue + const srcDuct = Object.values(useScene.getState().nodes).find( + (n) => + n.type === 'duct-segment' && + n.id !== duct.id && + ((n as DuctSegmentNode).path.length >= 2 + ? dist2((n as DuctSegmentNode).path[0]!, source.position) <= tol2 || + dist2( + (n as DuctSegmentNode).path[(n as DuctSegmentNode).path.length - 1]!, + source.position, + ) <= tol2 + : false), + ) as DuctSegmentNode | undefined + const newDir = new Vector3( + end.away[0] - end.point[0], + end.away[1] - end.point[1], + end.away[2] - end.point[2], + ) + if (newDir.lengthSq() < 1e-10) continue + newDir.normalize() + // Only steep runs are ambiguous (world-axis fallback); a + // horizontal run's roll-0 orientation is already canonical, and + // re-deriving it from a possibly-stale riser roll would corrupt it. + if (Math.abs(newDir.y) < Math.SQRT1_2) continue + const srcRoll = srcDuct && srcDuct.shape !== 'round' ? srcDuct.roll : 0 + const srcDir = new Vector3(...source.direction) + return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir) + } + } + return null +} + +export const ductSegmentParametrics: ParametricDescriptor = { + // Flipping a drawn run to rect / oval recovers the cross-section roll + // the draw tool would have computed — risers re-orient to stay + // continuous through the elbow they turn off instead of snapping to + // the world-axis fallback. Spiral is a round-only construction, so a + // non-round run can never hold it: leaving round (or picking spiral on + // a rect / oval run) falls back to plain sheet metal. + derive: (next, patch) => { + const out: Partial = {} + if (next.ductMaterial === 'spiral' && next.shape !== 'round') { + out.ductMaterial = 'sheet-metal' + } + if ('shape' in patch && next.shape !== 'round') { + const roll = rollFromMatedFitting(next) + if (roll !== null) out.roll = roll + } + return out + }, + groups: [ + { + label: 'Air', + fields: [ + { + key: 'system', + kind: 'enum', + options: ['supply', 'return'], + display: 'segmented', + }, + { + key: 'shape', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + }, + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 4, + max: 24, + step: 1, + visibleIf: (n) => n.shape === 'round', + }, + { + key: 'width', + kind: 'number', + unit: 'in', + min: 4, + max: 60, + step: 1, + visibleIf: (n) => n.shape !== 'round', + }, + { + key: 'height', + kind: 'number', + unit: 'in', + min: 3, + max: 40, + step: 1, + visibleIf: (n) => n.shape !== 'round', + }, + ], + }, + { + label: 'Construction', + fields: [ + { + key: 'ductMaterial', + kind: 'enum', + options: ['sheet-metal', 'spiral', 'flex', 'duct-board'], + }, + { + key: 'seamDetail', + kind: 'boolean', + // Only meaningful where a body detail exists: round spiral + // (lock seam) and round flex (wire corrugation). + visibleIf: (n) => + n.shape === 'round' && (n.ductMaterial === 'spiral' || n.ductMaterial === 'flex'), + }, + { + key: 'insulated', + kind: 'boolean', + }, + { + key: 'insulationR', + kind: 'number', + min: 0, + max: 8, + step: 0.5, + visibleIf: (n) => n.insulated, + }, + ], + }, + ], +} diff --git a/packages/nodes/src/duct-segment/schema.ts b/packages/nodes/src/duct-segment/schema.ts new file mode 100644 index 00000000..2df455db --- /dev/null +++ b/packages/nodes/src/duct-segment/schema.ts @@ -0,0 +1 @@ +export { DuctSegmentNode } from '@pascal-app/core' diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx new file mode 100644 index 00000000..15011eef --- /dev/null +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -0,0 +1,371 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + analyzePortConnectivity, + type DuctSegmentNode, + type PortConnectivity, + pauseSceneHistory, + resolveConnectivityUpdates, + 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, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' + +/** Handle pip radius (meters). */ +const HANDLE_RADIUS = 0.09 +/** Port-snap radius for dragged run endpoints (meters, XZ). */ +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 duct runs: one draggable handle + * per path point. + * + * Handles are PORTALED into the duct's registered scene group so they + * share its exact frame — path coords are node-local, and the level / + * building transform above the group applies to the handles for free. + * Drag raycasts run in world space and convert hits back into the + * group's local frame before writing the path. + * + * Drag model: by default the point is CONSTRAINED to the axis the + * segment was drawn along — a horizontal duct's endpoint slides along + * its own length, a riser's endpoint slides vertically. Holding **Alt** + * releases the constraint into free horizontal-plane movement (at the + * point's height); in free mode dragged run endpoints (first / last + * point) also snap onto nearby typed ports so a loose run can be mated + * onto a fitting after the fact. Holding **Shift** bypasses grid + * snapping in either mode for a perfectly smooth precision drag. + * + * History does the single-undo dance: paused during the drag (the live + * `updateNode` ticks are untracked), then on release the path is + * reverted, history resumed, and the final path applied as one tracked + * change. + */ +const DuctSegmentSelectionAffordance = () => { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const duct = useScene((s) => { + if (selectedIds.length !== 1) return null + const node = s.nodes[selectedIds[0] as AnyNodeId] + return node?.type === 'duct-segment' ? (node as DuctSegmentNode) : null + }) + + // Portal target: the duct's registered group. Resolved with a rAF + // retry because registration happens on the renderer's mount, which + // can land a frame after selection. + const ductId = duct?.id ?? null + const [target, setTarget] = useState(null) + useEffect(() => { + if (!ductId) { + setTarget(null) + return + } + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(ductId as AnyNodeId) ?? null + setTarget((cur) => (cur === next ? cur : next)) + if (!next) frameId = window.requestAnimationFrame(resolve) + } + resolve() + return () => window.cancelAnimationFrame(frameId) + }, [ductId]) + + if (!duct || !target) return null + return createPortal(, target, undefined) +} + +const DuctPointHandles = ({ duct, target }: { duct: DuctSegmentNode; target: Object3D }) => { + const { camera, gl } = useThree() + const unit = useViewer((s) => s.unit) + const [draggingIndex, setDraggingIndex] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + // Set while a drag is live; null otherwise. Holds everything the window + // pointer handlers need so they never read stale React state. + const dragRef = useRef<{ + index: number + initialPath: Point[] + current: Point + cleanup: () => void + // Connectivity snapshot taken at pointer-down: which fittings / ducts are + // mated to this run's endpoints, so they follow as the endpoint moves. + connectivity: PortConnectivity | null + } | 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 + } + + /** + * Signed distance along `axisWorld` (unit, through `anchorWorld`) of the + * point on that line closest to the cursor ray. Null when the ray runs + * (near-)parallel to the axis and the projection is unstable. + */ + 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 + } + + /** World-space position of a local path point. */ + const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2])) + /** Convert a world-space hit back into the duct group's local frame. */ + const toLocal = (world: Vector3): Point => { + const local = target.worldToLocal(world.clone()) + return [local.x, local.y, local.z] + } + + // Follow-updates for fittings / ducts mated to this run's endpoints, given + // the run's live path. Endpoints whose position didn't change resolve to a + // zero delta, so only the dragged endpoint's partner actually moves. + const connectivityUpdatesForPath = ( + connectivity: PortConnectivity | null, + path: Point[], + ): { id: AnyNodeId; data: Partial }[] => { + if (!connectivity) return [] + const preview = { ...(duct as Record), path } as AnyNode + return resolveConnectivityUpdates(connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + + const onHandleDown = (index: number) => (e: ThreeEvent) => { + e.stopPropagation() + const initialPath = duct.path.map((p) => [...p] as Point) + const startPoint = initialPath[index]! + const connectivity = analyzePortConnectivity(duct as AnyNode, useScene.getState().nodes) + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'grabbing' + setDraggingIndex(index) + + const isEndpoint = index === 0 || index === initialPath.length - 1 + + // Axis the segment was drawn along, at this point: from the + // neighbouring path point toward the dragged one. The default drag + // is constrained to this line. + 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() + // World-space anchor + axis, derived once — the constraint line is + // fixed for the whole drag regardless of where the point currently is. + 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 + // Shift = precision: bypass grid snapping for a perfectly smooth + // drag (snap() is a no-op at step 0). + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + let next: Point | null = null + if (event.altKey) { + // Alt = freedom: slide on the horizontal plane at the point's + // height. Endpoints can port-snap here to mate onto a fitting. + 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: duct.id, systems: DUCT_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) + if (port) next = [port.position[0], port.position[1], port.position[2]] + } + } + } else { + // Default: constrained to the axis the segment was drawn along — + // slide the point closer / further along its own line. + 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 = duct.path.map((p, i) => (i === drag.index ? next! : p)) as Point[] + // Drag the run + any fittings mated to the moved endpoint as one batch. + useScene + .getState() + .updateNodes([ + { id: duct.id as AnyNodeId, data: { path } }, + ...connectivityUpdatesForPath(drag.connectivity, path), + ]) + } + + const onUp = () => { + const drag = dragRef.current + if (!drag) return + drag.cleanup() + dragRef.current = null + setDraggingIndex(null) + // Single-undo dance: revert (still paused), resume, re-apply the + // final path — plus any connected fitting moves — as one tracked batch. + const finalPath = drag.initialPath.map((p, i) => + i === drag.index ? drag.current : p, + ) as Point[] + const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath) + // Revert the run AND the followers to their pre-drag state while paused + // so history captures a clean before→after delta. + const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) => + conn.kind === 'rigid-node' + ? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial }] + : [{ id: conn.nodeId, data: { path: conn.startPath } as Partial }], + ) + useScene + .getState() + .updateNodes([ + { id: duct.id as AnyNodeId, data: { path: drag.initialPath } }, + ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), + ]) + resumeSceneHistory(useScene) + const moved = finalPath[drag.index]!.some( + (v, axis) => v !== drag.initialPath[drag.index]![axis], + ) + if (moved) { + useScene + .getState() + .updateNodes([{ id: duct.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates]) + } + } + + 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, connectivity } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } + + return ( + + {duct.path.map((p, i) => { + const active = draggingIndex === i + const hovered = hoverIndex === i + return ( + { + 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} + > + + + + ) + })} + {draggingIndex !== null && + duct.path[draggingIndex] && + (() => { + // Same pill as the draw tool: signed per-axis deltas from the + // drag-start position, dominant axis emphasised. + const point = duct.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 ( + + ({ + key: axis, + prefix: axis.toUpperCase(), + value: deltas[i]!, + signed: true, + }))} + primary={primary} + unit={unit} + /> + + ) + })()} + + ) +} + +export default DuctSegmentSelectionAffordance diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx new file mode 100644 index 00000000..eb8ca57d --- /dev/null +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -0,0 +1,994 @@ +'use client' + +import { + type AnyNode, + DuctSegmentNode, + emitter, + type GridEvent, + getLevelHeight, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { type Group, Matrix4, Vector3 } from 'three' +import { getDuctFittingPorts } from '../duct-fitting/ports' +import { + planCrossAtRunBody, + planElbowAtPort, + planElbowRealign, + planTeeAtRunBody, +} from '../shared/auto-fitting' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { + collectScenePorts, + DUCT_PORT_SYSTEMS, + findNearestPortXZ, + findNearestRunBodyXZ, + findRunBodyCrossingXZ, + type RunBodyHit, + type ScenePort, +} from '../shared/ports' +import { ductSegmentDefinition } from './definition' +import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry' + +/** + * One-segment-at-a-time placement tool for round duct segments. + * + * Mouse-driven model: + * - **First click** anchors the segment start (port snap joins onto an + * existing run / fitting collar). + * - **Second click** commits a two-point duct immediately and re-arms + * the tool — no polyline accumulation, no finish gesture. Chain runs + * by clicking again near the end you just placed (port snap). + * - **Auto-elbow**: when either end snapped onto another RUN's open + * port at an angle (15–90°, vertical turns included), an elbow + * fitting is minted at the joint and the duct pulls back to its + * outlet collar — corners get real fittings instead of butt joints. + * - **Tee tap**: starting OR ending on the SIDE of an existing run + * (centerline snap) splits the trunk, mints a tee at the tap point, + * and the branch leaves square from its collar. + * - **Cross tap**: drawing a run straight THROUGH the side of an + * existing run (interior crossing) splits the trunk, mints a 4-way + * cross at the crossing, and the drawn run continues out the far + * branch — both fittings inherit the trunk's / branch's profile. + * - The in-flight end is angle-locked to the nearest 45° step in XZ + * from the start; Y stays at the start's height. Hold **Shift** to + * release the lock. + * - Hold **Alt** → vertical mode. Cursor XZ locks to the start; + * vertical mouse motion drives Y. Click commits the riser segment. + * - **[ / ]** step the duct diameter through nominal US sizes; the + * ghost preview and the committed node both use it. + * - **C** toggles ceiling-level placement: the start point lands at + * the level's ceiling height (duct top hugging the ceiling) instead + * of the floor. Subsequent points inherit the start's Y as usual. + * - Esc clears an anchored start point. + */ +const PREVIEW_OPACITY = 0.55 +/** + * Nominal US round-duct sizes (inches): 4"–10" in 1" steps, 12"+ in 2" + * steps — matches what flex and rigid round actually ship in. + */ +const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const +/** Snap radius (meters) for joining onto an existing duct's start/end. */ +const ENDPOINT_SNAP_RADIUS_M = 0.5 +/** Snap radius (meters) for tapping the SIDE of an existing run — a tee + * is minted there. Tighter than the port radius so run ends keep + * priority near their last stretch. */ +const BODY_SNAP_RADIUS_M = 0.35 +/** Angle step (radians) for the XZ angle lock — 45°. */ +const ANGLE_STEP_RAD = Math.PI / 4 +/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */ +const ALT_PIXELS_PER_METER = 100 +/** Bounds on Alt-driven Y so a wild fling doesn't fly off. */ +const ALT_Y_MIN_M = -3 +const ALT_Y_MAX_M = 10 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +/** + * Cross-section roll for a new rect run leaving `port` along `newDir`, + * so its profile stays continuous with whatever it joined: a turn + * re-derives the roll through the (future) elbow, a straight + * continuation inherits the source's roll as-is. Sources: a rect run's + * open end, or a rect fitting's open collar (continuity then comes from + * the leg on the far side of the junction and the rect run mated + * there). Null when the port doesn't carry a rect orientation. Shared + * by the ghost preview and the commit so what you see is what lands. + */ +function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | null { + if (!port) return null + const nodes = useScene.getState().nodes + const owner = nodes[port.nodeId] + let srcDir: Vector3 | null = null + let srcRoll = 0 + if ( + (owner?.type === 'hvac-equipment' || owner?.type === 'duct-terminal') && + port.shape && + port.shape !== 'round' + ) { + // The collar mesh is built at the canonical `rectSectionAxes(dir, 0)` + // basis, so it reads as a source run pointing out along the port with + // roll 0 — the new leg rolls to continue that across its turn. + srcDir = new Vector3(...port.direction) + srcRoll = 0 + } else if (owner?.type === 'duct-segment' && owner.shape !== 'round') { + srcDir = new Vector3(...port.direction) + srcRoll = owner.roll + } else if ( + owner?.type === 'duct-fitting' && + owner.shape !== 'round' && + owner.fittingType !== 'reducer' && + owner.fittingType !== 'transition' + ) { + const source = getDuctFittingPorts(owner).find( + (p) => p.id !== port.id && p.id !== 'branch' && p.id !== 'branch2', + ) + if (source) { + srcDir = new Vector3(...source.direction) + const tol2 = 0.03 * 0.03 + for (const n of Object.values(nodes)) { + if (n.type !== 'duct-segment' || n.shape === 'round' || n.path.length < 2) continue + const ends = [n.path[0]!, n.path[n.path.length - 1]!] + if (ends.some((e) => dist2(e, source.position) <= tol2)) { + srcRoll = n.roll + break + } + } + } + } + if (!srcDir) return null + const cross = new Vector3().crossVectors(srcDir, newDir) + if (cross.lengthSq() < 1e-8) return srcRoll + return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir) +} + +/** + * Nearest typed port — duct run ends, fitting collars, anything whose + * kind registers `def.ports` — within snap range of `point` on the XZ + * plane. Y is ignored for the distance check (grid events ride the floor + * while ports hang at duct height); the snap adopts the port's full 3D + * position. The full port is returned so the commit knows what it joined + * (auto-elbow insertion needs the port's direction and owner). + */ +function findNearbyPort(point: [number, number, number]): ScenePort | null { + return findNearestPortXZ( + point, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS }), + ENDPOINT_SNAP_RADIUS_M, + ) +} + +function portPoint(port: ScenePort): [number, number, number] { + return [port.position[0], port.position[1], port.position[2]] +} + +/** Cross-section the tool draws with (and commits onto the node). Oval + * never comes from the Q toggle (round ↔ rect) — it enters by joining + * an existing oval run / fitting collar and continuing its profile. */ +type DraftProfile = { + shape: 'round' | 'rect' | 'oval' + diameter: number + width: number + height: number +} + +/** + * Profile to inherit when the segment start snaps onto `port` — joining + * means continuing that thing: a rect trunk end keeps its W×H, a round + * run / fitting collar keeps its diameter. Equipment and terminal + * collars are round at the port's advertised size. + */ +function inheritProfile(port: ScenePort): DraftProfile | null { + const owner = useScene.getState().nodes[port.nodeId] + if (!owner) return null + if (owner.type === 'duct-segment' || owner.type === 'duct-fitting') { + return { + shape: owner.shape, + diameter: Math.min( + 48, + Math.max(2, owner.type === 'duct-segment' ? owner.diameter : port.diameter), + ), + width: owner.width, + height: owner.height, + } + } + if (owner.type === 'hvac-equipment' || owner.type === 'duct-terminal') { + const defaults = ductSegmentDefinition.defaults() as DraftProfile + // Adopt the collar's cross-section so the run leaves a rect / oval + // plenum as rect / oval (rolled to match in `continuityRollFrom`), + // falling back to round at the advertised diameter. + if (port.shape && port.shape !== 'round') { + return { + shape: port.shape, + diameter: Math.min(48, Math.max(2, port.diameter)), + width: port.width ?? defaults.width, + height: port.height ?? defaults.height, + } + } + return { + shape: 'round', + diameter: Math.min(48, Math.max(2, port.diameter)), + width: defaults.width, + height: defaults.height, + } + } + return null +} + +/** + * Project `raw` onto the nearest of the eight 45° rays emanating from + * `from` in the XZ plane. Y is preserved from `from`. The projection + * keeps the cursor's *distance* along the chosen ray so the user feels + * the segment grow with their mouse motion rather than snap to a fixed + * length. + */ +function projectToAngleLock( + from: [number, number, number], + raw: [number, number, number], +): [number, number, number] { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + // Distance along the chosen ray = projection of raw onto that direction. + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +const DuctSegmentTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const cursorRef = useRef(null) + // Cross-section profile for the next committed segment. Q toggles + // round/rect, [ / ] steps the round diameter, and snapping the start + // onto an existing run / fitting INHERITS that node's profile — so + // continuing a 14×8 trunk keeps drawing 14×8, and branching off a + // round collar keeps its diameter. Seeded from `toolDefaults`. + const [profile, setProfile] = useState(() => { + const defaults = ductSegmentDefinition.defaults() as DraftProfile + const seeded = useEditor.getState().toolDefaults['duct-segment'] as + | Partial + | undefined + return { + shape: seeded?.shape ?? defaults.shape, + diameter: seeded?.diameter ?? defaults.diameter, + width: seeded?.width ?? defaults.width, + height: seeded?.height ?? defaults.height, + } + }) + const [draftPoints, setDraftPoints] = useState>([]) + const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null) + // Ceiling mode (toggle with C): the first point lands at the level's + // ceiling height (duct top hugging the ceiling) instead of the floor. + const [ceilingMode, setCeilingMode] = useState(false) + // When the cursor is within snap range of an existing duct's endpoint we + // surface a brighter indicator and commit at the endpoint's exact coords. + const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + // True while Alt is held with a last point on the draft — drives the + // vertical-cylinder ghost and the cursor HUD label. + const [altActive, setAltActive] = useState(false) + // Mirror into refs so emitter callbacks (closing over the first render's + // setState) read the latest values without re-subscribing. + const draftRef = useRef(draftPoints) + draftRef.current = draftPoints + const cursorPosRef = useRef(cursorPos) + cursorPosRef.current = cursorPos + const profileRef = useRef(profile) + profileRef.current = profile + const ceilingModeRef = useRef(ceilingMode) + ceilingModeRef.current = ceilingMode + // Port the anchored START point snapped onto (null = free placement). + // Read at commit so a turn off an existing run mints an elbow there. + const startPortRef = useRef(null) + // Centerline hit the anchored START point snapped onto (null = none). + // Read at commit so a branch off a trunk's side mints a tee there. + const startBodyRef = useRef(null) + // Anchor captured when Alt is pressed: screen Y at that moment and the + // base elevation (= last point's Y). Cleared on Alt release. + const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) + // Latest mouse clientY from grid:move; used so the Alt anchor knows where + // the cursor was at key-press time. + const lastClientYRef = useRef(null) + + useEffect(() => { + if (!activeLevelId) return + + /** + * Auto-elbow gate: only joints onto another RUN's open end get a + * fitting minted. Ports on fittings / equipment / terminals are + * 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 } } + } + + /** + * 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 + // an angle, an elbow fitting is minted at that joint and the duct is + // pulled back to the elbow's outlet collar — corners get real + // fittings instead of butt joints. + const commitSegment = ( + start: [number, number, number], + end: [number, number, number], + endPort: ScenePort | null = null, + endBody: RunBodyHit | null = null, + ) => { + const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) + if (length < 1e-4) return + const dir: [number, number, number] = [ + (end[0] - start[0]) / length, + (end[1] - start[1]) / length, + (end[2] - start[2]) / length, + ] + + 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, + ) + : null + 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 + // fittings + the new duct. Single undo step. + useScene.getState().applyNodeChanges({ + create: [ + ...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })), + ...(tee + ? [ + { 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 }] : []), + ...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial }] : []), + ...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial }] : []), + ...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial }), + ], + }) + triggerSFX('sfx:item-place') + setDraftPoints([]) + setSnapTarget(null) + startPortRef.current = null + startBodyRef.current = null + altAnchorRef.current = null + setAltActive(false) + } + + // Base Y for a fresh run's first point: floor (0) by default, or just + // below the level's ceiling in ceiling mode so the duct's top hugs the + // ceiling (centerline = ceiling height − radius). + const resolveBaseY = (): number => { + if (!ceilingModeRef.current) return 0 + const ceiling = getLevelHeight( + activeLevelId, + useScene.getState().nodes, + (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, + ) + const p = profileRef.current + const verticalIn = p.shape === 'round' ? p.diameter : p.height + return Math.max(0, ceiling - (verticalIn * 0.0254) / 2) + } + + const resolveSnappedPoint = ( + event: GridEvent, + ): { + point: [number, number, number] + snapped: [number, number, number] | null + port: ScenePort | null + body: RunBodyHit | null + } => { + const last = draftRef.current.at(-1) + // First point of the run: grid-snapped placement at the base Y (floor, + // or ceiling height in ceiling mode). Endpoint snap can still join an + // existing run. + if (!last) { + const baseY = resolveBaseY() + const raw: [number, number, number] = [ + event.localPosition[0], + baseY, + event.localPosition[2], + ] + const step = useEditor.getState().gridSnapStep + const shift = event.nativeEvent?.shiftKey === true + if (event.nativeEvent?.altKey !== true) { + const target = findNearbyPort(raw) + if (target) + return { + point: portPoint(target), + snapped: portPoint(target), + port: target, + body: null, + } + // No open end nearby — try the side of a run (tee tap). Probe + // with a grid-snapped cursor so the tap steps along the duct + // like every other placement; Shift frees it to ride smoothly. + const probe: [number, number, number] = shift + ? raw + : [snap(raw[0], step), baseY, snap(raw[2], step)] + const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M) + if (body) return { point: body.point, snapped: body.point, port: null, body } + } + return { + point: [snap(raw[0], step), baseY, snap(raw[2], step)], + snapped: null, + port: null, + body: null, + } + } + // Subsequent points: angle-locked to 45° from `last` (Shift releases). + // Y stays at `last[1]` — depth changes come from Shift+click risers. + const rawXZ: [number, number, number] = [ + event.localPosition[0], + last[1], + event.localPosition[2], + ] + const shift = event.nativeEvent?.shiftKey === true + const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) + const step = useEditor.getState().gridSnapStep + // Port snap (Alt bypass) — checked against the RAW cursor, not the + // angle-locked projection, so a port slightly off the 45° ray can + // still capture the cursor. Joining beats the lock. + if (event.nativeEvent?.altKey !== true && !shift) { + const target = findNearbyPort(rawXZ) + if (target) + return { point: portPoint(target), snapped: portPoint(target), port: target, body: null } + // No open end nearby — landing on the side of a run taps a tee + // there (mirror of the first-point tee tap). Probe with a + // grid-snapped cursor so the tap steps along the duct instead of + // sliding smoothly (Shift above frees it). Checked against the + // cursor, not the 45° projection, so a slightly-off trunk captures. + const probe: [number, number, number] = [ + snap(rawXZ[0], step), + rawXZ[1], + snap(rawXZ[2], step), + ] + const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M) + if (body) return { point: body.point, snapped: body.point, port: null, body } + } + return { + point: [snap(angled[0], step), angled[1], snap(angled[2], step)], + snapped: null, + port: null, + body: null, + } + } + + /** + * Compute the Alt-mode cursor position: XZ locked to the last point, + * Y driven by how far the mouse has moved vertically on screen since + * Alt was pressed. Returns null if there's no anchor (Alt not active). + */ + const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => { + const anchor = altAnchorRef.current + const last = draftRef.current.at(-1) + if (!anchor || !last) return null + const step = useEditor.getState().gridSnapStep + // Screen +Y points down, so subtract to map "drag up = raise Y". + const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER + const snappedDy = snap(dy, step) + const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) + return [last[0], y, last[2]] + } + + // Resolve the cursor point (port / body / grid / angle snap) and then + // layer Figma-style alignment on top so a run lines up with other runs, + // fittings, and items as it's drawn. Snap is applied for a free point + // (first vertex, or Shift free-angle); an angle-locked continuation shows + // the guide passively without leaving its 45° ray. A port / body snap or + // Alt bypasses alignment entirely. + const resolveAlignedPoint = (event: GridEvent) => { + const r = resolveSnappedPoint(event) + const hasStart = draftRef.current.length > 0 + const shift = event.nativeEvent?.shiftKey === true + const alt = event.nativeEvent?.altKey === true + const point = alignDrawPoint(r.point, { + applySnap: !hasStart || shift, + bypass: alt || r.snapped !== null, + }) + return { ...r, point } + } + + const onMove = (event: GridEvent) => { + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY + if (typeof clientY === 'number') lastClientYRef.current = clientY + // Alt vertical mode wins over the XZ logic. + if (altAnchorRef.current && typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point) { + clearDrawAlignment() + setCursorPos(point) + setSnapTarget(null) + return + } + } + const { point, snapped } = resolveAlignedPoint(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + const start = draftRef.current.at(-1) + // Vertical mode with a start anchored: the click commits the riser + // segment right there. Never falls through to the XZ logic — a + // no-op Alt click (height unchanged) must not place anything. + if (altAnchorRef.current && start) { + const clientY = + (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current + if (typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point && Math.abs(point[1] - start[1]) >= 1e-4) { + commitSegment(start, point) + } + } + return + } + const { point, port, body } = resolveAlignedPoint(event) + if (!start) { + // First click: anchor the segment start, remembering the port or + // run body it snapped to so the commit can mint an elbow / tee. + // Joining a port INHERITS the source's cross-section — continuing + // a rect trunk keeps drawing rect at its W×H, a round collar its + // diameter. Body taps (tee branches) keep the tool's own profile. + triggerSFX('sfx:grid-snap') + startPortRef.current = port + startBodyRef.current = port ? null : body + if (port) { + const inherited = inheritProfile(port) + if (inherited) setProfile(inherited) + } + setDraftPoints([point]) + return + } + // Second click: commit the segment and re-arm. A body hit on the end + // (no end port) taps a tee into that run's side. + commitSegment(start, point, port, port ? null : body) + } + + const enterAltMode = () => { + const last = draftRef.current.at(-1) + if (!last || lastClientYRef.current === null) return + if (altAnchorRef.current) return + altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] } + setAltActive(true) + } + + const exitAltMode = () => { + if (!altAnchorRef.current) return + altAnchorRef.current = null + setAltActive(false) + } + + const stepDiameter = (step: 1 | -1) => { + const sizes = DUCT_DIAMETERS_IN + const current = profileRef.current.diameter + // Nearest catalogue index, then step — handles seeded off-catalogue + // values (e.g. a preset's 7.5") gracefully. + let nearest = 0 + for (let i = 1; i < sizes.length; i++) { + if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i + } + const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]! + if (next === current) return + setProfile((p) => ({ ...p, diameter: next })) + triggerSFX('sfx:grid-snap') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'Alt') { + e.preventDefault() + enterAltMode() + } else if (e.key === '[') { + e.preventDefault() + stepDiameter(-1) + } else if (e.key === ']') { + e.preventDefault() + stepDiameter(1) + } else if (e.key === 'q' || e.key === 'Q') { + e.preventDefault() + setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' })) + triggerSFX('sfx:grid-snap') + } else if (e.key === 'c' || e.key === 'C') { + // Toggle ceiling mode. Only the first point reads the base Y, so + // toggling mid-run is a no-op until the next fresh segment — flip + // it only while unanchored to keep the behaviour predictable. + if (draftRef.current.length > 0) return + e.preventDefault() + setCeilingMode((m) => !m) + triggerSFX('sfx:grid-snap') + } + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + e.preventDefault() + exitAltMode() + } + } + + const onCancel = () => { + clearDrawAlignment() + if (draftRef.current.length === 0) return + markToolCancelConsumed() + setDraftPoints([]) + setCursorPos(null) + setSnapTarget(null) + startPortRef.current = null + startBodyRef.current = null + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + altAnchorRef.current = null + clearDrawAlignment() + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = [] + for (let i = 0; i < draftPoints.length - 1; i++) { + previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! }) + } + const last = draftPoints.at(-1) + if (last && cursorPos) { + previewSegments.push({ a: last, b: cursorPos }) + } + + // Wall-style dimension pill above the cursor: absolute world coords before + // 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 + // Alt-vertical mode, otherwise whichever horizontal axis dominates. A + // trailing Ø readout shows the diameter the next click commits ([ / ]). + const pillParts = cursorPos + ? [ + ...(['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!, + signed: !!last, + })), + ...(profile.shape === 'round' + ? [{ key: 'diameter', prefix: 'Ø', value: profile.diameter * 0.0254, signed: false }] + : [ + { key: 'trunk-w', prefix: 'W', value: profile.width * 0.0254, signed: false }, + { key: 'trunk-h', prefix: 'H', value: profile.height * 0.0254, signed: false }, + ]), + ] + : null + const pillPrimary = + last && cursorPos + ? altActive + ? 'y' + : Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2]) + ? 'x' + : 'z' + : undefined + + return ( + + {/* Cursor marker — the same ground ring + vertical line + tool-icon + badge walls and items show while drawing (icon resolved from the + active `duct-segment` structure-tools entry). The dimension pill + rides just above the cursor. */} + {cursorPos && ( + <> + + {pillParts && ( + + +
+ + {ceilingMode && !last && ( +
+ Ceiling · C to toggle +
+ )} +
+ +
+ )} + + )} + {/* 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 && ( + + + + + )} + {/* Committed point pips */} + {draftPoints.map((p, i) => ( + + + + + ))} + {/* Preview sections */} + {previewSegments.map((seg, i) => ( + + ))} +
+ ) +} + +function PreviewSegment({ + a, + b, + profile, + startPort, +}: { + a: [number, number, number] + b: [number, number, number] + profile: DraftProfile + startPort: ScenePort | null +}) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + + // Rect AND oval ghost as a box — close enough for a translucent guide. + if (profile.shape !== 'round') { + const w = profile.width * 0.0254 + const h = profile.height * 0.0254 + return ( + { + if (!m) return + // Same basis AND roll as the commit will use, so the ghost + // shows the orientation that actually lands. + const roll = continuityRollFrom(startPort, dir) ?? 0 + const { width: x, height: z } = rectSectionAxes(dir, roll) + m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z)) + }} + > + + + + ) + } + + const radius = (profile.diameter * 0.0254) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default DuctSegmentTool diff --git a/packages/nodes/src/duct-terminal/definition.ts b/packages/nodes/src/duct-terminal/definition.ts new file mode 100644 index 00000000..4a960268 --- /dev/null +++ b/packages/nodes/src/duct-terminal/definition.ts @@ -0,0 +1,101 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildDuctTerminalFloorplan } from './floorplan' +import { buildDuctTerminalGeometry } from './geometry' +import { ductTerminalParametrics } from './parametrics' +import { getDuctTerminalPorts } from './ports' +import { DuctTerminalNode } from './schema' + +/** + * Phase 3 of the HVAC node system — duct terminals: supply registers, + * ceiling diffusers, return grilles. The end of the air loop. One typed + * port at the collar (mount-aware direction) so duct runs end onto a + * terminal like any other port. + * + * Composition: `def.geometry` only. Yaw-only rotation — the editor's + * default R-rotate works on a selected terminal. + */ +export const ductTerminalDefinition: NodeDefinition = { + kind: 'duct-terminal', + schemaVersion: 1, + schema: DuctTerminalNode, + category: 'utility', + distributionRole: 'terminal', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + terminalType: 'supply-register', + mount: 'floor', + width: 0.3, + depth: 0.15, + collarShape: 'round', + collarDiameter: 6, + collarWidth: 10, + collarHeight: 6, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'z'], gridSnap: true, portSnap: { systems: ['supply', 'return'] } }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + // A floor register rests on top of whatever slab is under it — the + // generic FloorElevationSystem lifts its mesh Y by the slab's elevation + // so the face sits on the slab surface instead of sinking into it. + // Ceiling / wall mounts derive their Y elsewhere, so `applies` skips them. + floorPlaced: { + footprint: (node) => { + const t = node as DuctTerminalNode + return { dimensions: [t.width, 0, t.depth], rotation: [0, t.rotation, 0] } + }, + applies: (node) => (node as DuctTerminalNode).mount === 'floor', + }, + }, + + parametrics: ductTerminalParametrics, + + geometry: buildDuctTerminalGeometry, + geometryKey: (n) => + JSON.stringify([ + n.terminalType, + n.mount, + n.width, + n.depth, + n.collarShape, + n.collarDiameter, + n.collarWidth, + n.collarHeight, + ]), + + ports: getDuctTerminalPorts, + + floorplan: buildDuctTerminalFloorplan, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place register' }, + { key: 'M', label: 'Mount: floor / ceiling / wall' }, + { key: 'R / T', label: 'Rotate ±45° (floor / ceiling)' }, + { key: 'Shift', label: 'Smooth (no grid snap)' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Register', + description: + 'Duct terminal — supply register, ceiling diffuser, or return grille. Duct runs end at its collar.', + icon: { kind: 'url', src: '/icons/registers.webp' }, + paletteSection: 'structure', + paletteOrder: 93, + }, + + mcp: { + description: + 'A duct terminal (supply register, ceiling diffuser, or return grille) with a single collar port. Mount (floor/ceiling/wall) drives the face orientation and collar direction.', + }, +} diff --git a/packages/nodes/src/duct-terminal/floorplan.ts b/packages/nodes/src/duct-terminal/floorplan.ts new file mode 100644 index 00000000..abadb915 --- /dev/null +++ b/packages/nodes/src/duct-terminal/floorplan.ts @@ -0,0 +1,73 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { terminalSystem } from './ports' +import type { DuctTerminalNode } from './schema' + +const SUPPLY_COLOR = '#d4825a' +const RETURN_COLOR = '#5a8ad4' +const FRAME_STROKE = '#6b7280' +const FACE_FILL = '#e5e7eb' + +/** + * Floor-plan symbol for a duct terminal: the face rectangle (rotated by + * yaw) with the conventional register cross-slats hinted as a single + * mid-line, tinted by system. Wall mounts render the same footprint — + * the face projects to a thin strip, which is close enough for plan + * reading at this stage. + */ +export function buildDuctTerminalFloorplan( + node: DuctTerminalNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const [cx, , cz] = node.position + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + const hw = node.width / 2 + const hd = (node.mount === 'wall' ? 0.06 : node.depth) / 2 + const corner = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos + lz * sin, + cz - lx * sin + lz * cos, + ] + const points: FloorplanPoint[] = [ + corner(-hw, -hd), + corner(hw, -hd), + corner(hw, hd), + corner(-hw, hd), + ] + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const accent = terminalSystem(node) === 'supply' ? SUPPLY_COLOR : RETURN_COLOR + const stroke = showSelectedChrome && palette ? palette.selectedStroke : FRAME_STROKE + + const mid1 = corner(-hw * 0.8, 0) + const mid2 = corner(hw * 0.8, 0) + + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill: FACE_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.025 : 0.015, + opacity: 0.92, + }, + { + kind: 'line', + x1: mid1[0], + y1: mid1[1], + x2: mid2[0], + y2: mid2[1], + stroke: accent, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + opacity: 0.9, + }, + ] + + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cz] }) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/duct-terminal/geometry.ts b/packages/nodes/src/duct-terminal/geometry.ts new file mode 100644 index 00000000..28475dfa --- /dev/null +++ b/packages/nodes/src/duct-terminal/geometry.ts @@ -0,0 +1,105 @@ +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + Group, + Mesh, + MeshStandardMaterial, + Vector3, +} from 'three' +import { createOvalSectionGeometry, INCHES_TO_METERS } from '../duct-segment/geometry' +import { COLLAR_LENGTH, mountQuaternion, terminalSystem } from './ports' +import type { DuctTerminalNode } from './schema' + +const RADIAL_SEGMENTS = 20 + +/** Radial clearance (meters) the collar sleeve carries over the duct's + * nominal cross-section, so a run leaving at the advertised size nests + * inside the sleeve instead of z-fighting its faces. ~5 mm ≈ a slip joint. */ +const COLLAR_CLEARANCE_M = 0.005 + +const FRAME_COLOR = '#e3e5e8' +const SLAT_SUPPLY_COLOR = '#cdd1d6' +const SLAT_RETURN_COLOR = '#aeb4bb' +const COLLAR_COLOR = '#c2c2c2' + +/** + * Pure geometry builder for a duct terminal, in the node's LOCAL frame — + * `` applies `position` + yaw, and the builder + * applies the mount orientation itself. + * + * Canonical (floor) frame before the mount rotation: face plate lying + * in XZ at y=0 with its normal +Y, louver slats just above it, collar + * cylinder going -Y toward the duct side. Ceiling mounts flip it; wall + * mounts stand it up facing +Z. + */ +export function buildDuctTerminalGeometry(node: DuctTerminalNode): Group { + const group = new Group() + const oriented = new Group() + oriented.quaternion.copy(mountQuaternion(node.mount)) + group.add(oriented) + + const frameMaterial = new MeshStandardMaterial({ + color: FRAME_COLOR, + metalness: 0.4, + roughness: 0.5, + }) + const slatMaterial = new MeshStandardMaterial({ + color: terminalSystem(node) === 'return' ? SLAT_RETURN_COLOR : SLAT_SUPPLY_COLOR, + metalness: 0.45, + roughness: 0.55, + }) + + const frameThickness = 0.018 + const frame = new Mesh(new BoxGeometry(node.width, frameThickness, node.depth), frameMaterial) + frame.name = 'terminal-frame' + frame.position.set(0, frameThickness / 2, 0) + oriented.add(frame) + + // Louver slats across the face. Return grilles read denser; diffusers + // get concentric-ish wide slats via the same simple pattern. + const slatCount = node.terminalType === 'return-grille' ? 7 : 4 + const innerDepth = node.depth * 0.82 + const slatDepth = (innerDepth / slatCount) * 0.55 + for (let i = 0; i < slatCount; i++) { + const slat = new Mesh(new BoxGeometry(node.width * 0.86, 0.006, slatDepth), slatMaterial) + slat.name = `terminal-slat-${i}` + const z = -innerDepth / 2 + (innerDepth / slatCount) * (i + 0.5) + slat.position.set(0, frameThickness + 0.002, z) + slat.rotation.x = node.terminalType === 'diffuser' ? 0 : -0.5 + oriented.add(slat) + } + + // Collar runs along -Y from the face toward the duct. Round is a + // cylinder; rect a box; oval the flat-oval prism (its extrude basis + // already puts the run length on Y, matching the collar axis). The + // sleeve is grown one clearance on every side so a duct run leaving at + // the advertised size nests inside it instead of z-fighting its faces. + const grow = 2 * COLLAR_CLEARANCE_M + let collarGeom: BufferGeometry + if (node.collarShape === 'rect') { + collarGeom = new BoxGeometry( + node.collarWidth * INCHES_TO_METERS + grow, + COLLAR_LENGTH, + node.collarHeight * INCHES_TO_METERS + grow, + ) + } else if (node.collarShape === 'oval') { + collarGeom = createOvalSectionGeometry( + node.collarWidth * INCHES_TO_METERS + grow, + node.collarHeight * INCHES_TO_METERS + grow, + COLLAR_LENGTH, + ) + } else { + const radius = (node.collarDiameter * INCHES_TO_METERS + grow) / 2 + collarGeom = new CylinderGeometry(radius, radius, COLLAR_LENGTH, RADIAL_SEGMENTS, 1, false) + } + const collar = new Mesh( + collarGeom, + new MeshStandardMaterial({ color: COLLAR_COLOR, metalness: 0.6, roughness: 0.4 }), + ) + collar.name = 'terminal-collar' + collar.position.copy(new Vector3(0, -COLLAR_LENGTH / 2, 0)) + oriented.add(collar) + + return group +} diff --git a/packages/nodes/src/duct-terminal/index.ts b/packages/nodes/src/duct-terminal/index.ts new file mode 100644 index 00000000..ede0e1c7 --- /dev/null +++ b/packages/nodes/src/duct-terminal/index.ts @@ -0,0 +1,4 @@ +export { ductTerminalDefinition } from './definition' +export { buildDuctTerminalGeometry } from './geometry' +export { getDuctTerminalPorts } from './ports' +export { DuctTerminalNode } from './schema' diff --git a/packages/nodes/src/duct-terminal/parametrics.ts b/packages/nodes/src/duct-terminal/parametrics.ts new file mode 100644 index 00000000..88a02c1e --- /dev/null +++ b/packages/nodes/src/duct-terminal/parametrics.ts @@ -0,0 +1,72 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { DuctTerminalNode } from './schema' + +export const ductTerminalParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Terminal', + fields: [ + { + key: 'terminalType', + kind: 'enum', + options: ['supply-register', 'diffuser', 'return-grille'], + }, + { + key: 'mount', + kind: 'enum', + options: ['floor', 'ceiling', 'wall'], + display: 'segmented', + }, + ], + }, + { + label: 'Face', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 1.5, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.05, max: 1.5, step: 0.05 }, + ], + }, + { + label: 'Collar', + fields: [ + { + key: 'collarShape', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + }, + { + key: 'collarDiameter', + kind: 'number', + unit: 'in', + min: 4, + max: 20, + step: 1, + visibleIf: (n) => n.collarShape === 'round', + }, + { + key: 'collarWidth', + kind: 'number', + unit: 'in', + min: 4, + max: 20, + step: 1, + visibleIf: (n) => n.collarShape !== 'round', + }, + { + key: 'collarHeight', + kind: 'number', + unit: 'in', + min: 3, + max: 20, + step: 1, + visibleIf: (n) => n.collarShape !== 'round', + }, + ], + }, + { + label: 'Placement', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], +} diff --git a/packages/nodes/src/duct-terminal/ports.ts b/packages/nodes/src/duct-terminal/ports.ts new file mode 100644 index 00000000..c53b81f7 --- /dev/null +++ b/packages/nodes/src/duct-terminal/ports.ts @@ -0,0 +1,64 @@ +import type { NodePort } from '@pascal-app/core' +import { Euler, Quaternion, Vector3 } from 'three' +import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry' +import type { DuctTerminalNode } from './schema' + +/** Collar stub length in meters behind the face. */ +export const COLLAR_LENGTH = 0.12 + +/** + * Mount orientation: rotation applied to the canonical floor frame + * (face normal +Y, collar pointing -Y). Ceiling flips it; wall stands + * it up so the face looks along +Z and the collar points -Z (into the + * wall). Yaw is applied on top by the renderer / port transform. + */ +export function mountQuaternion(mount: DuctTerminalNode['mount']): Quaternion { + if (mount === 'ceiling') return new Quaternion().setFromEuler(new Euler(Math.PI, 0, 0)) + if (mount === 'wall') return new Quaternion().setFromEuler(new Euler(Math.PI / 2, 0, 0)) + return new Quaternion() +} + +export function terminalSystem(node: DuctTerminalNode): 'supply' | 'return' { + return node.terminalType === 'return-grille' ? 'return' : 'supply' +} + +/** + * Diameter (inches) the collar advertises at its port. Rect / oval + * collars report the area-equivalent round diameter so round runs mate + * at a sensible size — the same convention duct segments use. + */ +export function collarPortDiameterIn(node: DuctTerminalNode): number { + if (node.collarShape === 'rect') return equivalentDiameterIn(node.collarWidth, node.collarHeight) + if (node.collarShape === 'oval') { + return ovalEquivalentDiameterIn(node.collarWidth, node.collarHeight) + } + return node.collarDiameter +} + +/** + * `def.ports` — the single collar port in level-local space. Canonical + * frame: collar tip at (0, -COLLAR_LENGTH, 0) pointing -Y (away from the + * face); mount + yaw + position transform it. Direction points OUT of + * the terminal — i.e. toward the duct that should connect. + */ +export function getDuctTerminalPorts(node: DuctTerminalNode): NodePort[] { + const transform = new Quaternion() + .setFromEuler(new Euler(0, node.rotation, 0)) + .multiply(mountQuaternion(node.mount)) + const position = new Vector3(0, -COLLAR_LENGTH, 0) + .applyQuaternion(transform) + .add(new Vector3(node.position[0], node.position[1], node.position[2])) + const direction = new Vector3(0, -1, 0).applyQuaternion(transform).normalize() + return [ + { + id: 'collar', + position: [position.x, position.y, position.z] as const, + direction: [direction.x, direction.y, direction.z] as const, + diameter: collarPortDiameterIn(node), + system: terminalSystem(node), + shape: node.collarShape, + width: node.collarWidth, + height: node.collarHeight, + }, + ] +} diff --git a/packages/nodes/src/duct-terminal/schema.ts b/packages/nodes/src/duct-terminal/schema.ts new file mode 100644 index 00000000..f72c87ee --- /dev/null +++ b/packages/nodes/src/duct-terminal/schema.ts @@ -0,0 +1 @@ +export { DuctTerminalNode } from '@pascal-app/core' diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx new file mode 100644 index 00000000..15ca0e64 --- /dev/null +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -0,0 +1,443 @@ +'use client' + +import { + type AnyNodeId, + DuctTerminalNode, + emitter, + pointInPolygon, + resolveLevelId, + sceneRegistry, + useScene, + type WallEvent, +} from '@pascal-app/core' +import { + CursorSphere, + getFloorStackPreviewPosition, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' +import { ductTerminalDefinition } from './definition' +import { buildDuctTerminalGeometry } from './geometry' +import { COLLAR_LENGTH, mountQuaternion } from './ports' + +const PREVIEW_OPACITY = 0.55 +/** R/T yaw step — 45°. */ +const ROTATE_STEP_RAD = Math.PI / 4 +/** Fallback height (meters) for a ceiling node that carries no `height`. */ +const DEFAULT_CEILING_HEIGHT = 2.5 +/** Snap radius (meters) for mating the collar onto a nearby duct port. */ +const PORT_SNAP_RADIUS_M = 0.5 + +type Mount = DuctTerminalNode['mount'] +const MOUNT_CYCLE: Mount[] = ['floor', 'ceiling', 'wall'] + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** + * Collar-port offset from the node origin for a given mount + yaw, in + * level-local meters — the same transform `def.ports` applies, so the + * placement tool can predict where the collar lands and shift the whole + * terminal to mate it onto a duct port. + */ +function collarOffset(mount: Mount, yaw: number): Vector3 { + const transform = new Quaternion() + .setFromEuler(new Euler(0, yaw, 0)) + .multiply(mountQuaternion(mount)) + return new Vector3(0, -COLLAR_LENGTH, 0).applyQuaternion(transform) +} + +/** The active level's mesh, or null. Carries the building transform plus the + * level's stacked elevation — the frame terminals are stored and parented in, + * so cursor hits resolve to true level-local coords on every floor. */ +function activeLevelMesh() { + const levelId = useViewer.getState().selection.levelId + return levelId ? (sceneRegistry.nodes.get(levelId as AnyNodeId) ?? null) : null +} + +type Placement = { + position: [number, number, number] + /** Yaw radians applied to the ghost / committed node. */ + yaw: number + /** Mount the ghost / committed node uses — inferred from the mated port + * when snapped, else the user's manual M selection. */ + mount: Mount + /** True when the collar mated onto a nearby duct port (magnetic snap). */ + snapped?: boolean +} + +/** Direction is "vertical" when its Y component dominates this much. */ +const VERTICAL_DOT = 0.7 + +/** + * Pick the mount that makes a collar mate onto a duct port pointing + * `dir` (the port's outward direction). The collar leaves the face along + * −Y in the canonical frame, so the mount rotation must turn −Y to face + * *into* the port (i.e. opposite `dir`): + * - port pointing up (a riser top) → collar must point down → **floor** + * - port pointing down (a ceiling drop) → collar points up → **ceiling** + * - port horizontal (a wall stub) → **wall**, yawed so the collar runs + * back along the port. `lockYaw` is set only for wall (floor / ceiling + * yaw is free — the user keeps spinning the face with R/T). + */ +function inferMountFromPort(dir: readonly [number, number, number]): { + mount: Mount + lockYaw: number | null +} { + const v = new Vector3(dir[0], dir[1], dir[2]) + if (v.lengthSq() < 1e-8) return { mount: 'floor', lockYaw: null } + v.normalize() + if (v.y > VERTICAL_DOT) return { mount: 'floor', lockYaw: null } + if (v.y < -VERTICAL_DOT) return { mount: 'ceiling', lockYaw: null } + // Wall collar dir after mount + yaw is (−sin yaw, 0, −cos yaw); set it + // opposite the port so the collar runs back into the wall stub. + return { mount: 'wall', lockYaw: Math.atan2(v.x, v.z) } +} + +/** + * If a duct port is within snap range of `position` (XZ — ports hang at + * duct height, the grid hit rides the floor), mate the register onto it: + * the port's direction *picks the mount* (floor / ceiling / wall) and, for + * walls, the yaw; the whole terminal then hops so its collar lands exactly + * on the port. Null when nothing is in range. `fallbackYaw` keeps the + * user's R/T face orientation for floor / ceiling mounts. + */ +function resolvePortSnap( + position: [number, number, number], + fallbackYaw: number, +): { position: [number, number, number]; mount: Mount; yaw: number } | null { + const port = findNearestPortXZ( + position, + collectScenePorts({ systems: DUCT_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) + if (!port) return null + const { mount, lockYaw } = inferMountFromPort(port.direction) + const yaw = lockYaw ?? fallbackYaw + const offset = collarOffset(mount, yaw) + return { + position: [ + port.position[0] - offset.x, + port.position[1] - offset.y, + port.position[2] - offset.z, + ], + mount, + yaw, + } +} + +/** + * Click-place tool for duct terminals (registers / diffusers / grilles). + * + * **Mount drives the target surface** (cycle with **M**): a floor register + * snaps to the floor grid, a ceiling diffuser snaps to a horizontal plane at + * ceiling height (derived from the level's ceilings/walls), and a wall + * register snaps flush onto whichever wall the cursor is over, its face + * oriented along the wall's outward normal. **R / T** rotate the floor/ceiling + * yaw ±45°; wall yaw is fixed by the wall it mates to. + */ +const DuctTerminalTool = () => { + const { camera, gl } = useThree() + const activeLevelId = useViewer((s) => s.selection.levelId) + const [mount, setMount] = useState('floor') + const [placement, setPlacement] = useState(null) + + const mountRef = useRef('floor') + const yawRef = useRef(0) + const raycaster = useRef(new Raycaster()) + const pointer = useRef(new Vector2()) + + // The ghost mirrors whatever mount will actually be committed: a snap can + // override the manual M selection (port direction picks floor / ceiling / + // wall), so the preview must show the inferred mount, not the toolbar one. + const effectiveMount = placement?.mount ?? mount + const previewNode = useMemo( + () => + DuctTerminalNode.parse({ + ...ductTerminalDefinition.defaults(), + name: 'Register', + mount: effectiveMount, + }), + [effectiveMount], + ) + const ghost = useMemo(() => { + const group = buildDuctTerminalGeometry(previewNode) + group.traverse((child) => { + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + const canvas = gl.domElement + + /** + * Intersect the cursor ray with a level-local horizontal plane at `y`. + * The ray is transformed into level-local space first (building transform + * plus the floor's stacked elevation), so the hit is already in the frame + * terminals are stored and parented in — accurate on every floor. + */ + const hitLocalPlane = (nativeEvent: PointerEvent | MouseEvent, y: number): Vector3 | null => { + const rect = canvas.getBoundingClientRect() + pointer.current.x = ((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1 + pointer.current.y = -((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1 + raycaster.current.setFromCamera(pointer.current, camera) + + const level = activeLevelMesh() + const ray = raycaster.current.ray.clone() + if (level) { + const inv = new Matrix4().copy(level.matrixWorld).invert() + ray.applyMatrix4(inv) + } + const plane = new Plane(new Vector3(0, 1, 0), -y) + const hit = new Vector3() + return ray.intersectPlane(plane, hit) ? hit : null + } + + /** + * Ceiling mount only lands where the cursor ray actually hits a real + * ceiling. Walk the active level's ceiling nodes, raycast each against a + * plane at its own height, and keep the lowest one whose polygon (minus + * holes) contains the hit — the surface you'd see looking up. Null when + * the ray misses every ceiling, so a ceiling register never drops onto a + * fixed virtual plane; the height comes from the ceiling itself. + */ + const resolveCeilingHit = ( + nativeEvent: PointerEvent | MouseEvent, + ): { hit: Vector3; height: number } | null => { + const nodes = useScene.getState().nodes + let best: { hit: Vector3; height: number } | null = null + for (const node of Object.values(nodes)) { + if (!node || node.type !== 'ceiling') continue + if (resolveLevelId(node, nodes) !== activeLevelId) continue + const ceiling = node as { + height?: number + polygon: Array<[number, number]> + holes?: Array> + } + const height = ceiling.height ?? DEFAULT_CEILING_HEIGHT + const hit = hitLocalPlane(nativeEvent, height) + if (!hit) continue + if (!pointInPolygon(hit.x, hit.z, ceiling.polygon)) continue + if (ceiling.holes?.some((h) => h.length >= 3 && pointInPolygon(hit.x, hit.z, h))) continue + if (!best || height < best.height) best = { hit, height } + } + return best + } + + const resolvePlanar = (nativeEvent: PointerEvent | MouseEvent): Placement | null => { + // Floor sits on the grid (y=0; the slab lift is applied to the committed + // mesh by FloorElevationSystem). Ceiling resolves the real ceiling the + // ray hits and takes that surface's height — no fixed fallback plane. + let hit: Vector3 | null + let y: number + if (mountRef.current === 'ceiling') { + const ceiling = resolveCeilingHit(nativeEvent) + if (!ceiling) return null + hit = ceiling.hit + y = ceiling.height + } else { + y = 0 + hit = hitLocalPlane(nativeEvent, y) + } + if (!hit) return null + const step = nativeEvent.shiftKey ? 0 : useEditor.getState().gridSnapStep + // Grid-snap, then layer Figma-style alignment so a floor / ceiling + // register lines up with ducts, equipment, and items (Shift = free). + const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], { + applySnap: true, + bypass: nativeEvent.shiftKey === true, + }) + // Magnetic port snap: if a duct run end / fitting collar is in range, + // the port's direction picks the mount (floor / ceiling / wall) and + // hops the whole register so its collar mates exactly onto it. Takes + // precedence over grid / alignment and the manual M mount; Shift + // bypasses. + if (!nativeEvent.shiftKey) { + const mated = resolvePortSnap(position, yawRef.current) + if (mated) { + return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } + } + } + return { position, yaw: yawRef.current, mount: mountRef.current } + } + + const commit = (p: Placement) => { + const terminal = DuctTerminalNode.parse({ + ...ductTerminalDefinition.defaults(), + name: 'Register', + mount: p.mount, + position: p.position, + rotation: p.yaw, + }) + useScene.getState().createNode(terminal, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [terminal.id] }) + triggerSFX('sfx:item-place') + } + + // ---- Floor / ceiling: own raycast against a horizontal plane ---- + const onPointerMove = (e: PointerEvent) => { + if (mountRef.current === 'wall') return + setPlacement(resolvePlanar(e)) + } + + const onCanvasClick = (e: MouseEvent) => { + if (mountRef.current === 'wall') return + if (useViewer.getState().cameraDragging) return + if ((e as PointerEvent).button !== undefined && (e as PointerEvent).button !== 0) return + const p = resolvePlanar(e) + if (p) commit(p) + } + + // ---- Wall: consume wall hover/click events, orient to the wall ---- + const resolveWall = (event: WallEvent): Placement | null => { + if (!event.normal) return null + // Wall faces are the ±Z faces in wall-local space; skip the thin + // top / end caps so the terminal only mounts onto a real face. + if (Math.abs(event.normal[2]) <= 0.7) return null + const worldNormal = new Vector3(event.normal[0], event.normal[1], event.normal[2]) + .applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)) + .normalize() + // Face normal after the wall mount + yaw is (sin yaw, 0, cos yaw); + // align it with the wall's outward world normal. + const yaw = Math.atan2(worldNormal.x, worldNormal.z) + + const world = new Vector3(event.position[0], event.position[1], event.position[2]) + const level = activeLevelMesh() + const local = level ? level.worldToLocal(world.clone()) : world + return { position: [local.x, local.y, local.z], yaw, mount: 'wall' } + } + + const onWallMove = (event: WallEvent) => { + if (mountRef.current !== 'wall') return + // Wall-mounted terminals snap flush to the wall — no plan alignment. + clearDrawAlignment() + const p = resolveWall(event) + if (p) setPlacement(p) + } + + const onWallClick = (event: WallEvent) => { + if (mountRef.current !== 'wall') return + if (useViewer.getState().cameraDragging) return + const p = resolveWall(event) + if (p) commit(p) + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key === 'm' || key === 'M') { + e.preventDefault() + e.stopPropagation() + const next = MOUNT_CYCLE[(MOUNT_CYCLE.indexOf(mountRef.current) + 1) % MOUNT_CYCLE.length]! + mountRef.current = next + setMount(next) + // Wall placement only resolves over a wall; clear the stale ghost. + if (next === 'wall') setPlacement(null) + triggerSFX('sfx:item-rotate') + return + } + if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return + // Wall yaw is dictated by the wall, so R/T only apply to planar mounts. + if (mountRef.current === 'wall') return + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setPlacement((prev) => (prev ? { ...prev, yaw: yawRef.current } : prev)) + triggerSFX('sfx:item-rotate') + } + + canvas.addEventListener('pointermove', onPointerMove) + canvas.addEventListener('click', onCanvasClick) + emitter.on('wall:move', onWallMove) + emitter.on('wall:click', onWallClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('click', onCanvasClick) + emitter.off('wall:move', onWallMove) + emitter.off('wall:click', onWallClick) + window.removeEventListener('keydown', onKeyDown, true) + clearDrawAlignment() + } + }, [activeLevelId, camera, gl]) + + if (!activeLevelId || !placement) return null + + const mountLabel = effectiveMount.charAt(0).toUpperCase() + effectiveMount.slice(1) + + // The committed mesh's slab lift is applied by FloorElevationSystem, but the + // ghost renders here directly — preview it on the slab top too so a floor + // register doesn't appear to sink in before the click. + const previewPosition = + effectiveMount === 'floor' + ? getFloorStackPreviewPosition({ + node: previewNode, + position: placement.position, + rotation: placement.yaw, + levelId: activeLevelId, + }) + : placement.position + + return ( + + {/* Same ground ring + vertical line + tool-icon badge the duct draw + tool shows in 3D (icon resolved from the active `duct-terminal` + structure-tools entry). In 2D the floorplan overlay draws this for + every tool; in 3D each tool renders its own. */} + + + + + +
+ {placement.snapped && ( + <> + Snapped to duct + + · + + + )} + Mount {mountLabel} + + · + + M surface + {effectiveMount !== 'wall' && ( + <> + + · + + R/T rotate + + )} +
+ +
+ ) +} + +export default DuctTerminalTool diff --git a/packages/nodes/src/elevator/definition.ts b/packages/nodes/src/elevator/definition.ts index c2829913..7f505bd3 100644 --- a/packages/nodes/src/elevator/definition.ts +++ b/packages/nodes/src/elevator/definition.ts @@ -260,7 +260,7 @@ export const elevatorDefinition: NodeDefinition = { presentation: { label: 'Elevator', description: 'A multi-level elevator shaft with configurable openings per level.', - icon: { kind: 'url', src: '/icons/wallcut.png' }, + icon: { kind: 'url', src: '/icons/wallcut.webp' }, paletteSection: 'structure', paletteOrder: 80, }, diff --git a/packages/nodes/src/elevator/panel.tsx b/packages/nodes/src/elevator/panel.tsx index 9eb51e3b..09ac7cc9 100644 --- a/packages/nodes/src/elevator/panel.tsx +++ b/packages/nodes/src/elevator/panel.tsx @@ -471,7 +471,7 @@ export default function ElevatorPanel() { return ( = { presentation: { label: 'Eyebrow Vent', description: 'Low curved lens-shaped roof vent with a louvered front.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/eyebrow-vent/panel.tsx b/packages/nodes/src/eyebrow-vent/panel.tsx index d43fd4b8..44934767 100644 --- a/packages/nodes/src/eyebrow-vent/panel.tsx +++ b/packages/nodes/src/eyebrow-vent/panel.tsx @@ -139,7 +139,7 @@ export default function EyebrowVentPanel() { return ( { +const EyebrowVentPreview = ({ node, invalid }: { node: EyebrowVentNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildEyebrowVentGeometry(node), [node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio], @@ -20,17 +22,17 @@ const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.7, metalness: 0.1, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index dfd41f48..eb9bf093 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -55,6 +55,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => : undefined, ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildEyebrowVentGeometry(node), [node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio], diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx index 5d5b0d00..93ccb5b9 100644 --- a/packages/nodes/src/eyebrow-vent/tool.tsx +++ b/packages/nodes/src/eyebrow-vent/tool.tsx @@ -122,11 +122,11 @@ const EyebrowVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.2, 0.4, 0.5]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index be18d3c9..f184cf35 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -234,7 +234,7 @@ export const fenceDefinition: NodeDefinition = { presentation: { label: 'Fence', description: 'A straight or curved fence segment with configurable posts and infill.', - icon: { kind: 'url', src: '/icons/fence.png' }, + icon: { kind: 'url', src: '/icons/fence.webp' }, paletteSection: 'structure', paletteOrder: 20, }, diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index 2f5e09f5..cd2c546b 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -45,7 +45,7 @@ export const guideDefinition: NodeDefinition = { presentation: { label: 'Guide', description: 'A measurement / reference annotation (linear, area, or arc).', - icon: { kind: 'url', src: '/icons/blueprint.png' }, + icon: { kind: 'url', src: '/icons/blueprint.webp' }, paletteSection: 'site', paletteOrder: 30, }, diff --git a/packages/nodes/src/gutter/definition.ts b/packages/nodes/src/gutter/definition.ts index 2e6d655b..f191dcbd 100644 --- a/packages/nodes/src/gutter/definition.ts +++ b/packages/nodes/src/gutter/definition.ts @@ -181,7 +181,7 @@ export const gutterDefinition: NodeDefinition = { presentation: { label: 'Gutter', description: 'Rain-water channel running along the eave of a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 122, }, diff --git a/packages/nodes/src/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index 60496d50..5c03e204 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from 'react' import * as THREE from 'three' +import { INVALID_GHOST_COLOR } from '../shared/ghost-materials' import { buildGutterGeometry } from './geometry' import type { GutterNode } from './schema' @@ -20,7 +21,8 @@ import type { GutterNode } from './schema' * of the trough walls and visually thicken the ghost relative to the * placed gutter. */ -const GutterPreview = ({ node }: { node: GutterNode }) => { +const GutterPreview = ({ node, invalid }: { node: GutterNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildGutterGeometry(node), [ @@ -39,17 +41,17 @@ const GutterPreview = ({ node }: { node: GutterNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.7, metalness: 0.2, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.FrontSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 76827e52..5a3862d6 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -117,6 +117,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { // the FULL host segment (the alignment needs wallHeight / overhang / // pitch / roofType to derive each eave Y), which is a superset of what // the mitre detector reads — so one list feeds both. + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const { mitres, sharedEaveY } = useMemo(() => { if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined } const segById = new Map() @@ -158,6 +159,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { mitreNodes, ]) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildGutterGeometry(node, mitres), [ diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx index 6fd43d2d..948d4feb 100644 --- a/packages/nodes/src/gutter/tool.tsx +++ b/packages/nodes/src/gutter/tool.tsx @@ -147,8 +147,8 @@ const GutterTool = () => { <> } onInvalidTarget={() => setTarget(null)} - size={[2, 0.2, 0.25]} /> {activeBuildingId && target && ( diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts new file mode 100644 index 00000000..47921bd9 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -0,0 +1,106 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildHvacEquipmentFloorplan } from './floorplan' +import { buildHvacEquipmentGeometry } from './geometry' +import { hvacEquipmentParametrics } from './parametrics' +import { getHvacEquipmentPorts } from './ports' +import { HvacEquipmentNode } from './schema' + +/** + * Phase 3 of the HVAC node system — equipment cabinets (furnace / + * air handler / condenser). Furnaces and air handlers expose supply + + * return ports, giving duct runs a real origin: the duct and fitting + * tools snap onto these collars like any other port. + * + * Composition: `def.geometry` only. Yaw-only rotation, so the editor's + * default R-rotate works on a selected unit without custom actions. + */ +export const hvacEquipmentDefinition: NodeDefinition = { + kind: 'hvac-equipment', + schemaVersion: 1, + schema: HvacEquipmentNode, + category: 'utility', + distributionRole: 'equipment', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + equipmentType: 'furnace', + width: 0.56, + depth: 0.71, + height: 1.1, + supplyShape: 'round', + returnShape: 'round', + supplyDiameter: 8, + returnDiameter: 8, + supplyWidth: 12, + supplyHeight: 8, + returnWidth: 14, + returnHeight: 8, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + floorPlaced: { + footprint: (node) => { + const n = node as HvacEquipmentNode + return { + dimensions: [n.width, n.height, n.depth], + rotation: [0, n.rotation, 0], + } + }, + }, + }, + + parametrics: hvacEquipmentParametrics, + + geometry: buildHvacEquipmentGeometry, + geometryKey: (n) => + JSON.stringify([ + n.equipmentType, + n.width, + n.depth, + n.height, + n.supplyShape, + n.returnShape, + n.supplyDiameter, + n.returnDiameter, + n.supplyWidth, + n.supplyHeight, + n.returnWidth, + n.returnHeight, + ]), + + ports: getHvacEquipmentPorts, + + floorplan: buildHvacEquipmentFloorplan, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place unit' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Shift', label: 'Smooth (no grid snap)' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'HVAC Unit', + description: + 'Furnace, air handler, or condenser — duct runs connect to its supply/return collars.', + icon: { kind: 'url', src: '/icons/HVAC.webp' }, + paletteSection: 'structure', + paletteOrder: 92, + }, + + mcp: { + description: + 'HVAC equipment cabinet (furnace, air handler, or condenser). Furnaces and air handlers have supply/return duct ports; every unit also has a refrigerant service port that a lineset run connects to. Position is level-local meters; rotation is yaw radians.', + }, +} diff --git a/packages/nodes/src/hvac-equipment/floorplan.ts b/packages/nodes/src/hvac-equipment/floorplan.ts new file mode 100644 index 00000000..e15b8504 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/floorplan.ts @@ -0,0 +1,83 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import { getHvacEquipmentPorts } from './ports' +import type { HvacEquipmentNode } from './schema' + +const BODY_FILL = '#c7cbd1' +const BODY_STROKE = '#6b7280' +const SUPPLY_COLOR = '#d4825a' +const RETURN_COLOR = '#5a8ad4' + +/** + * Floor-plan footprint for HVAC equipment: the cabinet rectangle + * (rotated by yaw) with a diagonal so it reads as an equipment symbol, + * plus a supply/return collar dot per duct port. Selected → themed + * stroke + move handle. + */ +export function buildHvacEquipmentFloorplan( + node: HvacEquipmentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const [cx, , cz] = node.position + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + const hw = node.width / 2 + const hd = node.depth / 2 + // Local corner → plan, applying yaw. Plan x = world x, plan y = world z; + // a +yaw about world Y maps local (x, z) to (x cos + z sin, -x sin + z cos). + const corner = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos + lz * sin, + cz - lx * sin + lz * cos, + ] + const points: FloorplanPoint[] = [ + corner(-hw, -hd), + corner(hw, -hd), + corner(hw, hd), + corner(-hw, hd), + ] + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const stroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_STROKE + + const children: FloorplanGeometry[] = [ + { + kind: 'polygon', + points, + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.02, + opacity: 0.92, + }, + // Diagonal — the conventional "mechanical equipment" plan mark. + { + kind: 'line', + x1: points[0]![0], + y1: points[0]![1], + x2: points[2]![0], + y2: points[2]![1], + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + opacity: 0.7, + }, + ] + + for (const port of getHvacEquipmentPorts(node)) { + children.push({ + kind: 'circle', + cx: port.position[0], + cy: port.position[2], + r: (port.diameter * INCHES_TO_METERS) / 2, + fill: port.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR, + opacity: 0.85, + }) + } + + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cz] }) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/hvac-equipment/geometry.ts b/packages/nodes/src/hvac-equipment/geometry.ts new file mode 100644 index 00000000..72fb6122 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/geometry.ts @@ -0,0 +1,862 @@ +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + Path, + Shape, + TorusGeometry, + Vector3, +} from 'three' +import { + createOvalSectionGeometry, + INCHES_TO_METERS, + rectSectionAxes, +} from '../duct-segment/geometry' +import { localEquipmentPorts, localRefrigerantPorts } from './ports' +import type { HvacEquipmentNode } from './schema' + +const RADIAL_SEGMENTS = 24 +const SMALL_SEGMENTS = 16 + +// Shared cabinet white used by every equipment body (furnace, air handler, +// condenser) so the units read as one product family. +const EQUIPMENT_WHITE = '#eef0f2' +const EQUIPMENT_TRIM = '#cfd3d8' + +const CABINET_COLOR = EQUIPMENT_WHITE +const INTERIOR_COLOR = '#9aa1a8' +const PANEL_COLOR = EQUIPMENT_TRIM +const CONTROL_COLOR = '#3f4549' +const CONDENSER_COLOR = EQUIPMENT_WHITE +const CONDENSER_FRAME_COLOR = EQUIPMENT_TRIM +const CONDENSER_FIN_COLOR = '#9aa1a8' +const FAN_COLOR = '#3f4549' +const BLOWER_COLOR = '#2f6fb0' +const BLOWER_BLADE_COLOR = '#274f7d' +const BURNER_COLOR = '#d9772e' +const GAS_PIPE_COLOR = '#d2691e' +const AIR_HANDLER_COLOR = EQUIPMENT_WHITE +const AIR_HANDLER_TRIM = EQUIPMENT_TRIM +const FAN_GRILLE_COLOR = '#3a3f44' +const FAN_BLADE_COLOR = '#d7dade' +const COIL_FIN_COLOR = '#9aa1a8' +const COPPER_COLOR = '#b06b3f' +const SERVICE_VALVE_COLOR = '#7a8086' + +const UP = new Vector3(0, 1, 0) + +/** + * Pure geometry builder for an HVAC equipment cabinet, in the node's + * LOCAL frame (origin at base center, +Z front, +X right) — + * `` applies `position` + yaw. + * + * Furnace / air handler: the cabinet is built from individual sheet-metal + * walls (not a solid box) so the lower front can be left OPEN — a real + * cut that exposes the squirrel-cage circulating fan and, on a furnace, + * the orange burner manifold and gas valve. Furnaces also get the + * combustion train from the reference drawing: a draft hood + vent + * connector elbow on top and a gas pipe with drip leg down the front-left. + * + * Air handler: tall white cabinet with two stacked guarded axial fans on + * the front and finned coil bands down the sides (vertical fan-coil look). + * Condenser: squat cabinet with a fan ring and hub on top. + */ +export function buildHvacEquipmentGeometry(node: HvacEquipmentNode): Group { + const group = new Group() + if (node.equipmentType === 'condenser') return buildCondenser(node, group) + if (node.equipmentType === 'air-handler') return buildAirHandler(node, group) + + const W = node.width + const H = node.height + const D = node.depth + const hw = W / 2 + const hd = D / 2 + const t = Math.min(0.02, W * 0.04, D * 0.04) + + // Single-sided. Each wall is a thin slab whose interior-facing face is an + // outward face of its own box, so the cut still shows metal inside — and + // single-sided culling means coplanar butt joints can't z-fight. + const cabinet = new MeshStandardMaterial({ + color: CABINET_COLOR, + metalness: 0.55, + roughness: 0.45, + }) + const interior = new MeshStandardMaterial({ + color: INTERIOR_COLOR, + metalness: 0.4, + roughness: 0.6, + }) + + const addBox = ( + w: number, + h: number, + dd: number, + mat: MeshStandardMaterial, + x: number, + y: number, + z: number, + name: string, + ) => { + const mesh = new Mesh(new BoxGeometry(w, h, dd), mat) + mesh.name = name + mesh.position.set(x, y, z) + group.add(mesh) + return mesh + } + + const ports = localEquipmentPorts(node) + const supplyPort = ports.find((p) => p.id === 'supply') + const returnPort = ports.find((p) => p.id === 'return') + + // ── Cabinet shell as butt-jointed sheet-metal plates. Top + bottom span + // the full footprint; the four walls sit *between* them (height innerH), + // and back / front pieces sit *between* the side walls (width W - 2t). No + // two same-facing surfaces are ever coplanar, which is what was z-fighting + // when these were full-size overlapping boxes; single-sided materials + // (above) finish the job. Left wall carries the return hole, top the supply. + const innerH = H - 2 * t + const midY = H / 2 + const frontZ = hd - t / 2 + + addBox(W, t, D, cabinet, 0, t / 2, 0, 'equipment-bottom') + addBox(t, innerH, D, interior, hw - t / 2, midY, 0, 'equipment-right') + addBox(W - 2 * t, innerH, t, interior, 0, midY, -hd + t / 2, 'equipment-back') + + // Top plate, flat, with the supply hole at the cabinet center. Built + // centered in its own XY plane (x→W, y→D); rotate.x = -90° lays it flat. + const top = buildHolePlate(W, D, t, supplyPort, 0, 0, cabinet) + top.name = 'equipment-top' + top.rotation.x = -Math.PI / 2 + top.position.set(0, H - t / 2, 0) + group.add(top) + + // Left wall with the return hole. After rotate.y = -90° the plate's x→world + // -z and y→world height; centered at midY with the return port at world + // y = H*0.35, so the hole sits at plate-y (H*0.35 - midY). + const left = buildHolePlate(D, innerH, t, returnPort, 0, H * 0.35 - midY, interior) + left.name = 'equipment-left' + left.rotation.y = -Math.PI / 2 + left.position.set(-hw + t / 2, midY, 0) + group.add(left) + + // Front opening: framed sill, jambs and an upper control panel, all inset + // to (W - 2t) so they tuck between the side walls. The gap between sill + // and panel (and inside the jambs) is the visible cut. + const openBottom = H * 0.1 + const openTop = H * 0.58 + const jamb = W * 0.08 + const frontW = W - 2 * t + const frontHalf = frontW / 2 + const panelMat = new MeshStandardMaterial({ + color: PANEL_COLOR, + metalness: 0.5, + roughness: 0.5, + }) + addBox(frontW, openBottom - t, t, cabinet, 0, (t + openBottom) / 2, frontZ, 'equipment-sill') + addBox(frontW, H - t - openTop, t, panelMat, 0, (openTop + H - t) / 2, frontZ, 'equipment-panel') + addBox( + jamb, + openTop - openBottom, + t, + cabinet, + -frontHalf + jamb / 2, + (openBottom + openTop) / 2, + frontZ, + 'equipment-jamb-l', + ) + addBox( + jamb, + openTop - openBottom, + t, + cabinet, + frontHalf - jamb / 2, + (openBottom + openTop) / 2, + frontZ, + 'equipment-jamb-r', + ) + + // ── Control area on the upper front panel (fan-limit switch + cover). + const ctrlMat = new MeshStandardMaterial({ + color: CONTROL_COLOR, + metalness: 0.4, + roughness: 0.6, + }) + addBox( + W * 0.34, + (H - openTop) * 0.5, + 0.012, + ctrlMat, + W * 0.18, + (openTop + H) / 2, + frontZ + 0.008, + 'equipment-control', + ) + addBox( + W * 0.1, + (H - openTop) * 0.3, + 0.02, + ctrlMat, + -W * 0.22, + (openTop + H) / 2, + frontZ + 0.012, + 'equipment-switch', + ) + + // ── Squirrel-cage circulating fan, seated in the open lower cavity. The + // round scroll housing faces front (+Z) so it shows through the cut. + const rB = Math.min(W * 0.34, (openTop - openBottom) * 0.42) + const housingD = D * 0.42 + const cy = openBottom + rB + 0.01 + const zc = hd - t - housingD / 2 - 0.01 + const blowerMat = new MeshStandardMaterial({ + color: BLOWER_COLOR, + metalness: 0.3, + roughness: 0.6, + }) + const bladeMat = new MeshStandardMaterial({ + color: BLOWER_BLADE_COLOR, + metalness: 0.2, + roughness: 0.75, + }) + const housing = new Mesh(new CylinderGeometry(rB, rB, housingD, RADIAL_SEGMENTS), blowerMat) + housing.name = 'blower-housing' + housing.rotation.x = Math.PI / 2 // axis Y → axis Z (round face toward front) + housing.position.set(0, cy, zc) + group.add(housing) + const intake = new Mesh(new TorusGeometry(rB * 0.7, rB * 0.12, 10, RADIAL_SEGMENTS), blowerMat) + intake.name = 'blower-intake' + intake.position.set(0, cy, hd - t - 0.005) + group.add(intake) + const hub = new Mesh( + new CylinderGeometry(rB * 0.18, rB * 0.18, housingD * 0.9, SMALL_SEGMENTS), + bladeMat, + ) + hub.name = 'blower-hub' + hub.rotation.x = Math.PI / 2 + hub.position.set(0, cy, zc) + group.add(hub) + // Radial cage blades around the hub axis (Z). + const BLADES = 14 + for (let i = 0; i < BLADES; i++) { + const a = (i / BLADES) * Math.PI * 2 + const blade = new Mesh(new BoxGeometry(0.006, rB * 0.62, housingD * 0.82), bladeMat) + blade.name = `blower-blade-${i}` + blade.position.set(Math.cos(a) * rB * 0.5, cy + Math.sin(a) * rB * 0.5, zc) + blade.rotation.z = a + group.add(blade) + } + + buildCombustionTrain(node, group, { hw, hd, H, openTop, frontZ }) + buildGasLine(node, group, { hw, hd, H }) + + buildCollars(node, group) + buildServiceValves(node, group) + return group +} + +/** Orange burner manifold + gas valve above the blower (furnace only). */ +function buildCombustionTrain( + node: HvacEquipmentNode, + group: Group, + dims: { hw: number; hd: number; H: number; openTop: number; frontZ: number }, +): void { + const { hw, hd, H, openTop } = dims + const burnerMat = new MeshStandardMaterial({ + color: BURNER_COLOR, + metalness: 0.35, + roughness: 0.55, + emissive: BURNER_COLOR, + emissiveIntensity: 0.12, + }) + const y = openTop - 0.12 + const z = hd - node.depth * 0.32 + + // Manifold pipe running across the unit (axis X), feeding the burners. + const manifold = new Mesh( + new CylinderGeometry(0.018, 0.018, node.width * 0.66, SMALL_SEGMENTS), + burnerMat, + ) + manifold.name = 'burner-manifold' + manifold.rotation.z = Math.PI / 2 + manifold.position.set(-node.width * 0.05, y, z) + group.add(manifold) + + // 4 burner tubes shooting back into the heat exchanger (axis Z). + const tubes = 4 + for (let i = 0; i < tubes; i++) { + const x = (-(tubes - 1) / 2 + i) * (node.width * 0.16) + const tube = new Mesh( + new CylinderGeometry(0.022, 0.022, node.depth * 0.34, SMALL_SEGMENTS), + burnerMat, + ) + tube.name = `burner-tube-${i}` + tube.rotation.x = Math.PI / 2 + tube.position.set(x, y, z - node.depth * 0.17) + group.add(tube) + } + + // Gas valve block at the right end of the manifold. + const valve = new Mesh(new BoxGeometry(0.08, 0.07, 0.09), burnerMat) + valve.name = 'gas-valve' + valve.position.set(hw - 0.07, y, z + 0.02) + group.add(valve) +} + +/** Gas supply pipe with a capped drip leg, down the front-left (furnace). */ +function buildGasLine( + node: HvacEquipmentNode, + group: Group, + dims: { hw: number; hd: number; H: number }, +): void { + const { hw, hd, H } = dims + const gasMat = new MeshStandardMaterial({ + color: GAS_PIPE_COLOR, + metalness: 0.4, + roughness: 0.5, + }) + const r = 0.014 + const x = -hw + 0.06 + const z = hd + 0.03 + const teeY = H * 0.34 + + // Vertical main running down the front-left face. + const mainTop = H * 0.92 + const mainLen = mainTop - teeY + const main = new Mesh(new CylinderGeometry(r, r, mainLen, SMALL_SEGMENTS), gasMat) + main.name = 'gas-main' + main.position.set(x, teeY + mainLen / 2, z) + group.add(main) + + // Tee into the cabinet toward the gas valve (axis X, +). + const tee = new Mesh(new CylinderGeometry(r, r, 0.12, SMALL_SEGMENTS), gasMat) + tee.name = 'gas-tee' + tee.rotation.z = Math.PI / 2 + tee.position.set(x + 0.06, teeY, z) + group.add(tee) + + // Drip leg: short capped vertical pipe below the tee to catch sediment. + const legLen = H * 0.14 + const leg = new Mesh(new CylinderGeometry(r, r, legLen, SMALL_SEGMENTS), gasMat) + leg.name = 'gas-drip-leg' + leg.position.set(x, teeY - legLen / 2, z) + group.add(leg) + const cap = new Mesh(new CylinderGeometry(r * 1.4, r * 1.4, 0.02, SMALL_SEGMENTS), gasMat) + cap.name = 'gas-drip-cap' + cap.position.set(x, teeY - legLen, z) + group.add(cap) +} + +type LocalPort = ReturnType[number] + +type CollarSection = { shape: 'round' | 'rect' | 'oval'; widthM: number; heightM: number } + +/** + * Radial clearance (meters) the collar sleeve carries over the duct's + * nominal cross-section. A duct run leaves the port at the advertised size; + * the collar is built one clearance larger on every side so it reads as a + * sheet-metal sleeve wrapping the duct — and so their faces never coincide + * (no z-fighting where the run overlaps the stub). ~5 mm ≈ a real slip joint. + */ +const COLLAR_CLEARANCE_M = 0.005 + +/** + * Collar cross-section in meters, already grown by `COLLAR_CLEARANCE_M` so + * the sleeve sits over the duct. Round collapses to a single diameter on + * both axes; rect / oval carry the explicit width × height (width is the + * horizontal face, height the vertical). For round the port's `diameter` + * is the true round size; for rect / oval it is the area-equivalent value + * the port advertises, so the mesh uses width / height instead. + */ +function collarSection(port: LocalPort): CollarSection { + const shape = port.shape ?? 'round' + const grow = 2 * COLLAR_CLEARANCE_M + if (shape === 'round') { + const d = port.diameter * INCHES_TO_METERS + grow + return { shape, widthM: d, heightM: d } + } + return { + shape, + widthM: (port.width ?? port.diameter) * INCHES_TO_METERS + grow, + heightM: (port.height ?? port.diameter) * INCHES_TO_METERS + grow, + } +} + +/** Collar sleeve geometry with the run length on local Y and the + * cross-section on local X (width) × Z (height) — the basis the caller + * orients with `rectSectionAxes`. Round stays open-ended so you can see + * straight through into the hole. */ +function collarGeometry(section: CollarSection, length: number): BufferGeometry { + if (section.shape === 'rect') return new BoxGeometry(section.widthM, length, section.heightM) + if (section.shape === 'oval') { + return createOvalSectionGeometry(section.widthM, section.heightM, length) + } + const r = section.widthM / 2 + return new CylinderGeometry(r, r, length, RADIAL_SEGMENTS, 1, true) +} + +/** + * Hole `Path` in the plate's local XY (width → X, height → Y), centered at + * (`hx`, `hy`) and clamped to keep it inside the plate. Three.js corrects + * hole winding when extruding, so the path direction here is irrelevant. + */ +function collarHolePath( + section: CollarSection, + hx: number, + hy: number, + maxHalfW: number, + maxHalfH: number, +): Path | null { + if (section.shape === 'rect') { + const hw = Math.min(section.widthM / 2, maxHalfW) + const hh = Math.min(section.heightM / 2, maxHalfH) + if (hw <= 0 || hh <= 0) return null + return new Path() + .moveTo(hx - hw, hy - hh) + .lineTo(hx + hw, hy - hh) + .lineTo(hx + hw, hy + hh) + .lineTo(hx - hw, hy + hh) + .closePath() + } + if (section.shape === 'oval') { + const w = Math.min(section.widthM, maxHalfW * 2) + const h = Math.min(section.heightM, maxHalfH * 2) + const r = Math.min(w, h) / 2 + const straight = Math.max(0, w - h) / 2 + if (r <= 0) return null + const path = new Path() + path.absarc(hx + straight, hy, r, -Math.PI / 2, Math.PI / 2, false) + path.absarc(hx - straight, hy, r, Math.PI / 2, (3 * Math.PI) / 2, false) + path.closePath() + return path + } + const r = Math.min(section.widthM / 2, maxHalfW, maxHalfH) + if (r <= 0) return null + const path = new Path() + path.absarc(hx, hy, r, 0, Math.PI * 2, true) + return path +} + +/** + * Flat rectangular plate of `thickness`, centered on the origin in its own + * XY plane (width → X, height → Y) and centered through the thickness on Z, + * with the duct opening for `port` punched at (`hx`, `hy`). Callers rotate / + * position it into a wall; the hole takes the collar's round / rect / oval + * cross-section. + */ +function buildHolePlate( + width: number, + height: number, + thickness: number, + port: LocalPort | undefined, + hx: number, + hy: number, + material: MeshStandardMaterial, +): Mesh { + const hw = width / 2 + const hh = height / 2 + const shape = new Shape() + .moveTo(-hw, -hh) + .lineTo(hw, -hh) + .lineTo(hw, hh) + .lineTo(-hw, hh) + .lineTo(-hw, -hh) + + const hole = port ? collarHolePath(collarSection(port), hx, hy, hw * 0.95, hh * 0.95) : null + if (hole) shape.holes.push(hole) + + const geom = new ExtrudeGeometry(shape, { depth: thickness, bevelEnabled: false }) + geom.translate(0, 0, -thickness / 2) + geom.computeVertexNormals() + return new Mesh(geom, material) +} + +/** + * Sheet-metal sleeves at the supply/return ports. Each collar straddles the + * wall hole — part inside the cabinet, part outside — so a duct run slides + * through the opening instead of dead-ending on a panel. The collar takes + * the port's round / rect / oval cross-section, oriented with the same + * width-horizontal / height-vertical basis as the hole it sits in. + */ +function buildCollars(node: HvacEquipmentNode, group: Group): void { + const collarMaterial = new MeshStandardMaterial({ + color: '#c2c2c2', + metalness: 0.6, + roughness: 0.4, + side: 2, + }) + const OUT = 0.12 // sleeve length outside the cabinet + const IN = 0.05 // sleeve length reaching inside past the hole + const length = OUT + IN + for (const port of localEquipmentPorts(node)) { + const dir = port.direction.clone().normalize() + const sleeve = new Mesh(collarGeometry(collarSection(port), length), collarMaterial) + sleeve.name = `equipment-collar-${port.id}` + const { width: wAxis, height: hAxis } = rectSectionAxes(dir) + sleeve.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(wAxis, dir, hAxis)) + sleeve.position.copy(port.position).addScaledVector(dir, (OUT - IN) / 2) + group.add(sleeve) + } +} + +// Default lineset line radii (meters) — must mirror the lineset kind's +// defaults so the two service stubs sit exactly where its suction/liquid +// pipes run. See `lineset/geometry.ts` (suction 7/8", liquid 3/8", 3/8" +// foam jacket) and its symmetric ±offset about the path centerline. +const LINESET_SUCTION_R = (0.875 * INCHES_TO_METERS) / 2 +const LINESET_LIQUID_R = (0.375 * INCHES_TO_METERS) / 2 +const LINESET_JACKET_R = LINESET_SUCTION_R + 0.01 +const LINESET_PAIR_OFFSET = LINESET_JACKET_R + LINESET_LIQUID_R + +/** + * Refrigerant service valves at the lineset port — a brass-grey valve body + * with two copper stubs the lineset run mates onto. Built on every + * equipment type so a split system can be piped from condenser to coil. + * + * A lineset is a parallel pair (insulated suction + bare liquid) offset + * symmetrically about its path centerline. The snap point is that + * centerline, so a single stub would sit in the empty gap between the two + * pipes. Instead we emit two stubs at exactly the lineset's ±offset along + * the port's horizontal perpendicular: the suction pipe lands on the wide + * stub, the liquid pipe on the narrow one, when the run leaves the face. + */ +function buildServiceValves(node: HvacEquipmentNode, group: Group): void { + const valveMat = new MeshStandardMaterial({ + color: SERVICE_VALVE_COLOR, + metalness: 0.7, + roughness: 0.35, + }) + const copperMat = new MeshStandardMaterial({ + color: COPPER_COLOR, + metalness: 0.8, + roughness: 0.3, + }) + for (const port of localRefrigerantPorts(node)) { + const dir = port.direction.clone().normalize() + // Horizontal perpendicular to the port — matches the lineset geometry's + // `horizontal.cross(UP)`, so the stub offsets track its pipe offsets. + const perp = dir.clone().cross(UP).normalize() + + // Brass-grey valve body bolted to the cabinet face, spanning the pair. + const bodyWidth = 2 * LINESET_PAIR_OFFSET + 2 * LINESET_JACKET_R + const body = new Mesh(new BoxGeometry(0.05, 0.08, bodyWidth), valveMat) + body.name = 'service-valve-body' + body.position.copy(port.position).addScaledVector(dir, 0.025) + body.quaternion.setFromUnitVectors(UP, dir) + group.add(body) + + const stubLen = 0.07 + const addStub = (sign: number, radius: number, id: string) => { + const stub = new Mesh( + new CylinderGeometry(radius, radius, stubLen, SMALL_SEGMENTS), + copperMat, + ) + stub.name = `service-valve-stub-${id}` + stub.position + .copy(port.position) + .addScaledVector(perp, sign * LINESET_PAIR_OFFSET) + .addScaledVector(dir, 0.05 + stubLen / 2) + stub.quaternion.setFromUnitVectors(UP, dir) + group.add(stub) + } + // Suction pipe is the lineset's -offset line; liquid is +offset. + addStub(-1, LINESET_SUCTION_R, 'suction') + addStub(1, LINESET_LIQUID_R, 'liquid') + } +} + +/** + * Residential split-system condenser, matching the reference photos: a + * greenish-grey body wrapped in vertical louvered coil fins on all four + * sides, a dark base and dark top frame, and a top-mounted fan with a + * radial wire guard (concentric rings + spokes) over a recessed throat. + */ +function buildCondenser(node: HvacEquipmentNode, group: Group): Group { + const W = node.width + const H = node.height + const D = node.depth + const hw = W / 2 + const hd = D / 2 + + const bodyMat = new MeshStandardMaterial({ + color: CONDENSER_COLOR, + metalness: 0.5, + roughness: 0.5, + }) + const frameMat = new MeshStandardMaterial({ + color: CONDENSER_FRAME_COLOR, + metalness: 0.4, + roughness: 0.6, + }) + const finMat = new MeshStandardMaterial({ + color: CONDENSER_FIN_COLOR, + metalness: 0.65, + roughness: 0.4, + }) + + const frameH = Math.min(0.07, H * 0.09) + const post = Math.min(0.04, W * 0.07) + + // Inner body the fins wrap around (inset so corner posts read proud). + const body = new Mesh(new BoxGeometry(W - post, H - 2 * frameH, D - post), bodyMat) + body.name = 'equipment-body' + body.position.set(0, H / 2, 0) + group.add(body) + + // Dark base + top frame rings. + const base = new Mesh(new BoxGeometry(W, frameH, D), frameMat) + base.name = 'condenser-base' + base.position.set(0, frameH / 2, 0) + group.add(base) + const topFrame = new Mesh(new BoxGeometry(W, frameH, D), frameMat) + topFrame.name = 'condenser-top-frame' + topFrame.position.set(0, H - frameH / 2, 0) + group.add(topFrame) + + // Corner posts. + for (const sx of [-1, 1]) { + for (const sz of [-1, 1]) { + const p = new Mesh(new BoxGeometry(post, H, post), frameMat) + p.name = `condenser-post-${sx > 0 ? 'r' : 'l'}${sz > 0 ? 'f' : 'b'}` + p.position.set(sx * (hw - post / 2), H / 2, sz * (hd - post / 2)) + group.add(p) + } + } + + // Vertical louvered coil fins on all four faces. Each fin is a thin + // vertical slat standing slightly proud of the body; the gaps between + // them read as the coil louvers. + const finY = H / 2 + const finH = H - 2 * frameH + const addFins = (count: number, span: number, fixed: number, axis: 'x' | 'z', sign: number) => { + for (let i = 0; i < count; i++) { + const t = (i + 0.5) / count + const c = -span / 2 + t * span + const fin = + axis === 'x' + ? new Mesh(new BoxGeometry(0.006, finH, 0.018), finMat) + : new Mesh(new BoxGeometry(0.018, finH, 0.006), finMat) + fin.name = `condenser-fin-${axis}${sign > 0 ? '+' : '-'}-${i}` + if (axis === 'x') fin.position.set(c, finY, sign * fixed) + else fin.position.set(sign * fixed, finY, c) + group.add(fin) + } + } + const finsAlongW = Math.max(10, Math.round(W / 0.025)) + const finsAlongD = Math.max(10, Math.round(D / 0.025)) + addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', 1) // front + addFins(finsAlongW, W - post, hd - post / 2 + 0.004, 'x', -1) // back + addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', 1) // right + addFins(finsAlongD, D - post, hw - post / 2 + 0.004, 'z', -1) // left + + buildCondenserFanGuard(group, W, H, D) + buildServiceValves(node, group) + return group +} + +/** Top fan: recessed throat + hub/blades under a radial wire guard. */ +function buildCondenserFanGuard(group: Group, W: number, H: number, D: number): void { + const fanMat = new MeshStandardMaterial({ + color: FAN_COLOR, + metalness: 0.3, + roughness: 0.7, + }) + const guardMat = new MeshStandardMaterial({ + color: CONDENSER_FRAME_COLOR, + metalness: 0.4, + roughness: 0.6, + }) + const r = Math.min(W, D) * 0.4 + const deckY = H + + // Recessed throat dropping below the top deck so the fan reads as an + // opening, not a disc sitting on the lid. + const throat = new Mesh(new CylinderGeometry(r, r, H * 0.12, RADIAL_SEGMENTS, 1, true), fanMat) + throat.name = 'condenser-fan-throat' + throat.position.set(0, deckY - H * 0.06, 0) + group.add(throat) + + // Hub + swept blades just below the deck. + const bladeMat = new MeshStandardMaterial({ + color: '#5a6066', + metalness: 0.3, + roughness: 0.6, + }) + const hub = new Mesh(new CylinderGeometry(r * 0.16, r * 0.16, 0.04, SMALL_SEGMENTS), bladeMat) + hub.name = 'condenser-fan-hub' + hub.position.set(0, deckY - 0.02, 0) + group.add(hub) + const BLADES = 6 + for (let i = 0; i < BLADES; i++) { + const a = (i / BLADES) * Math.PI * 2 + const blade = new Mesh(new BoxGeometry(r * 0.7, 0.006, r * 0.28), bladeMat) + blade.name = `condenser-fan-blade-${i}` + blade.position.set(Math.cos(a) * r * 0.45, deckY - 0.02, Math.sin(a) * r * 0.45) + blade.rotation.y = a + blade.rotation.x = 0.35 + group.add(blade) + } + + // Radial wire guard: concentric rings + spokes, slightly domed above deck. + const guardY = deckY + 0.012 + for (let k = 1; k <= 5; k++) { + const rr = (r * k) / 5 + const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), guardMat) + ring.name = `condenser-guard-ring-${k}` + ring.rotation.x = Math.PI / 2 + ring.position.set(0, guardY, 0) + group.add(ring) + } + const SPOKES = 8 + for (let i = 0; i < SPOKES; i++) { + const a = (i / SPOKES) * Math.PI + const spoke = new Mesh(new BoxGeometry(r * 2, 0.004, 0.004), guardMat) + spoke.name = `condenser-guard-spoke-${i}` + spoke.position.set(0, guardY, 0) + spoke.rotation.y = a + group.add(spoke) + } +} + +/** + * Guarded axial fan on the front (+Z) face: a recessed dark throat, a + * spider hub with swept blades, and a concentric wire grille — the look of + * the units in the air-handler reference. Centered at (`x`, `y`) on the + * cabinet front at `frontZ`, radius `r`. + */ +function buildAxialFan( + group: Group, + x: number, + y: number, + frontZ: number, + r: number, + index: number, +): void { + const grilleMat = new MeshStandardMaterial({ + color: FAN_GRILLE_COLOR, + metalness: 0.4, + roughness: 0.6, + }) + const bladeMat = new MeshStandardMaterial({ + color: FAN_BLADE_COLOR, + metalness: 0.3, + roughness: 0.5, + }) + + // Recessed throat behind the blades so the fan reads as an opening. + const throat = new Mesh(new CylinderGeometry(r, r, 0.04, RADIAL_SEGMENTS), grilleMat) + throat.name = `fan-${index}-throat` + throat.rotation.x = Math.PI / 2 + throat.position.set(x, y, frontZ - 0.02) + group.add(throat) + + // Hub + swept blades, sitting just proud of the throat. + const hub = new Mesh(new CylinderGeometry(r * 0.18, r * 0.18, 0.03, SMALL_SEGMENTS), bladeMat) + hub.name = `fan-${index}-hub` + hub.rotation.x = Math.PI / 2 + hub.position.set(x, y, frontZ + 0.005) + group.add(hub) + + const BLADES = 5 + for (let i = 0; i < BLADES; i++) { + const a = (i / BLADES) * Math.PI * 2 + const blade = new Mesh(new BoxGeometry(r * 0.34, 0.006, r * 0.78), bladeMat) + blade.name = `fan-${index}-blade-${i}` + // Position blade outward from hub, then tilt for an airfoil sweep. + const br = r * 0.5 + blade.position.set(x + Math.cos(a) * br, y + Math.sin(a) * br, frontZ + 0.005) + blade.rotation.z = a + blade.rotation.y = 0.5 + group.add(blade) + } + + // Concentric wire grille (rings) over the front of the fan. + const ringMat = new MeshStandardMaterial({ + color: AIR_HANDLER_TRIM, + metalness: 0.5, + roughness: 0.4, + }) + for (let k = 1; k <= 3; k++) { + const rr = (r * k) / 3 + const ring = new Mesh(new TorusGeometry(rr, 0.004, 6, RADIAL_SEGMENTS), ringMat) + ring.name = `fan-${index}-grille-${k}` + ring.position.set(x, y, frontZ + 0.02) + group.add(ring) + } +} + +/** + * Air handler / vertical fan-coil: a tall white cabinet with two stacked + * guarded axial fans on the front and finned coil bands down both sides — + * the unit in the reference photo. Keeps the supply/return collars (built + * by the shared `buildCollars`) so duct runs still connect. + */ +function buildAirHandler(node: HvacEquipmentNode, group: Group): Group { + const W = node.width + const H = node.height + const D = node.depth + const hw = W / 2 + const hd = D / 2 + + const cabinetMat = new MeshStandardMaterial({ + color: AIR_HANDLER_COLOR, + metalness: 0.3, + roughness: 0.55, + }) + const trimMat = new MeshStandardMaterial({ + color: AIR_HANDLER_TRIM, + metalness: 0.4, + roughness: 0.5, + }) + const finMat = new MeshStandardMaterial({ + color: COIL_FIN_COLOR, + metalness: 0.6, + roughness: 0.45, + }) + + // Cabinet body + top/bottom trim caps. + const body = new Mesh(new BoxGeometry(W, H, D), cabinetMat) + body.name = 'equipment-body' + body.position.set(0, H / 2, 0) + group.add(body) + // Trim caps straddle the cabinet's top / bottom edges (centered on + // y = H and y = 0) so the body's end faces fall inside the cap volume. + // Sitting them flush instead (top face at y = H) leaves two coplanar + // full-footprint faces that z-fight. + const capH = Math.min(0.05, H * 0.06) + const topCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat) + topCap.name = 'air-handler-top-cap' + topCap.position.set(0, H, 0) + group.add(topCap) + const botCap = new Mesh(new BoxGeometry(W * 1.04, capH, D * 1.04), trimMat) + botCap.name = 'air-handler-bottom-cap' + botCap.position.set(0, 0, 0) + group.add(botCap) + + // Two stacked axial fans on the front face, sized to the cabinet width. + const frontZ = hd + 0.001 + const fanR = Math.min(W * 0.4, H * 0.22) + const margin = capH + fanR + H * 0.04 + buildAxialFan(group, 0, H - margin, frontZ, fanR, 0) + buildAxialFan(group, 0, margin, frontZ, fanR, 1) + + // Finned coil bands down both sides (horizontal slats = condenser fins). + const fins = Math.max(6, Math.floor(H / 0.06)) + for (let side = -1; side <= 1; side += 2) { + for (let i = 0; i < fins; i++) { + const fy = capH + ((i + 0.5) / fins) * (H - 2 * capH) + const fin = new Mesh(new BoxGeometry(0.004, 0.012, D * 0.82), finMat) + fin.name = `coil-fin-${side > 0 ? 'r' : 'l'}-${i}` + fin.position.set(side * (hw + 0.002), fy, 0) + group.add(fin) + } + } + + buildCollars(node, group) + buildServiceValves(node, group) + return group +} diff --git a/packages/nodes/src/hvac-equipment/index.ts b/packages/nodes/src/hvac-equipment/index.ts new file mode 100644 index 00000000..0f176b17 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/index.ts @@ -0,0 +1,4 @@ +export { hvacEquipmentDefinition } from './definition' +export { buildHvacEquipmentGeometry } from './geometry' +export { getHvacEquipmentPorts } from './ports' +export { HvacEquipmentNode } from './schema' diff --git a/packages/nodes/src/hvac-equipment/parametrics.ts b/packages/nodes/src/hvac-equipment/parametrics.ts new file mode 100644 index 00000000..569fe829 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/parametrics.ts @@ -0,0 +1,104 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { HvacEquipmentNode } from './schema' + +export const hvacEquipmentParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Equipment', + fields: [ + { + key: 'equipmentType', + kind: 'enum', + options: ['furnace', 'air-handler', 'condenser'], + display: 'segmented', + }, + ], + }, + { + label: 'Cabinet', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 }, + { key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 2.5, step: 0.05 }, + ], + }, + { + label: 'Supply', + fields: [ + { + key: 'supplyShape', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + visibleIf: (n) => n.equipmentType !== 'condenser', + }, + { + key: 'supplyDiameter', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape === 'round', + }, + { + key: 'supplyWidth', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round', + }, + { + key: 'supplyHeight', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.supplyShape !== 'round', + }, + ], + }, + { + label: 'Return', + fields: [ + { + key: 'returnShape', + kind: 'enum', + options: ['round', 'rect', 'oval'], + display: 'segmented', + visibleIf: (n) => n.equipmentType !== 'condenser', + }, + { + key: 'returnDiameter', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape === 'round', + }, + { + key: 'returnWidth', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round', + }, + { + key: 'returnHeight', + kind: 'number', + unit: 'in', + min: 6, + max: 30, + step: 1, + visibleIf: (n) => n.equipmentType !== 'condenser' && n.returnShape !== 'round', + }, + ], + }, + ], +} diff --git a/packages/nodes/src/hvac-equipment/ports.ts b/packages/nodes/src/hvac-equipment/ports.ts new file mode 100644 index 00000000..9bcfb21c --- /dev/null +++ b/packages/nodes/src/hvac-equipment/ports.ts @@ -0,0 +1,122 @@ +import type { NodePort } from '@pascal-app/core' +import { Vector3 } from 'three' +import { equivalentDiameterIn, ovalEquivalentDiameterIn } from '../duct-segment/geometry' +import type { HvacEquipmentNode } from './schema' + +type CollarShape = 'round' | 'rect' | 'oval' + +type LocalPort = { + id: string + position: Vector3 + direction: Vector3 + diameter: number + system: 'supply' | 'return' | 'refrigerant' + // Duct collars only — the cross-section the collar mesh and wall hole + // take. `diameter` above is the area-equivalent round size the port + // advertises so round runs mate at a sensible size. Refrigerant ports + // are always round and omit these. + shape?: CollarShape + width?: number + height?: number +} + +/** Area-equivalent round diameter (inches) a shaped collar advertises. */ +function collarDiameterIn(shape: CollarShape, diameter: number, width: number, height: number) { + if (shape === 'rect') return equivalentDiameterIn(width, height) + if (shape === 'oval') return ovalEquivalentDiameterIn(width, height) + return diameter +} + +/** Nominal suction-line OD (inches) the refrigerant service connection + * advertises — matches the lineset kind's default suction diameter so a + * lineset run mates cleanly onto the valve. */ +const REFRIGERANT_PORT_DIAMETER_IN = 0.875 + +/** + * Duct ports in the cabinet's LOCAL frame (origin at the base center, + * before yaw / position). Matches a typical upflow furnace / vertical air + * handler: supply plenum collar on top, return drop on the -X side near + * the bottom third. Condensers carry no duct ports — their connection is + * the refrigerant lineset (see `localRefrigerantPorts`). + */ +export function localEquipmentPorts(node: HvacEquipmentNode): LocalPort[] { + if (node.equipmentType === 'condenser') return [] + return [ + { + id: 'supply', + position: new Vector3(0, node.height, 0), + direction: new Vector3(0, 1, 0), + diameter: collarDiameterIn( + node.supplyShape, + node.supplyDiameter, + node.supplyWidth, + node.supplyHeight, + ), + system: 'supply', + shape: node.supplyShape, + width: node.supplyWidth, + height: node.supplyHeight, + }, + { + id: 'return', + position: new Vector3(-node.width / 2, node.height * 0.35, 0), + direction: new Vector3(-1, 0, 0), + diameter: collarDiameterIn( + node.returnShape, + node.returnDiameter, + node.returnWidth, + node.returnHeight, + ), + system: 'return', + shape: node.returnShape, + width: node.returnWidth, + height: node.returnHeight, + }, + ] +} + +/** + * Refrigerant service connection in the cabinet's LOCAL frame — the point + * a lineset run leaves from (condenser) or arrives at (indoor coil on a + * furnace / air handler). Every equipment type exposes exactly one, on the + * +X service-valve face: a condenser/air-handler near the bottom third, a + * furnace near the top where the cased A-coil sits above the heat + * exchanger. + */ +export function localRefrigerantPorts(node: HvacEquipmentNode): LocalPort[] { + const y = node.equipmentType === 'furnace' ? node.height * 0.8 : node.height * 0.3 + return [ + { + id: 'lineset', + position: new Vector3(node.width / 2, y, 0), + direction: new Vector3(1, 0, 0), + diameter: REFRIGERANT_PORT_DIAMETER_IN, + system: 'refrigerant', + }, + ] +} + +/** `def.ports` — duct + refrigerant ports transformed into level-local + * space (yaw + position). */ +export function getHvacEquipmentPorts(node: HvacEquipmentNode): NodePort[] { + const offset = new Vector3(node.position[0], node.position[1], node.position[2]) + const local = [...localEquipmentPorts(node), ...localRefrigerantPorts(node)] + return local.map((port) => { + const position = port.position.clone().applyAxisAngle(new Vector3(0, 1, 0), node.rotation) + position.add(offset) + const direction = port.direction + .clone() + .applyAxisAngle(new Vector3(0, 1, 0), node.rotation) + .normalize() + return { + id: port.id, + position: [position.x, position.y, position.z] as const, + direction: [direction.x, direction.y, direction.z] as const, + diameter: port.diameter, + system: port.system, + shape: port.shape, + width: port.width, + height: port.height, + } + }) +} diff --git a/packages/nodes/src/hvac-equipment/schema.ts b/packages/nodes/src/hvac-equipment/schema.ts new file mode 100644 index 00000000..33021216 --- /dev/null +++ b/packages/nodes/src/hvac-equipment/schema.ts @@ -0,0 +1 @@ +export { HvacEquipmentNode } from '@pascal-app/core' diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx new file mode 100644 index 00000000..54ee0bee --- /dev/null +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -0,0 +1,135 @@ +'use client' + +import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' +import { triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { hvacEquipmentDefinition } from './definition' +import { buildHvacEquipmentGeometry } from './geometry' + +const PREVIEW_OPACITY = 0.55 +/** R/T yaw step — 45°, matching the editor's default rotate. */ +const ROTATE_STEP_RAD = Math.PI / 4 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** + * Click-place tool for HVAC equipment (furnace / air handler / + * condenser). A translucent cabinet ghost follows the cursor on the + * floor with grid snap; **R / T** rotate the ghost ±45° around Y. Click + * places the unit — its supply/return collars become ports the duct + * tools snap onto. Equipment type and cabinet size are edited in the + * inspector after placement. + */ +const HvacEquipmentTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const [yaw, setYaw] = useState(0) + const yawRef = useRef(0) + + const previewNode = useMemo( + () => HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), name: 'Furnace' }), + [], + ) + const ghost = useMemo(() => { + const group = buildHvacEquipmentGeometry(previewNode) + group.traverse((child) => { + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent): [number, number, number] => { + const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep + return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] + } + + // Grid-snap the cursor, then layer Figma-style alignment so the unit lines + // up with ducts, other equipment, and items as it's placed (Shift = free, + // no snap + no guides). + const resolveAligned = (event: GridEvent): [number, number, number] => + alignDrawPoint(resolve(event), { + applySnap: true, + bypass: event.nativeEvent?.shiftKey === true, + }) + + const onMove = (event: GridEvent) => setCursor(resolveAligned(event)) + + const onClick = (event: GridEvent) => { + const position = resolveAligned(event) + const unit = HvacEquipmentNode.parse({ + ...hvacEquipmentDefinition.defaults(), + name: 'Furnace', + position, + rotation: yawRef.current, + }) + useScene.getState().createNode(unit, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [unit.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key !== 'r' && key !== 'R' && key !== 't' && key !== 'T') return + // Capture-phase + stopPropagation so the editor's selection-rotate + // handler doesn't also spin the previously placed unit. + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + triggerSFX('sfx:item-rotate') + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + clearDrawAlignment() + } + }, [activeLevelId]) + + if (!activeLevelId || !cursor) return null + + return ( + + + + + +
+ R/T rotate + + · + + ⇧ smooth +
+ +
+ ) +} + +export default HvacEquipmentTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 6c67f839..ee5a13b3 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -8,13 +8,22 @@ import { cupolaDefinition } from './cupola' import { doorDefinition } from './door' import { dormerDefinition } from './dormer' import { downspoutDefinition } from './downspout' +import { ductFittingDefinition } from './duct-fitting' +import { ductSegmentDefinition } from './duct-segment' +import { ductTerminalDefinition } from './duct-terminal' import { elevatorDefinition } from './elevator' import { eyebrowVentDefinition } from './eyebrow-vent' import { fenceDefinition } from './fence' import { guideDefinition } from './guide' import { gutterDefinition } from './gutter' +import { hvacEquipmentDefinition } from './hvac-equipment' import { itemDefinition } from './item' import { levelDefinition } from './level' +import { linesetDefinition } from './lineset' +import { liquidLineDefinition } from './liquid-line' +import { pipeFittingDefinition } from './pipe-fitting' +import { pipeSegmentDefinition } from './pipe-segment' +import { pipeTrapDefinition } from './pipe-trap' import { ridgeVentDefinition } from './ridge-vent' import { roofDefinition } from './roof' import { roofSegmentDefinition } from './roof-segment' @@ -88,6 +97,17 @@ export const builtinPlugin: Plugin = { dormerDefinition as unknown as AnyNodeDefinition, gutterDefinition as unknown as AnyNodeDefinition, downspoutDefinition as unknown as AnyNodeDefinition, + // HVAC — Phase 1: round duct segment polyline. Phase 2: fittings + ports. + ductSegmentDefinition as unknown as AnyNodeDefinition, + ductFittingDefinition as unknown as AnyNodeDefinition, + ductTerminalDefinition as unknown as AnyNodeDefinition, + hvacEquipmentDefinition as unknown as AnyNodeDefinition, + linesetDefinition as unknown as AnyNodeDefinition, + liquidLineDefinition as unknown as AnyNodeDefinition, + // DWV plumbing — Phase 2 of the research doc's plan. + pipeSegmentDefinition as unknown as AnyNodeDefinition, + pipeFittingDefinition as unknown as AnyNodeDefinition, + pipeTrapDefinition as unknown as AnyNodeDefinition, ], } @@ -100,13 +120,22 @@ export { cupolaDefinition } from './cupola' export { doorDefinition } from './door' export { dormerDefinition } from './dormer' export { downspoutDefinition } from './downspout' +export { ductFittingDefinition } from './duct-fitting' +export { ductSegmentDefinition } from './duct-segment' +export { ductTerminalDefinition } from './duct-terminal' export { elevatorDefinition } from './elevator' export { eyebrowVentDefinition } from './eyebrow-vent' export { fenceDefinition } from './fence' export { guideDefinition } from './guide' export { gutterDefinition } from './gutter' +export { hvacEquipmentDefinition } from './hvac-equipment' export { itemDefinition } from './item' export { levelDefinition } from './level' +export { linesetDefinition } from './lineset' +export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line' +export { pipeFittingDefinition } from './pipe-fitting' +export { pipeSegmentDefinition } from './pipe-segment' +export { pipeTrapDefinition } from './pipe-trap' export { ridgeVentDefinition } from './ridge-vent' export { roofDefinition } from './roof' export { roofSegmentDefinition } from './roof-segment' diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 34255675..c501c986 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -322,7 +322,7 @@ export const itemDefinition: NodeDefinition = { presentation: { label: 'Item', description: 'A catalog-backed item (furniture, fixtures, decorations).', - icon: { kind: 'url', src: '/icons/item.png' }, + icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', paletteOrder: 10, }, diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index 1e7cfd3b..f32c7ce4 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -1,6 +1,6 @@ 'use client' -import type { ItemNode } from '@pascal-app/core' +import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core' import { type PlacementState, triggerSFX, @@ -67,6 +67,32 @@ function getInitialState(node: ItemNode): PlacementState { shelfId: null, } } + // A floor item resting on a host surface (table / counter / shelf) starts in + // that surface, not 'floor', so the first pointer move runs the surface move + // handler — which preserves the grab offset — instead of a fresh `enter()` + // that snaps the item's origin under the cursor. Without this the item + // teleports the instant it's grabbed. + const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined + if (parent?.type === 'item') { + return { + surface: 'item-surface', + wallId: null, + roofSegmentId: null, + ceilingId: null, + surfaceItemId: node.parentId, + shelfId: null, + } + } + if (parent?.type === 'shelf') { + return { + surface: 'shelf-surface', + wallId: null, + roofSegmentId: null, + ceilingId: null, + surfaceItemId: null, + shelfId: node.parentId, + } + } return { surface: 'floor', wallId: null, @@ -102,7 +128,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { : getInitialState(node), // Preserve the original item's scale so Y-position calculations use the correct height. defaultScale: isNew ? node.scale : undefined, - preserveFloorDragOffset: true, + preserveDragOffset: true, initDraft: (gridPosition) => { if (isNew) { // Duplicate: floor items get a draft immediately; wall/ceiling diff --git a/packages/nodes/src/item/panel.tsx b/packages/nodes/src/item/panel.tsx index d484410f..1ef946f4 100644 --- a/packages/nodes/src/item/panel.tsx +++ b/packages/nodes/src/item/panel.tsx @@ -100,7 +100,7 @@ export default function ItemPanel() { return ( = { presentation: { label: 'Level', description: 'A single floor of a building, holding walls / slabs / ceilings / items.', - icon: { kind: 'url', src: '/icons/level.png' }, + icon: { kind: 'url', src: '/icons/level.webp' }, paletteSection: 'site', paletteOrder: 7, }, diff --git a/packages/nodes/src/lineset/connect.test.ts b/packages/nodes/src/lineset/connect.test.ts new file mode 100644 index 00000000..5fe7b9d4 --- /dev/null +++ b/packages/nodes/src/lineset/connect.test.ts @@ -0,0 +1,124 @@ +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') + }) +}) diff --git a/packages/nodes/src/lineset/connect.ts b/packages/nodes/src/lineset/connect.ts new file mode 100644 index 00000000..5a88a06f --- /dev/null +++ b/packages/nodes/src/lineset/connect.ts @@ -0,0 +1,98 @@ +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] } +} diff --git a/packages/nodes/src/lineset/definition.ts b/packages/nodes/src/lineset/definition.ts new file mode 100644 index 00000000..03e40495 --- /dev/null +++ b/packages/nodes/src/lineset/definition.ts @@ -0,0 +1,132 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { buildLinesetFloorplan } from './floorplan' +import { buildLinesetGeometry } from './geometry' +import { linesetParametrics } from './parametrics' +import { LinesetNode } from './schema' + +/** + * Refrigerant lineset — the copper suction + liquid pair joining a split + * system's outdoor condenser to its indoor coil. The refrigerant-side + * sibling of `duct-segment`: same polyline model and draw tool, but it + * snaps onto refrigerant service ports instead of duct collars. + * + * Composition: `def.geometry` only, plus a selection-time path-handle + * system shared in spirit with the duct segment. The framework's + * `` mounts an empty group; `` + * fills it via `buildLinesetGeometry` on dirty. + */ +export const linesetDefinition: NodeDefinition = { + kind: 'lineset', + schemaVersion: 1, + schema: LinesetNode, + category: 'utility', + distributionRole: 'run', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [2, 0, 0], + ], + suctionDiameter: 0.875, + liquidDiameter: 0.375, + insulated: true, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: linesetParametrics, + + geometry: buildLinesetGeometry, + geometryKey: (n) => JSON.stringify([n.path, n.suctionDiameter, n.liquidDiameter, n.insulated]), + + // Open run ends as typed refrigerant ports — directions point outward + // along the path tangent so they mate flush onto a service valve. Path + // coords are already level-local, so no transform is needed. + ports: (n) => { + if (n.path.length < 2) return [] + const diameter = n.suctionDiameter + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { + id: 'start', + position: first, + direction: unit(first, second), + diameter, + system: 'refrigerant', + }, + { + id: 'end', + position: last, + direction: unit(last, prev), + diameter, + system: 'refrigerant', + }, + ] + }, + + floorplan: buildLinesetFloorplan, + + // 2D selection-time path-point handles — the floor-plan twin of the 3D + // `affordanceTools.selection` handles. The builder emits an + // `endpoint-handle` per path vertex; this drags the matching point. + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('lineset'), + }, + + // Selection-time path-point handles (drag to edit a committed run). + // Editor-only UI (reads gridSnapStep, renders DimensionPill), so it + // mounts via the editor's SelectionAffordanceManager — not `def.system`, + // which the viewer package mounts for the read-only route. + affordanceTools: { + selection: () => import('./selection'), + // Ghost-preview duplicate / move (the refrigerant-loop sibling of + // duct-segment's mover). Duplicate is pure drag-to-place: a translucent + // copy of the run, wrapped in a footprint bounding box, follows the + // cursor and only lands on the commit click — nothing is inserted into + // the scene before that. + move: () => import('./move-tool'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start lineset' }, + { key: 'Click again', label: 'Place it (locked to 45°)' }, + { key: 'Shift', label: 'Free angle' }, + { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, + { key: 'Esc', label: 'Cancel start point' }, + ], + + presentation: { + label: 'Lineset', + description: + 'Refrigerant lineset — copper suction + liquid pair joining a condenser to the indoor coil.', + icon: { kind: 'url', src: '/icons/lineset.webp' }, + paletteSection: 'structure', + paletteOrder: 93, + }, + + mcp: { + description: + 'A refrigerant lineset defined as a polyline: an insulated suction line plus a bare liquid line, joining an HVAC condenser to its indoor coil. Snaps onto refrigerant service ports.', + }, +} diff --git a/packages/nodes/src/lineset/floorplan.ts b/packages/nodes/src/lineset/floorplan.ts new file mode 100644 index 00000000..7de546cc --- /dev/null +++ b/packages/nodes/src/lineset/floorplan.ts @@ -0,0 +1,89 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { LinesetNode } from './schema' + +const COPPER_LINE = '#b06b3f' +const BODY_COLOR = '#9ca3af' + +/** + * Floor-plan representation of a lineset: the path drawn at the suction + * jacket's real width with a dashed copper centerline. Vertical risers + * collapse to a point in plan; consecutive duplicate plan points are + * dropped so they don't render zero-length artifacts. + */ +export function buildLinesetFloorplan( + node: LinesetNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + const points: FloorplanPoint[] = [] + // Plan point k ← original path index indexMap[k] (risers collapse to one + // plan point), so the path-point drag handle edits the right vertex. + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + const widthM = Math.max(node.suctionDiameter, node.liquidDiameter) * INCHES_TO_METERS + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'circle', + cx: p[0], + cy: p[1], + r: widthM, + fill: BODY_COLOR, + stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE, + strokeWidth: 0.02, + opacity: 0.9, + } + } + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points, + stroke: showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR, + strokeWidth: widthM * 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.8, + }, + { + kind: 'polyline', + points, + stroke: COPPER_LINE, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + strokeDasharray: '4 3', + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: 0.9, + }, + ] + + // Selection chrome: one draggable handle per path vertex (2D twin of the + // 3D selection handles). Routes to the shared `move-path-point` affordance. + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/lineset/geometry.ts b/packages/nodes/src/lineset/geometry.ts new file mode 100644 index 00000000..96360835 --- /dev/null +++ b/packages/nodes/src/lineset/geometry.ts @@ -0,0 +1,105 @@ +import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { LinesetNode } from './schema' + +const RADIAL_SEGMENTS = 16 + +const COPPER_COLOR = '#b06b3f' +// Light foam sleeve. Real Armaflex is black, but a light jacket reads +// cleaner against the scene and matches the white pipe materials. +const INSULATION_COLOR = '#e8e8ea' + +const UP = new Vector3(0, 1, 0) + +/** + * Foam-jacket thickness (meters) wrapped around the line when `insulated`. A + * real ~3/4" black Armaflex sleeve adds ~3/8" of wall; this matches that so an + * insulated line reads visibly fatter than the bare copper underneath. + */ +const INSULATION_THICKNESS_M = 0.01 + +/** Cylinder spanning `start`→`end` at `radius`, named for debugging. */ +function buildRun( + start: Vector3, + end: Vector3, + radius: number, + material: MeshStandardMaterial, + name: string, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + const mesh = new Mesh( + new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false), + material, + ) + mesh.name = name + mesh.position.copy(start).addScaledVector(dir, length / 2) + mesh.quaternion.setFromUnitVectors(UP, dir) + return mesh +} + +/** + * Pure geometry builder for a refrigerant lineset: a single copper line that + * follows the node path centerline, optionally wrapped in a foam jacket. + * + * 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 + * rather than rendering both together off one path. Joint spheres cap interior + * corners so turns read as continuous pipe. + * + * Children are level-local meters; `` owns the + * node transform (identity today — the path is absolute within the level). + */ +export function buildLinesetGeometry(node: LinesetNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const copperR = (node.suctionDiameter * INCHES_TO_METERS) / 2 + const jacketR = node.insulated ? copperR + INSULATION_THICKNESS_M : copperR + + const copperMat = new MeshStandardMaterial({ + color: COPPER_COLOR, + metalness: 0.8, + roughness: 0.3, + }) + const insulationMat = new MeshStandardMaterial({ + color: INSULATION_COLOR, + metalness: 0.1, + roughness: 0.9, + }) + + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + for (let i = 0; i < points.length - 1; i++) { + const copper = buildRun(points[i]!, points[i + 1]!, copperR, copperMat, `lineset-copper-${i}`) + if (copper) group.add(copper) + if (node.insulated) { + const jacket = buildRun( + points[i]!, + points[i + 1]!, + jacketR, + insulationMat, + `lineset-jacket-${i}`, + ) + if (jacket) group.add(jacket) + } + } + + // Joint caps at interior corners so turns read as continuous pipe. + for (let i = 1; i < points.length - 1; i++) { + const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat) + joint.name = `lineset-copper-joint-${i}` + joint.position.copy(points[i] as Vector3) + group.add(joint) + if (node.insulated) { + const jJoint = new Mesh(new SphereGeometry(jacketR, RADIAL_SEGMENTS, 10), insulationMat) + jJoint.name = `lineset-jacket-joint-${i}` + jJoint.position.copy(points[i] as Vector3) + group.add(jJoint) + } + } + + return group +} diff --git a/packages/nodes/src/lineset/index.ts b/packages/nodes/src/lineset/index.ts new file mode 100644 index 00000000..5689ef85 --- /dev/null +++ b/packages/nodes/src/lineset/index.ts @@ -0,0 +1,4 @@ +export { type LinesetConnectPlan, planLinesetConnect } from './connect' +export { linesetDefinition } from './definition' +export { buildLinesetGeometry } from './geometry' +export { LinesetNode } from './schema' diff --git a/packages/nodes/src/lineset/move-tool.tsx b/packages/nodes/src/lineset/move-tool.tsx new file mode 100644 index 00000000..a025b534 --- /dev/null +++ b/packages/nodes/src/lineset/move-tool.tsx @@ -0,0 +1,304 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + LinesetNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 +const IN_TO_M = 0.0254 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +function pathCenterXZ(path: readonly Vec3[]): [number, number] { + let x = 0 + let z = 0 + for (const p of path) { + x += p[0] + z += p[2] + } + const n = path.length || 1 + return [x / n, z / n] +} + +/** The lineset's footprint radius (meters) — half the suction OD (the + * bigger of the pair), used as box / footprint padding and ghost radius. */ +function linesetRadiusM(lineset: LinesetNode): number { + return (lineset.suctionDiameter * IN_TO_M) / 2 +} + +/** XZ bounds of a path padded by the lineset's radius. */ +function pathAabb(path: readonly Vec3[], r: number): Aabb2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const p of path) { + if (p[0] < minX) minX = p[0] + if (p[0] > maxX) maxX = p[0] + if (p[2] < minZ) minZ = p[2] + if (p[2] > maxZ) maxZ = p[2] + } + return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r } +} + +/** + * Ghost-preview duplicate / move tool for refrigerant linesets — the + * refrigerant-loop sibling of `MovePipeSegmentTool`. A lineset is a + * suction + liquid copper pair; the ghost stands in with a single + * translucent cylinder at the suction OD per section (mirrors the draw + * tool's `PreviewSegment`). + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is + * inserted into the scene until the commit click. A translucent ghost of + * the run rides the cursor inside a footprint bounding box — the same + * affordance other items get — and Figma-style alignment guides snap the + * box's edges to nearby geometry. The next grid click calls `createNode`; + * Esc discards. The run's Y coords ride along untouched: the move only + * shifts XZ. + * + * **Move** (existing run): the real node's mesh is hidden while the same + * ghost + box tracks the cursor; the commit click writes the translated + * `path` and reveals it, Esc reveals it unchanged. + * + * Wired via `def.affordanceTools.move`. + */ +export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const lineset = node as LinesetNode + const originalPathRef = useRef(lineset.path.map((p) => [...p] as Vec3)) + + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [previewPath, setPreviewPath] = useState(originalPathRef.current) + const previewPathRef = useRef(originalPathRef.current) + const hasMovedRef = useRef(false) + const activatedAtRef = useRef(Date.now()) + const prevSnapRef = useRef<[number, number] | null>(null) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const originalPath = originalPathRef.current + const [centerX, centerZ] = pathCenterXZ(originalPath) + const r = linesetRadiusM(lineset) + const baseAabb = pathAabb(originalPath, r) + + useScene.temporal.getState().pause() + let committed = false + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing run: hide its 3D MESH imperatively (NOT the store + // `visible` flag — the 2D floor plan skips `visible:false` nodes, so a + // store hide makes the run vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + const setPreview = (path: Vec3[]) => { + previewPathRef.current = path + setPreviewPath(path) + } + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + const snap = bypass ? (v: number) => v : snapToGridStep + let dx = snap(event.localPosition[0] - centerX) + let dz = snap(event.localPosition[2] - centerZ) + + // Figma-style alignment: snap the run's footprint box edges onto + // nearby geometry and publish the guides (Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: baseAabb.minX + dx, + maxX: baseAabb.maxX + dx, + minZ: baseAabb.minZ + dz, + maxZ: baseAabb.maxZ + dz, + } + const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + dx += sdx + dz += sdz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + + const cur: [number, number] = [centerX + dx, centerZ + dz] + if ( + !bypass && + (!prevSnapRef.current || + prevSnapRef.current[0] !== cur[0] || + prevSnapRef.current[1] !== cur[1]) + ) { + triggerSFX('sfx:grid-snap') + } + prevSnapRef.current = cur + hasMovedRef.current = true + setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMovedRef.current) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + const finalPath = previewPathRef.current + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = LinesetNode.parse({ + ...(node as Record), + path: finalPath, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [lineset, isNew, node]) + + const segments: Array<{ a: Vec3; b: Vec3 }> = [] + for (let i = 0; i < previewPath.length - 1; i++) { + segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! }) + } + + // Footprint box spanning the whole run (axis-aligned), drawn around the + // ghost the same way items get one. Recomputed from the live preview path. + const r = linesetRadiusM(lineset) + const box = pathAabb(previewPath, r) + const boxY = previewPath[0]?.[1] ?? 0 + + return ( + + {segments.map((seg, i) => ( + + ))} + + + ) +} + +/** Translucent stand-in for one lineset section — mirrors the draw tool's + * `PreviewSegment` so the ghost matches what actually lands. */ +function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default MoveLinesetTool diff --git a/packages/nodes/src/lineset/parametrics.ts b/packages/nodes/src/lineset/parametrics.ts new file mode 100644 index 00000000..d55eb784 --- /dev/null +++ b/packages/nodes/src/lineset/parametrics.ts @@ -0,0 +1,37 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { LinesetNode } from './schema' + +export const linesetParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Lines', + fields: [ + { + key: 'suctionDiameter', + kind: 'number', + unit: 'in', + min: 0.25, + max: 1.5, + step: 0.125, + }, + { + key: 'liquidDiameter', + kind: 'number', + unit: 'in', + min: 0.125, + max: 0.75, + step: 0.125, + }, + ], + }, + { + label: 'Insulation', + fields: [ + { + key: 'insulated', + kind: 'boolean', + }, + ], + }, + ], +} diff --git a/packages/nodes/src/lineset/schema.ts b/packages/nodes/src/lineset/schema.ts new file mode 100644 index 00000000..f987bfd3 --- /dev/null +++ b/packages/nodes/src/lineset/schema.ts @@ -0,0 +1 @@ +export { LinesetNode } from '@pascal-app/core' diff --git a/packages/nodes/src/lineset/selection.tsx b/packages/nodes/src/lineset/selection.tsx new file mode 100644 index 00000000..e348d0c1 --- /dev/null +++ b/packages/nodes/src/lineset/selection.tsx @@ -0,0 +1,282 @@ +'use client' + +import { + 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 +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(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(, target, undefined) +} + +const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => { + const { camera, gl } = useThree() + const unit = useViewer((s) => s.unit) + const [draggingIndex, setDraggingIndex] = useState(null) + const [hoverIndex, setHoverIndex] = useState(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) => { + 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 ( + + {lineset.path.map((p, i) => { + const active = draggingIndex === i + const hovered = hoverIndex === i + return ( + { + 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} + > + + + + ) + })} + {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 ( + + ({ + key: axis, + prefix: axis.toUpperCase(), + value: deltas[i]!, + signed: true, + }))} + primary={primary} + unit={unit} + /> + + ) + })()} + + ) +} + +export default LinesetSelectionAffordance diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx new file mode 100644 index 00000000..d7a057a5 --- /dev/null +++ b/packages/nodes/src/lineset/tool.tsx @@ -0,0 +1,388 @@ +'use client' + +import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports' +import { planLinesetConnect } from './connect' +import { linesetDefinition } from './definition' + +/** + * One-segment-at-a-time placement tool for refrigerant linesets — the + * refrigerant-loop sibling of the duct-segment tool. + * + * Mouse-driven model: + * - **First click** anchors the run start. Within range of a refrigerant + * service port (a condenser / coil valve, or another lineset's end) it + * snaps onto the port so a run mates flush. + * - **Second click** commits a two-point lineset and re-arms the tool. + * - The in-flight end is angle-locked to the nearest 45° step in XZ from + * the start; Y stays at the start's height. Hold **Shift** to release. + * - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse + * motion drives Y. Click commits the riser segment. + * - Esc clears an anchored start point. + * + * Snapping is restricted to refrigerant ports, so a lineset never grabs a + * supply/return duct collar. + */ +const PREVIEW_OPACITY = 0.6 +const PREVIEW_COLOR = '#b06b3f' +/** Snap radius (meters) for joining onto a refrigerant port. */ +const ENDPOINT_SNAP_RADIUS_M = 0.5 +/** Angle step (radians) for the XZ angle lock — 45°. */ +const ANGLE_STEP_RAD = Math.PI / 4 +/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */ +const ALT_PIXELS_PER_METER = 100 +const ALT_Y_MIN_M = -3 +const ALT_Y_MAX_M = 10 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** Nearest refrigerant port within snap range on the XZ plane, as a + * position tuple. Y is ignored for the distance check; the snap adopts the + * port's full 3D position. */ +function findNearbyPort(point: [number, number, number]): [number, number, number] | null { + const port = findNearestPortXZ( + point, + collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }), + ENDPOINT_SNAP_RADIUS_M, + ) + return port ? [port.position[0], port.position[1], port.position[2]] : null +} + +function projectToAngleLock( + from: [number, number, number], + raw: [number, number, number], +): [number, number, number] { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +const LinesetTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const cursorRef = useRef(null) + const [draftPoints, setDraftPoints] = useState>([]) + const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null) + const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + const [altActive, setAltActive] = useState(false) + const draftRef = useRef(draftPoints) + draftRef.current = draftPoints + const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) + const lastClientYRef = useRef(null) + + useEffect(() => { + if (!activeLevelId) return + + const commitSegment = (start: [number, number, number], end: [number, number, number]) => { + const sameSpot = + Math.abs(start[0] - end[0]) < 1e-4 && + Math.abs(start[1] - end[1]) < 1e-4 && + Math.abs(start[2] - end[2]) < 1e-4 + if (sameSpot) return + + // Fold into any existing run that shares this segment's endpoint, so + // two runs meeting at a coordinate become one mitered path instead of + // overlapping nodes. Only same-level runs are candidates — lineset + // paths are level-local. + 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({ + ...linesetDefinition.defaults(), + name: 'Lineset', + path: plan.path, + }) + scene.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') + setDraftPoints([]) + setSnapTarget(null) + altAnchorRef.current = null + setAltActive(false) + } + + const resolveSnappedPoint = ( + event: GridEvent, + ): { point: [number, number, number]; snapped: [number, number, number] | null } => { + const last = draftRef.current.at(-1) + if (!last) { + const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] + if (event.nativeEvent?.altKey !== true) { + const target = findNearbyPort(raw) + if (target) return { point: target, snapped: target } + } + const step = useEditor.getState().gridSnapStep + return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null } + } + const rawXZ: [number, number, number] = [ + event.localPosition[0], + last[1], + event.localPosition[2], + ] + const shift = event.nativeEvent?.shiftKey === true + const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) + if (event.nativeEvent?.altKey !== true && !shift) { + const target = findNearbyPort(rawXZ) + if (target) return { point: target, snapped: target } + } + const step = useEditor.getState().gridSnapStep + return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null } + } + + const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => { + const anchor = altAnchorRef.current + const last = draftRef.current.at(-1) + if (!anchor || !last) return null + const step = useEditor.getState().gridSnapStep + const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER + const snappedDy = snap(dy, step) + const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) + return [last[0], y, last[2]] + } + + // Resolve the cursor point (port / grid / angle snap) then layer + // Figma-style alignment so a lineset lines up with other runs, equipment, + // and items as it's drawn. Free point (first vertex / Shift) snaps; an + // angle-locked continuation shows the guide passively. Port snap or Alt + // bypasses alignment. + const resolveAlignedPoint = (event: GridEvent) => { + const r = resolveSnappedPoint(event) + const hasStart = draftRef.current.length > 0 + const shift = event.nativeEvent?.shiftKey === true + const alt = event.nativeEvent?.altKey === true + const point = alignDrawPoint(r.point, { + applySnap: !hasStart || shift, + bypass: alt || r.snapped !== null, + }) + return { ...r, point } + } + + const onMove = (event: GridEvent) => { + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY + if (typeof clientY === 'number') lastClientYRef.current = clientY + if (altAnchorRef.current && typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point) { + clearDrawAlignment() + setCursorPos(point) + setSnapTarget(null) + return + } + } + const { point, snapped } = resolveAlignedPoint(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + const start = draftRef.current.at(-1) + if (altAnchorRef.current && start) { + const clientY = + (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current + if (typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point && Math.abs(point[1] - start[1]) >= 1e-4) { + commitSegment(start, point) + } + } + return + } + const { point } = resolveAlignedPoint(event) + if (!start) { + triggerSFX('sfx:grid-snap') + setDraftPoints([point]) + return + } + commitSegment(start, point) + } + + const enterAltMode = () => { + const last = draftRef.current.at(-1) + if (!last || lastClientYRef.current === null) return + if (altAnchorRef.current) return + altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] } + setAltActive(true) + } + + const exitAltMode = () => { + if (!altAnchorRef.current) return + altAnchorRef.current = null + setAltActive(false) + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'Alt') { + e.preventDefault() + enterAltMode() + } + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + e.preventDefault() + exitAltMode() + } + } + + const onCancel = () => { + clearDrawAlignment() + if (draftRef.current.length === 0) return + markToolCancelConsumed() + setDraftPoints([]) + setCursorPos(null) + setSnapTarget(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + altAnchorRef.current = null + clearDrawAlignment() + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = [] + for (let i = 0; i < draftPoints.length - 1; i++) { + previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! }) + } + const last = draftPoints.at(-1) + if (last && cursorPos) { + previewSegments.push({ a: last, b: cursorPos }) + } + + const pillParts = cursorPos + ? (['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!, + signed: !!last, + })) + : null + const pillPrimary = + last && cursorPos + ? altActive + ? 'y' + : Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2]) + ? 'x' + : 'z' + : undefined + + return ( + + {/* Cursor marker — the same ground ring + vertical line + tool-icon + badge the duct draw tool shows in 3D (icon resolved from the active + `lineset` structure-tools entry). In 2D the floorplan overlay draws + this for every tool; in 3D each tool renders its own. The dimension + pill rides just above the cursor. */} + {cursorPos && ( + <> + + {pillParts && ( + + + + + + )} + + )} + {snapTarget && ( + + + + + )} + {draftPoints.map((p, i) => ( + + + + + ))} + {previewSegments.map((seg, i) => ( + + ))} + + ) +} + +function PreviewSegment({ a, b }: { a: [number, number, number]; b: [number, number, number] }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + // Default suction OD (~7/8") for the ghost. + const radius = (0.875 * 0.0254) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default LinesetTool diff --git a/packages/nodes/src/liquid-line/connect.ts b/packages/nodes/src/liquid-line/connect.ts new file mode 100644 index 00000000..74c7afab --- /dev/null +++ b/packages/nodes/src/liquid-line/connect.ts @@ -0,0 +1,98 @@ +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] } +} diff --git a/packages/nodes/src/liquid-line/definition.ts b/packages/nodes/src/liquid-line/definition.ts new file mode 100644 index 00000000..87a0cc69 --- /dev/null +++ b/packages/nodes/src/liquid-line/definition.ts @@ -0,0 +1,124 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { buildLiquidLineFloorplan } from './floorplan' +import { buildLiquidLineGeometry } from './geometry' +import { liquidLineParametrics } from './parametrics' +import { LiquidLineNode } from './schema' + +/** + * Standalone refrigerant liquid line — the thin bare-copper line broken out of + * the lineset so it can be drawn on its own. The refrigerant-side sibling of + * `lineset`: same polyline model and draw tool, snapping onto refrigerant + * service ports, but a single thin line. Its tool adds a Follow mode that + * traces an existing lineset's path at an offset. + * + * Composition: `def.geometry` only, plus a selection-time path-handle system + * shared in spirit with the lineset. The framework's `` + * mounts an empty group; `` fills it via + * `buildLiquidLineGeometry` on dirty. + */ +export const liquidLineDefinition: NodeDefinition = { + kind: 'liquid-line', + schemaVersion: 1, + schema: LiquidLineNode, + category: 'utility', + distributionRole: 'run', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [2, 0, 0], + ], + diameter: 0.375, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: liquidLineParametrics, + + geometry: buildLiquidLineGeometry, + geometryKey: (n) => JSON.stringify([n.path, n.diameter]), + + // Open run ends as typed refrigerant ports — directions point outward along + // the path tangent so they mate flush onto a service valve. Path coords are + // already level-local, so no transform is needed. + ports: (n) => { + if (n.path.length < 2) return [] + const diameter = n.diameter + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { + id: 'start', + position: first, + direction: unit(first, second), + diameter, + system: 'refrigerant', + }, + { + id: 'end', + position: last, + direction: unit(last, prev), + diameter, + system: 'refrigerant', + }, + ] + }, + + floorplan: buildLiquidLineFloorplan, + + // 2D selection-time path-point handles — the floor-plan twin of the 3D + // `affordanceTools.selection` handles. + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('liquid-line'), + }, + + // Selection-time path-point handles (drag to edit a committed run) and the + // ghost-preview duplicate / move tool (drag-to-place a translucent copy). + affordanceTools: { + selection: () => import('./selection'), + move: () => import('./move-tool'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start liquid line' }, + { key: 'Click again', label: 'Place it (locked to 45°)' }, + { key: 'Shift', label: 'Free angle' }, + { key: 'Alt + drag', label: 'Go vertical ↕, click to place' }, + { key: 'F', label: 'Follow: trace a lineset' }, + { key: 'Esc', label: 'Cancel' }, + ], + + presentation: { + label: 'Liquid Line', + description: + 'Standalone refrigerant liquid line — a thin bare-copper run; Follow mode traces an existing lineset.', + icon: { kind: 'url', src: '/icons/lineset.webp' }, + paletteSection: 'structure', + paletteOrder: 94, + }, + + mcp: { + description: + 'A standalone refrigerant liquid line defined as a polyline of thin bare copper. Snaps onto refrigerant service ports; can be traced alongside an existing lineset.', + }, +} diff --git a/packages/nodes/src/liquid-line/floorplan.ts b/packages/nodes/src/liquid-line/floorplan.ts new file mode 100644 index 00000000..13b03943 --- /dev/null +++ b/packages/nodes/src/liquid-line/floorplan.ts @@ -0,0 +1,77 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { LiquidLineNode } from './schema' + +const COPPER_LINE = '#b06b3f' + +/** + * Floor-plan representation of a liquid line: a single thin copper polyline at + * the line's real width. Vertical risers collapse to a point in plan; + * consecutive duplicate plan points are dropped so they don't render + * zero-length artifacts. + */ +export function buildLiquidLineFloorplan( + node: LiquidLineNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + const points: FloorplanPoint[] = [] + // Plan point k ← original path index indexMap[k] (risers collapse to one + // plan point), so the path-point drag handle edits the right vertex. + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + const widthM = node.diameter * INCHES_TO_METERS + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'circle', + cx: p[0], + cy: p[1], + r: Math.max(widthM, 0.02), + fill: COPPER_LINE, + stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE, + strokeWidth: 0.02, + opacity: 0.9, + } + } + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points, + stroke: showSelectedChrome && palette ? palette.selectedStroke : COPPER_LINE, + strokeWidth: Math.max(widthM * 2, 0.04), + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.85, + }, + ] + + // Selection chrome: one draggable handle per path vertex (2D twin of the + // 3D selection handles). Routes to the shared `move-path-point` affordance. + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/liquid-line/geometry.ts b/packages/nodes/src/liquid-line/geometry.ts new file mode 100644 index 00000000..0a99afab --- /dev/null +++ b/packages/nodes/src/liquid-line/geometry.ts @@ -0,0 +1,66 @@ +import { CylinderGeometry, Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { LiquidLineNode } from './schema' + +const RADIAL_SEGMENTS = 16 +const COPPER_COLOR = '#b06b3f' + +const UP = new Vector3(0, 1, 0) + +/** Cylinder spanning `start`→`end` at `radius`, named for debugging. */ +function buildRun( + start: Vector3, + end: Vector3, + radius: number, + material: MeshStandardMaterial, + name: string, +): Mesh | null { + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-6) return null + dir.normalize() + const mesh = new Mesh( + new CylinderGeometry(radius, radius, length, RADIAL_SEGMENTS, 1, false), + material, + ) + mesh.name = name + mesh.position.copy(start).addScaledVector(dir, length / 2) + mesh.quaternion.setFromUnitVectors(UP, dir) + return mesh +} + +/** + * Pure geometry builder for a standalone liquid line: a single thin bare-copper + * cylinder following the node path centerline, with joint spheres capping + * interior corners so turns read as continuous pipe. + * + * Children are level-local meters; `` owns the node + * transform (identity today — the path is absolute within the level). + */ +export function buildLiquidLineGeometry(node: LiquidLineNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const copperMat = new MeshStandardMaterial({ + color: COPPER_COLOR, + metalness: 0.8, + roughness: 0.3, + }) + + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + for (let i = 0; i < points.length - 1; i++) { + const run = buildRun(points[i]!, points[i + 1]!, radius, copperMat, `liquid-line-${i}`) + if (run) group.add(run) + } + + for (let i = 1; i < points.length - 1; i++) { + const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat) + joint.name = `liquid-line-joint-${i}` + joint.position.copy(points[i] as Vector3) + group.add(joint) + } + + return group +} diff --git a/packages/nodes/src/liquid-line/index.ts b/packages/nodes/src/liquid-line/index.ts new file mode 100644 index 00000000..cd63be63 --- /dev/null +++ b/packages/nodes/src/liquid-line/index.ts @@ -0,0 +1,5 @@ +export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect' +export { liquidLineDefinition } from './definition' +export { buildLiquidLineGeometry } from './geometry' +export { useLiquidLineToolOptions } from './options' +export { LiquidLineNode } from './schema' diff --git a/packages/nodes/src/liquid-line/move-tool.tsx b/packages/nodes/src/liquid-line/move-tool.tsx new file mode 100644 index 00000000..3e5c8141 --- /dev/null +++ b/packages/nodes/src/liquid-line/move-tool.tsx @@ -0,0 +1,300 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + LiquidLineNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 +const IN_TO_M = 0.0254 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +function pathCenterXZ(path: readonly Vec3[]): [number, number] { + let x = 0 + let z = 0 + for (const p of path) { + x += p[0] + z += p[2] + } + const n = path.length || 1 + return [x / n, z / n] +} + +/** The liquid line's footprint radius (meters) — half its OD, used as box / + * footprint padding and ghost radius. */ +function liquidLineRadiusM(line: LiquidLineNode): number { + return (line.diameter * IN_TO_M) / 2 +} + +/** XZ bounds of a path padded by the line's radius. */ +function pathAabb(path: readonly Vec3[], r: number): Aabb2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const p of path) { + if (p[0] < minX) minX = p[0] + if (p[0] > maxX) maxX = p[0] + if (p[2] < minZ) minZ = p[2] + if (p[2] > maxZ) maxZ = p[2] + } + return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r } +} + +/** + * Ghost-preview duplicate / move tool for liquid lines — the path-mover sibling + * of `MoveLinesetTool`. A translucent cylinder at the line's OD per section + * stands in for the run (mirrors the draw tool's `PreviewSegment`). + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is inserted + * into the scene until the commit click. A translucent ghost rides the cursor + * inside a footprint bounding box and Figma-style alignment guides snap the + * box's edges to nearby geometry. The next grid click calls `createNode`; Esc + * discards. The run's Y coords ride along untouched: the move only shifts XZ. + * + * **Move** (existing run): the real node's mesh is hidden while the same ghost + * + box tracks the cursor; the commit click writes the translated `path` and + * reveals it, Esc reveals it unchanged. + * + * Wired via `def.affordanceTools.move`. + */ +export const MoveLiquidLineTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const line = node as LiquidLineNode + const originalPathRef = useRef(line.path.map((p) => [...p] as Vec3)) + + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [previewPath, setPreviewPath] = useState(originalPathRef.current) + const previewPathRef = useRef(originalPathRef.current) + const hasMovedRef = useRef(false) + const activatedAtRef = useRef(Date.now()) + const prevSnapRef = useRef<[number, number] | null>(null) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const originalPath = originalPathRef.current + const [centerX, centerZ] = pathCenterXZ(originalPath) + const r = liquidLineRadiusM(line) + const baseAabb = pathAabb(originalPath, r) + + useScene.temporal.getState().pause() + let committed = false + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing run: hide its 3D MESH imperatively (NOT the store + // `visible` flag — the 2D floor plan skips `visible:false` nodes, so a + // store hide makes the run vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + const setPreview = (path: Vec3[]) => { + previewPathRef.current = path + setPreviewPath(path) + } + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + const snap = bypass ? (v: number) => v : snapToGridStep + let dx = snap(event.localPosition[0] - centerX) + let dz = snap(event.localPosition[2] - centerZ) + + // Figma-style alignment: snap the run's footprint box edges onto nearby + // geometry and publish the guides (Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: baseAabb.minX + dx, + maxX: baseAabb.maxX + dx, + minZ: baseAabb.minZ + dz, + maxZ: baseAabb.maxZ + dz, + } + const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + dx += sdx + dz += sdz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + + const cur: [number, number] = [centerX + dx, centerZ + dz] + if ( + !bypass && + (!prevSnapRef.current || + prevSnapRef.current[0] !== cur[0] || + prevSnapRef.current[1] !== cur[1]) + ) { + triggerSFX('sfx:grid-snap') + } + prevSnapRef.current = cur + hasMovedRef.current = true + setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMovedRef.current) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + const finalPath = previewPathRef.current + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = LiquidLineNode.parse({ + ...(node as Record), + path: finalPath, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [line, isNew, node]) + + const segments: Array<{ a: Vec3; b: Vec3 }> = [] + for (let i = 0; i < previewPath.length - 1; i++) { + segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! }) + } + + // Footprint box spanning the whole run (axis-aligned), drawn around the ghost + // the same way items get one. Recomputed from the live preview path. + const r = liquidLineRadiusM(line) + const box = pathAabb(previewPath, r) + const boxY = previewPath[0]?.[1] ?? 0 + + return ( + + {segments.map((seg, i) => ( + + ))} + + + ) +} + +/** Translucent stand-in for one liquid-line section — mirrors the draw tool's + * `PreviewSegment` so the ghost matches what actually lands. */ +function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default MoveLiquidLineTool diff --git a/packages/nodes/src/liquid-line/options.ts b/packages/nodes/src/liquid-line/options.ts new file mode 100644 index 00000000..04c87098 --- /dev/null +++ b/packages/nodes/src/liquid-line/options.ts @@ -0,0 +1,21 @@ +import { create } from 'zustand' + +/** + * Shared draw-time options for the liquid-line tool. Lives in the nodes + * package so both the tool (which reads + key-toggles it) and the app's MEP + * panel (which renders the toggle button) can bind to the same state. + * + * `follow` arms "trace a lineset": while on, clicking an existing lineset + * lays a liquid line beside it along the same path instead of free-drawing. + */ +type LiquidLineToolOptions = { + follow: boolean + setFollow: (value: boolean) => void + toggleFollow: () => void +} + +export const useLiquidLineToolOptions = create((set) => ({ + follow: false, + setFollow: (value) => set({ follow: value }), + toggleFollow: () => set((s) => ({ follow: !s.follow })), +})) diff --git a/packages/nodes/src/liquid-line/parametrics.ts b/packages/nodes/src/liquid-line/parametrics.ts new file mode 100644 index 00000000..c1940375 --- /dev/null +++ b/packages/nodes/src/liquid-line/parametrics.ts @@ -0,0 +1,20 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { LiquidLineNode } from './schema' + +export const liquidLineParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Line', + fields: [ + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 0.125, + max: 0.75, + step: 0.125, + }, + ], + }, + ], +} diff --git a/packages/nodes/src/liquid-line/schema.ts b/packages/nodes/src/liquid-line/schema.ts new file mode 100644 index 00000000..f9b7fbd2 --- /dev/null +++ b/packages/nodes/src/liquid-line/schema.ts @@ -0,0 +1 @@ +export { LiquidLineNode } from '@pascal-app/core' diff --git a/packages/nodes/src/liquid-line/selection.tsx b/packages/nodes/src/liquid-line/selection.tsx new file mode 100644 index 00000000..ed23f9d2 --- /dev/null +++ b/packages/nodes/src/liquid-line/selection.tsx @@ -0,0 +1,282 @@ +'use client' + +import { + 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 +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(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(, target, undefined) +} + +const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => { + const { camera, gl } = useThree() + const unit = useViewer((s) => s.unit) + const [draggingIndex, setDraggingIndex] = useState(null) + const [hoverIndex, setHoverIndex] = useState(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) => { + 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 ( + + {line.path.map((p, i) => { + const active = draggingIndex === i + const hovered = hoverIndex === i + return ( + { + 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} + > + + + + ) + })} + {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 ( + + ({ + key: axis, + prefix: axis.toUpperCase(), + value: deltas[i]!, + signed: true, + }))} + primary={primary} + unit={unit} + /> + + ) + })()} + + ) +} + +export default LiquidLineSelectionAffordance diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx new file mode 100644 index 00000000..745f1f14 --- /dev/null +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -0,0 +1,545 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type GridEvent, + type LinesetNode, + LiquidLineNode, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { offsetPathHorizontal } from '../shared/path-offset' +import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports' +import { planLiquidLineConnect } from './connect' +import { liquidLineDefinition } from './definition' +import { useLiquidLineToolOptions } from './options' + +/** + * One-segment-at-a-time placement tool for standalone liquid lines — the same + * draw model as the lineset tool (the line it used to be a rail of): + * - **First click** anchors the run start; within range of a refrigerant + * service port it snaps onto it so a run mates flush. + * - **Second click** commits a two-point line and re-arms; the in-flight end + * is angle-locked to 45° (Shift frees it), Alt drags it vertical. + * + * **Follow mode** (toggled by the MEP panel's Follow button or the `F` key): + * instead of free-drawing, hover an existing lineset and click — a liquid line + * is laid beside it, tracing the lineset's whole path at a fixed offset on the + * side the cursor is on. This is the "place it exactly next to this" affordance. + */ +const PREVIEW_OPACITY = 0.6 +const PREVIEW_COLOR = '#b06b3f' +/** Snap radius (meters) for joining onto a refrigerant port. */ +const ENDPOINT_SNAP_RADIUS_M = 0.5 +/** Angle step (radians) for the XZ angle lock — 45°. */ +const ANGLE_STEP_RAD = Math.PI / 4 +/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */ +const ALT_PIXELS_PER_METER = 100 +const ALT_Y_MIN_M = -3 +const ALT_Y_MAX_M = 10 + +const IN_TO_M = 0.0254 +/** Default liquid OD (~3/8") — the ghost radius and trace-line size. */ +const DEFAULT_DIAMETER_IN = 0.375 +const GHOST_RADIUS_M = (DEFAULT_DIAMETER_IN * IN_TO_M) / 2 +/** Matches the lineset's foam-jacket thickness so the traced line sits just + * outside an insulated suction line, exactly where the old paired rail was. */ +const INSULATION_THICKNESS_M = 0.01 +/** How close (meters, XZ) the cursor must be to a lineset path to trace it. */ +const FOLLOW_PICK_RADIUS_M = 0.6 +/** Clear-air gap (meters) between the lineset's outer surface and the traced + * liquid line, so the new run reads as its own line instead of fusing onto + * the lineset (~2"). */ +const FOLLOW_GAP_M = 0.05 + +type Vec3 = [number, number, number] + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** Nearest refrigerant port within snap range on the XZ plane, as a position + * tuple. Y is ignored for the distance check; the snap adopts the port's full + * 3D position. */ +function findNearbyPort(point: Vec3): Vec3 | null { + const port = findNearestPortXZ( + point, + collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }), + ENDPOINT_SNAP_RADIUS_M, + ) + return port ? [port.position[0], port.position[1], port.position[2]] : null +} + +function projectToAngleLock(from: Vec3, raw: Vec3): Vec3 { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +/** Distance (XZ) from point `p` to segment `a`→`b`. */ +function distToSegmentXZ(p: Vec3, a: Vec3, b: Vec3): number { + const dx = b[0] - a[0] + const dz = b[2] - a[2] + const len2 = dx * dx + dz * dz + let t = len2 > 0 ? ((p[0] - a[0]) * dx + (p[2] - a[2]) * dz) / len2 : 0 + t = Math.max(0, Math.min(1, t)) + const cx = a[0] + t * dx + const cz = a[2] + t * dz + return Math.hypot(p[0] - cx, p[2] - cz) +} + +/** Center-to-center offset (meters) that drops the liquid line a small gap + * outside the lineset's outer surface, so the two read as separate lines. */ +function traceOffsetMeters(lineset: LinesetNode): number { + const suctionR = (lineset.suctionDiameter * IN_TO_M) / 2 + const jacket = lineset.insulated ? INSULATION_THICKNESS_M : 0 + return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M +} + +type FollowTarget = { lineset: LinesetNode; sign: number } + +/** + * Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the + * cursor, plus which side of it the cursor is on (`sign`, matching + * `offsetPathHorizontal`'s side convention). Restricted to the active level. + */ +function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null { + const scene = useScene.getState() + let best: FollowTarget | null = null + let bestD = FOLLOW_PICK_RADIUS_M + for (const n of Object.values(scene.nodes)) { + if (!n || n.type !== 'lineset') continue + if ((n.parentId as AnyNodeId | null) !== levelId) continue + const ls = n as LinesetNode + if (ls.path.length < 2) continue + for (let i = 0; i < ls.path.length - 1; i++) { + const a = ls.path[i] as Vec3 + const b = ls.path[i + 1] as Vec3 + const d = distToSegmentXZ(point, a, b) + if (d >= bestD) continue + bestD = d + // Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which + // side of the segment the cursor sits on. + const hx = b[0] - a[0] + const hz = b[2] - a[2] + const hlen = Math.hypot(hx, hz) + const sx = hlen > 1e-9 ? -hz / hlen : 0 + const sz = hlen > 1e-9 ? hx / hlen : 0 + const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz + best = { lineset: ls, sign: dot >= 0 ? 1 : -1 } + } + } + return best +} + +/** The offset path a follow-target would trace, or null if degenerate. */ +function tracePath(target: FollowTarget): Vec3[] | null { + const offset = target.sign * traceOffsetMeters(target.lineset) + const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset) + return traced.length >= 2 ? traced : null +} + +const LiquidLineTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const follow = useLiquidLineToolOptions((s) => s.follow) + const cursorRef = useRef(null) + const [draftPoints, setDraftPoints] = useState([]) + const [cursorPos, setCursorPos] = useState(null) + const [snapTarget, setSnapTarget] = useState(null) + const [traceGhost, setTraceGhost] = useState(null) + const [altActive, setAltActive] = useState(false) + const draftRef = useRef(draftPoints) + draftRef.current = draftPoints + const followTargetRef = useRef(null) + const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) + const lastClientYRef = useRef(null) + + // Clear in-flight draft / trace whenever Follow toggles (panel button or F). + // biome-ignore lint/correctness/useExhaustiveDependencies: `follow` is an intentional re-run trigger; the body clears the in-flight draft when it toggles. + useEffect(() => { + setDraftPoints([]) + setTraceGhost(null) + followTargetRef.current = null + altAnchorRef.current = null + setAltActive(false) + }, [follow]) + + // Leaving the tool clears Follow so re-arming it starts in free-draw. + useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), []) + + useEffect(() => { + if (!activeLevelId) return + + const commitSegment = (start: Vec3, end: Vec3) => { + const sameSpot = + Math.abs(start[0] - end[0]) < 1e-4 && + Math.abs(start[1] - end[1]) < 1e-4 && + Math.abs(start[2] - end[2]) < 1e-4 + if (sameSpot) return + + // Fold into any existing run that shares this segment's endpoint, so two + // runs meeting at a coordinate become one mitered path instead of + // overlapping nodes. Only same-level runs are candidates. + const scene = useScene.getState() + const existing = Object.values(scene.nodes).filter( + (n): n is LiquidLineNode => + n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId, + ) + const plan = planLiquidLineConnect(existing, start, end) + + if (plan.kind === 'create') { + const line = LiquidLineNode.parse({ + ...liquidLineDefinition.defaults(), + name: 'Liquid Line', + path: plan.path, + }) + scene.createNode(line, 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') + setDraftPoints([]) + setSnapTarget(null) + altAnchorRef.current = null + setAltActive(false) + } + + // Lay a liquid line beside a lineset, tracing its whole path at the offset. + const commitTrace = (target: FollowTarget) => { + const traced = tracePath(target) + if (!traced) return + const scene = useScene.getState() + const line = LiquidLineNode.parse({ + ...liquidLineDefinition.defaults(), + name: 'Liquid Line', + path: traced, + }) + scene.createNode(line, activeLevelId) + triggerSFX('sfx:item-place') + setTraceGhost(null) + followTargetRef.current = null + } + + const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => { + const last = draftRef.current.at(-1) + if (!last) { + const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]] + if (event.nativeEvent?.altKey !== true) { + const target = findNearbyPort(raw) + if (target) return { point: target, snapped: target } + } + const step = useEditor.getState().gridSnapStep + return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null } + } + const rawXZ: Vec3 = [event.localPosition[0], last[1], event.localPosition[2]] + const shift = event.nativeEvent?.shiftKey === true + const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) + if (event.nativeEvent?.altKey !== true && !shift) { + const target = findNearbyPort(rawXZ) + if (target) return { point: target, snapped: target } + } + const step = useEditor.getState().gridSnapStep + return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null } + } + + const resolveAltVerticalPoint = (clientY: number): Vec3 | null => { + const anchor = altAnchorRef.current + const last = draftRef.current.at(-1) + if (!anchor || !last) return null + const step = useEditor.getState().gridSnapStep + const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER + const snappedDy = snap(dy, step) + const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) + return [last[0], y, last[2]] + } + + const resolveAlignedPoint = (event: GridEvent) => { + const r = resolveSnappedPoint(event) + const hasStart = draftRef.current.length > 0 + const shift = event.nativeEvent?.shiftKey === true + const alt = event.nativeEvent?.altKey === true + const point = alignDrawPoint(r.point, { + applySnap: !hasStart || shift, + bypass: alt || r.snapped !== null, + }) + return { ...r, point } + } + + const onMove = (event: GridEvent) => { + // Follow mode: track the lineset under the cursor and preview its trace. + if (useLiquidLineToolOptions.getState().follow) { + const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]] + clearDrawAlignment() + setCursorPos(raw) + setSnapTarget(null) + const target = findFollowTarget(raw, activeLevelId as AnyNodeId) + followTargetRef.current = target + setTraceGhost(target ? tracePath(target) : null) + return + } + + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY + if (typeof clientY === 'number') lastClientYRef.current = clientY + if (altAnchorRef.current && typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point) { + clearDrawAlignment() + setCursorPos(point) + setSnapTarget(null) + return + } + } + const { point, snapped } = resolveAlignedPoint(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + // Follow mode: a click commits the trace beside the hovered lineset. + if (useLiquidLineToolOptions.getState().follow) { + const target = followTargetRef.current + if (target) commitTrace(target) + return + } + + const start = draftRef.current.at(-1) + if (altAnchorRef.current && start) { + const clientY = + (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current + if (typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point && Math.abs(point[1] - start[1]) >= 1e-4) { + commitSegment(start, point) + } + } + return + } + const { point } = resolveAlignedPoint(event) + if (!start) { + triggerSFX('sfx:grid-snap') + setDraftPoints([point]) + return + } + commitSegment(start, point) + } + + const enterAltMode = () => { + if (useLiquidLineToolOptions.getState().follow) return + const last = draftRef.current.at(-1) + if (!last || lastClientYRef.current === null) return + if (altAnchorRef.current) return + altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] } + setAltActive(true) + } + + const exitAltMode = () => { + if (!altAnchorRef.current) return + altAnchorRef.current = null + setAltActive(false) + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'f' || e.key === 'F') { + e.preventDefault() + useLiquidLineToolOptions.getState().toggleFollow() + return + } + if (e.key === 'Alt') { + e.preventDefault() + enterAltMode() + } + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + e.preventDefault() + exitAltMode() + } + } + + const onCancel = () => { + clearDrawAlignment() + if (draftRef.current.length === 0 && !followTargetRef.current) return + markToolCancelConsumed() + setDraftPoints([]) + setCursorPos(null) + setSnapTarget(null) + setTraceGhost(null) + followTargetRef.current = null + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + altAnchorRef.current = null + clearDrawAlignment() + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + const previewSegments: Array<{ a: Vec3; b: Vec3 }> = [] + for (let i = 0; i < draftPoints.length - 1; i++) { + previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! }) + } + const last = draftPoints.at(-1) + if (last && cursorPos) { + previewSegments.push({ a: last, b: cursorPos }) + } + + const traceSegments: Array<{ a: Vec3; b: Vec3 }> = [] + if (traceGhost) { + for (let i = 0; i < traceGhost.length - 1; i++) { + traceSegments.push({ a: traceGhost[i]!, b: traceGhost[i + 1]! }) + } + } + + const pillParts = cursorPos + ? (['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!, + signed: !!last, + })) + : null + const pillPrimary = + last && cursorPos + ? altActive + ? 'y' + : Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2]) + ? 'x' + : 'z' + : undefined + + return ( + + {cursorPos && ( + <> + + {follow ? ( + + +
+ {followTargetRef.current + ? 'Click to trace this lineset' + : 'Follow: hover a lineset'} +
+ +
+ ) : ( + pillParts && ( + + + + + + ) + )} + + )} + {snapTarget && ( + + + + + )} + {draftPoints.map((p, i) => ( + + + + + ))} + {previewSegments.map((seg, i) => ( + + ))} + {traceSegments.map((seg, i) => ( + + ))} +
+ ) +} + +function PreviewSegment({ a, b }: { a: Vec3; b: Vec3 }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default LiquidLineTool diff --git a/packages/nodes/src/pipe-fitting/definition.ts b/packages/nodes/src/pipe-fitting/definition.ts new file mode 100644 index 00000000..b00ffb92 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/definition.ts @@ -0,0 +1,106 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' +import { getRotationAxis, rotateEulerWorld } from '../shared/fitting-rotation' +import { buildPipeFittingFloorplan } from './floorplan' +import { buildPipeFittingGeometry } from './geometry' +import { pipeFittingParametrics } from './parametrics' +import { getPipeFittingPorts } from './ports' +import { PipeFittingNode } from './schema' + +/** + * DWV fittings — minted automatically by the pipe draw tool (corner + * joints → elbows, body taps → wyes on horizontal drains / sanitary + * tees on stacks), or click-placed via the tool (armed from the Build + * tab's DWV Pipe panel). Editable after the fact via the inspector. + */ +export const pipeFittingDefinition: NodeDefinition = { + kind: 'pipe-fitting', + schemaVersion: 1, + schema: PipeFittingNode, + category: 'utility', + distributionRole: 'fitting', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: [0, 0, 0], + fittingType: 'elbow', + angle: 90, + diameter: 2, + diameter2: 2, + pipeMaterial: 'pvc', + system: 'waste', + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true }, + duplicable: true, + deletable: true, + }, + + parametrics: pipeFittingParametrics, + + geometry: buildPipeFittingGeometry, + geometryKey: (n) => + JSON.stringify([n.fittingType, n.angle, n.diameter, n.diameter2, n.pipeMaterial, n.system]), + + ports: getPipeFittingPorts, + + floorplan: buildPipeFittingFloorplan, + + // R/T rotate a selected fitting ±45° around the shared active axis — + // same scheme as duct fittings (the default editor rotate only knows + // Y; DWV stacks need X/Z). Alt-cycling lives in `./selection.tsx`. + keyboardActions: { + r: { + appliesTo: (node) => node.type === 'pipe-fitting', + run: (node) => + useScene.getState().updateNode(node.id, { + rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), 1), + }), + }, + t: { + appliesTo: (node) => node.type === 'pipe-fitting', + run: (node) => + useScene.getState().updateNode(node.id, { + rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), -1), + }), + }, + axisCycling: true, + }, + + // Alt-cycles the active rotation axis while a fitting is selected. + // Editor-only (drives `useEditor.rotationAxis`), so it mounts via the + // editor's SelectionAffordanceManager rather than `def.system`. + affordanceTools: { + selection: () => import('./selection'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place fitting' }, + { key: 'Hover a pipe end', label: 'Snap onto the run' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Pipe Fitting', + description: 'DWV joint — elbow bend, 45° wye, or sanitary tee.', + // Reuses the duct-fitting artwork — DWV fittings read the same in the UI. + icon: { kind: 'url', src: '/icons/duct-fitting.webp' }, + paletteSection: 'structure', + paletteOrder: 96, + hidden: true, + }, + + mcp: { + description: + 'A DWV pipe fitting (elbow, wye, or sanitary tee) with typed ports. Minted automatically at drain joints; position is level-local meters, rotation an XYZ euler.', + }, +} diff --git a/packages/nodes/src/pipe-fitting/floorplan.ts b/packages/nodes/src/pipe-fitting/floorplan.ts new file mode 100644 index 00000000..1db7f3e4 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/floorplan.ts @@ -0,0 +1,58 @@ +import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import { getPipeFittingPorts } from './ports' +import type { PipeFittingNode } from './schema' + +const WASTE_COLOR = '#57534e' +const VENT_COLOR = '#78716c' + +/** + * Floor-plan symbol for a DWV fitting: one line per collar from the + * junction out (a wye's 45° branch reads at its true plan angle), plus + * a hub circle. Vertical collars (stack connections) collapse onto the + * hub, which is how they should read from above. + */ +export function buildPipeFittingFloorplan( + node: PipeFittingNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const [cx, , cz] = node.position + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const stroke = + showSelectedChrome && palette + ? palette.selectedStroke + : node.system === 'vent' + ? VENT_COLOR + : WASTE_COLOR + + const children: FloorplanGeometry[] = [] + for (const port of getPipeFittingPorts(node)) { + const px = port.position[0] + const pz = port.position[2] + if (Math.hypot(px - cx, pz - cz) < 1e-4) continue + children.push({ + kind: 'line', + x1: cx, + y1: cz, + x2: px, + y2: pz, + stroke, + strokeWidth: port.diameter * INCHES_TO_METERS, + strokeLinecap: 'round', + opacity: showSelectedChrome ? 0.95 : 0.85, + }) + } + children.push({ + kind: 'circle', + cx, + cy: cz, + r: (node.diameter * INCHES_TO_METERS) / 2 + 0.012, + fill: stroke, + opacity: 0.95, + }) + if (showSelectedChrome) children.push({ kind: 'move-handle', point: [cx, cz] }) + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/pipe-fitting/geometry.ts b/packages/nodes/src/pipe-fitting/geometry.ts new file mode 100644 index 00000000..b7db2552 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/geometry.ts @@ -0,0 +1,42 @@ +import { Group, Mesh, SphereGeometry, Vector3 } from 'three' +import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' +import { createPipeMaterial } from '../pipe-segment/geometry' +import { localPipeFittingPorts } from './ports' +import type { PipeFittingNode } from './schema' + +const RADIAL_SEGMENTS = 20 + +/** + * Pure geometry builder for a DWV fitting, in the node's LOCAL frame. + * One cylinder stub per port from the junction outward, an oversized + * hub sphere at the junction, and a smaller hub at each collar opening + * (solvent-weld couplings). Wyes read correctly because their branch + * stub leaves at 45° — the port layout does the work. + */ +export function buildPipeFittingGeometry(node: PipeFittingNode): Group { + const group = new Group() + const material = createPipeMaterial(node) + const radiusRun = (node.diameter * INCHES_TO_METERS) / 2 + + for (const port of localPipeFittingPorts(node)) { + const radius = (port.diameter * INCHES_TO_METERS) / 2 + const stub = buildSection( + new Vector3(0, 0, 0), + port.position, + radius, + material, + `pipe-fitting-stub-${port.id}`, + ) + if (stub) group.add(stub) + const hub = new Mesh(new SphereGeometry(radius * 1.18, RADIAL_SEGMENTS, 12), material) + hub.name = `pipe-fitting-hub-${port.id}` + hub.position.copy(port.position) + group.add(hub) + } + + const junction = new Mesh(new SphereGeometry(radiusRun * 1.18, RADIAL_SEGMENTS, 12), material) + junction.name = 'pipe-fitting-junction' + group.add(junction) + + return group +} diff --git a/packages/nodes/src/pipe-fitting/index.ts b/packages/nodes/src/pipe-fitting/index.ts new file mode 100644 index 00000000..6943690c --- /dev/null +++ b/packages/nodes/src/pipe-fitting/index.ts @@ -0,0 +1,4 @@ +export { pipeFittingDefinition } from './definition' +export { buildPipeFittingGeometry } from './geometry' +export { getPipeFittingPorts } from './ports' +export { PipeFittingNode } from './schema' diff --git a/packages/nodes/src/pipe-fitting/parametrics.ts b/packages/nodes/src/pipe-fitting/parametrics.ts new file mode 100644 index 00000000..9d937750 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/parametrics.ts @@ -0,0 +1,56 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { PipeFittingNode } from './schema' + +export const pipeFittingParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Fitting', + fields: [ + { + key: 'fittingType', + kind: 'enum', + options: ['elbow', 'wye', 'sanitary-tee', 'cross'], + display: 'segmented', + }, + { + key: 'angle', + kind: 'number', + unit: '°', + min: 15, + max: 90, + step: 7.5, + visibleIf: (n) => n.fittingType === 'elbow', + }, + { + key: 'system', + kind: 'enum', + options: ['waste', 'vent'], + display: 'segmented', + }, + ], + }, + { + label: 'Connections', + fields: [ + { key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 6, step: 0.25 }, + { + key: 'diameter2', + kind: 'number', + unit: 'in', + min: 1.25, + max: 6, + step: 0.25, + visibleIf: (n) => n.fittingType !== 'elbow', + }, + { key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] }, + ], + }, + { + label: 'Placement', + fields: [ + { key: 'position', kind: 'vec3' }, + { key: 'rotation', kind: 'vec3' }, + ], + }, + ], +} diff --git a/packages/nodes/src/pipe-fitting/ports.ts b/packages/nodes/src/pipe-fitting/ports.ts new file mode 100644 index 00000000..158e4896 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/ports.ts @@ -0,0 +1,102 @@ +import type { NodePort } from '@pascal-app/core' +import { Euler, Vector3 } from 'three' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { PipeFittingNode } from './schema' + +/** Hub stub length in meters — pipe fittings are stubbier than duct + * fittings (a 2" wye hub is ~7 cm to the collar). */ +export function pipeFittingLegLength(diameterInches: number): number { + const radius = (diameterInches * INCHES_TO_METERS) / 2 + return Math.max(0.07, radius * 2.2) +} + +/** Wye branch angle — DWV wyes enter at 45°. */ +export const WYE_BRANCH_RAD = Math.PI / 4 + +type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number } + +/** + * Ports in the fitting's LOCAL frame (origin at the junction, before + * `position`/`rotation`). Conventions documented on the schema: elbow + * inlet -X / outlet at `angle`° in XZ; wye run along X with the branch + * at 45° between +X and +Z; sanitary tee run along X, branch +Z; cross + * run along X, two opposed branches on ±Z. + */ +export function localPipeFittingPorts(node: PipeFittingNode): LocalPort[] { + const run = pipeFittingLegLength(node.diameter) + const inlet: LocalPort = { + id: 'inlet', + position: new Vector3(-run, 0, 0), + direction: new Vector3(-1, 0, 0), + diameter: node.diameter, + } + if (node.fittingType === 'elbow') { + const theta = (node.angle * Math.PI) / 180 + const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta)) + return [ + inlet, + { + id: 'outlet', + position: outDir.clone().multiplyScalar(run), + direction: outDir, + diameter: node.diameter, + }, + ] + } + const outlet: LocalPort = { + id: 'outlet', + position: new Vector3(run, 0, 0), + direction: new Vector3(1, 0, 0), + diameter: node.diameter, + } + const branchLeg = pipeFittingLegLength(node.diameter2) + if (node.fittingType === 'cross') { + return [ + inlet, + outlet, + { + id: 'branch', + position: new Vector3(0, 0, branchLeg), + direction: new Vector3(0, 0, 1), + diameter: node.diameter2, + }, + { + id: 'branch2', + position: new Vector3(0, 0, -branchLeg), + direction: new Vector3(0, 0, -1), + diameter: node.diameter2, + }, + ] + } + const branchDir = + node.fittingType === 'wye' + ? new Vector3(Math.cos(WYE_BRANCH_RAD), 0, Math.sin(WYE_BRANCH_RAD)) + : new Vector3(0, 0, 1) + return [ + inlet, + outlet, + { + id: 'branch', + position: branchDir.clone().multiplyScalar(branchLeg), + direction: branchDir, + diameter: node.diameter2, + }, + ] +} + +/** `def.ports` — local ports transformed into level-local space. */ +export function getPipeFittingPorts(node: PipeFittingNode): NodePort[] { + const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2]) + const offset = new Vector3(node.position[0], node.position[1], node.position[2]) + return localPipeFittingPorts(node).map((port) => { + const position = port.position.clone().applyEuler(euler).add(offset) + const direction = port.direction.clone().applyEuler(euler).normalize() + return { + id: port.id, + position: [position.x, position.y, position.z] as const, + direction: [direction.x, direction.y, direction.z] as const, + diameter: port.diameter, + system: node.system, + } + }) +} diff --git a/packages/nodes/src/pipe-fitting/schema.ts b/packages/nodes/src/pipe-fitting/schema.ts new file mode 100644 index 00000000..ab5ae3b5 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/schema.ts @@ -0,0 +1 @@ +export { PipeFittingNode } from '@pascal-app/core' diff --git a/packages/nodes/src/pipe-fitting/selection.tsx b/packages/nodes/src/pipe-fitting/selection.tsx new file mode 100644 index 00000000..20e9a924 --- /dev/null +++ b/packages/nodes/src/pipe-fitting/selection.tsx @@ -0,0 +1,43 @@ +'use client' + +import { type AnyNodeId, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import { cycleRotationAxis } from '../shared/fitting-rotation' + +/** + * Selection-time rotation support for placed pipe fittings — mirrors + * the duct-fitting affordance, mounted by the editor's + * SelectionAffordanceManager (`def.affordanceTools.selection`). R/T + * rotation lives in `def.keyboardActions`; this contributes the piece + * that hook can't: **Alt cycles the active rotation axis** while a + * single fitting is selected. The axis lives on `useEditor.rotationAxis`, + * which the floating action menu reads to show the axis pill — so this + * component renders nothing. + */ +const PipeFittingSelectionAffordance = () => { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const hasSelectedFitting = useScene((s) => { + if (selectedIds.length !== 1) return false + return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'pipe-fitting' + }) + + useEffect(() => { + if (!hasSelectedFitting) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Alt' || e.repeat) return + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + e.preventDefault() + 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) + return () => window.removeEventListener('keydown', onKeyDown) + }, [hasSelectedFitting]) + + return null +} + +export default PipeFittingSelectionAffordance diff --git a/packages/nodes/src/pipe-fitting/tool.tsx b/packages/nodes/src/pipe-fitting/tool.tsx new file mode 100644 index 00000000..eeb502bf --- /dev/null +++ b/packages/nodes/src/pipe-fitting/tool.tsx @@ -0,0 +1,255 @@ +'use client' + +import { emitter, type GridEvent, PipeFittingNode, useScene } from '@pascal-app/core' +import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Euler, Quaternion, Vector3 } from 'three' +import { + AXIS_VECTORS, + cycleRotationAxis, + getRotationAxis, + ROTATE_STEP_RAD, +} from '../shared/fitting-rotation' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { + collectScenePorts, + DWV_PORT_SYSTEMS, + findNearestPortXZ, + type ScenePort, +} from '../shared/ports' +import { pipeFittingDefinition } from './definition' +import { buildPipeFittingGeometry } from './geometry' +import { localPipeFittingPorts } from './ports' + +/** Snap radius (meters, XZ) for mating onto an existing DWV port. */ +const PORT_SNAP_RADIUS_M = 0.5 +const PREVIEW_OPACITY = 0.55 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +type Placement = { + position: [number, number, number] + rotation: [number, number, number] + snapPort: ScenePort | null +} + +/** + * Resolve where the fitting would land for a cursor at `raw`: + * - Near an existing DWV port → mate: orientation aligns the inlet + * onto the port (plus the user's manual R/T rotation, pivoting + * around the inlet collar so it stays on the port while the body + * sweeps). + * - Otherwise → grid-snapped free placement on the floor, manual + * rotation only. + */ +function resolvePlacement( + raw: [number, number, number], + previewNode: PipeFittingNode, + gridStep: number, + manualQuat: Quaternion, +): Placement { + const port = findNearestPortXZ( + raw, + collectScenePorts({ systems: DWV_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) + if (port) { + const direction = new Vector3(...port.direction).normalize() + // Local +X must map onto the port's outward direction so the inlet + // (local -X) faces back into the run it's joining. Manual rotation + // composes in the world frame on top of the mate orientation. + const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction) + const final = manualQuat.clone().multiply(mate) + const inlet = localPipeFittingPorts(previewNode)[0]! + const inletWorldOffset = inlet.position.clone().applyQuaternion(final) + const position = new Vector3(...port.position).sub(inletWorldOffset) + const euler = new Euler().setFromQuaternion(final) + return { + position: [position.x, position.y, position.z], + rotation: [euler.x, euler.y, euler.z], + snapPort: port, + } + } + const euler = new Euler().setFromQuaternion(manualQuat) + return { + position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)], + rotation: [euler.x, euler.y, euler.z], + snapPort: null, + } +} + +/** + * Click-place tool for DWV pipe fittings (elbow / wye / sanitary tee) — + * the plumbing sibling of the duct-fitting tool. + * + * A translucent ghost of the fitting follows the cursor. Within snap + * range of any DWV port (pipe run ends, other fittings' collars) the + * ghost jumps onto the port — position AND orientation — so one click + * mates the fitting onto the run. + * + * Rotation while placing: **R / T** turn the ghost ±45° around the + * active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge + * above the ghost shows the current axis. When snapped to a port the + * rotation pivots around the inlet collar so the joint stays mated. + * Handlers run in the capture phase so R doesn't also spin whatever + * node happens to be selected. + */ +const PipeFittingTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [placement, setPlacement] = useState(null) + const axis = useEditor((s) => s.rotationAxis) + // Accumulated manual rotation from R/T presses. Ref (not state) so the + // emitter callbacks always read the latest without re-subscribing; a + // placement recompute is triggered explicitly after each change. + const manualQuatRef = useRef(new Quaternion()) + // Last raw cursor position so a key press can recompute the placement + // without waiting for the next mouse move. + const lastRawRef = useRef<[number, number, number] | null>(null) + + // Ghost matches exactly what a click creates (the kind's defaults). + const previewNode = useMemo( + () => PipeFittingNode.parse({ ...pipeFittingDefinition.defaults(), name: 'Pipe fitting' }), + [], + ) + const ghost = useMemo(() => { + const group = buildPipeFittingGeometry(previewNode) + group.traverse((child) => { + // Overlay layer keeps the placement ghost out of the ink / SSGI + // buffers and the thumbnail export, like every other tool preview. + child.layers.set(EDITOR_LAYER) + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const recompute = () => { + const raw = lastRawRef.current + if (!raw) return + setPlacement( + resolvePlacement( + raw, + previewNode, + useEditor.getState().gridSnapStep, + manualQuatRef.current, + ), + ) + } + + const onMove = (event: GridEvent) => { + lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] + recompute() + } + + const onClick = (event: GridEvent) => { + lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]] + const { position, rotation } = resolvePlacement( + lastRawRef.current, + previewNode, + useEditor.getState().gridSnapStep, + manualQuatRef.current, + ) + const fitting = PipeFittingNode.parse({ + ...pipeFittingDefinition.defaults(), + name: 'Pipe fitting', + position, + rotation, + }) + useScene.getState().createNode(fitting, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [fitting.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key === 'r' || key === 'R' || key === 't' || key === 'T') { + // Capture-phase + stopPropagation so the editor's selection-rotate + // R handler doesn't also fire while the placement tool owns R. + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 + const turn = new Quaternion().setFromAxisAngle( + AXIS_VECTORS[getRotationAxis()], + steps * ROTATE_STEP_RAD, + ) + manualQuatRef.current = turn.multiply(manualQuatRef.current) + triggerSFX('sfx:item-rotate') + recompute() + } else if (key === 'Alt' && !e.repeat) { + e.preventDefault() + e.stopPropagation() + cycleRotationAxis() + } + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + } + }, [activeLevelId, previewNode]) + + if (!activeLevelId || !placement) return null + + return ( + + {/* Same ground ring + vertical line + tool-icon badge the duct draw + tool shows in 3D (icon resolved from the active `pipe-fitting` + structure-tools entry). In 2D the floorplan overlay draws this for + every tool; in 3D each tool renders its own. */} + + + + + {/* Rotation HUD — active axis + key hints, pinned above the ghost. */} + + {/* Same pill shell as DimensionPill so the placement HUD matches + the drawing / dragging readouts. */} +
+ Axis {axis.toUpperCase()} + + · + + R/T rotate + + · + + ⌥ axis +
+ + {/* Port-snap halo so the user sees the click will mate, not free-place. */} + {placement.snapPort && ( + + + + + )} +
+ ) +} + +export default PipeFittingTool diff --git a/packages/nodes/src/pipe-segment/definition.ts b/packages/nodes/src/pipe-segment/definition.ts new file mode 100644 index 00000000..ce280055 --- /dev/null +++ b/packages/nodes/src/pipe-segment/definition.ts @@ -0,0 +1,130 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { createPathPointMoveAffordance } from '../shared/path-point-affordance' +import { buildPipeSegmentFloorplan } from './floorplan' +import { buildPipeSegmentGeometry } from './geometry' +import { pipeSegmentParametrics } from './parametrics' +import { PipeSegmentNode } from './schema' + +/** + * Phase 4 of the distribution-system effort (the research doc's Phase 2) + * — DWV plumbing's first kind: the pipe run. The plumbing sibling of + * `duct-segment`: same polyline + typed-ports model, with SLOPE as the + * new ingredient (the draw tool drops waste runs ¼"/ft; vents run level + * or vertical). + * + * Deferred to later slices: DWV fittings (wye / sanitary tee / closet + * bend), fixtures, traps, cleanouts, IPC validators, riser view. + */ +export const pipeSegmentDefinition: NodeDefinition = { + kind: 'pipe-segment', + schemaVersion: 1, + schema: PipeSegmentNode, + category: 'utility', + distributionRole: 'run', + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + path: [ + [0, 0, 0], + [3, -0.0625, 0], + ], + diameter: 2, + pipeMaterial: 'pvc', + system: 'waste', + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + }, + + parametrics: pipeSegmentParametrics, + + geometry: buildPipeSegmentGeometry, + geometryKey: (n) => JSON.stringify([n.path, n.diameter, n.pipeMaterial, n.system]), + + // Open run ends as typed ports — system 'waste'/'vent' keeps the DWV + // network invisible to duct / refrigerant tools and vice versa. + ports: (n) => { + if (n.path.length < 2) return [] + const unit = ( + a: readonly [number, number, number], + b: readonly [number, number, number], + ): [number, number, number] => { + const d: [number, number, number] = [a[0] - b[0], a[1] - b[1], a[2] - b[2]] + const len = Math.hypot(d[0], d[1], d[2]) + return len < 1e-9 ? [1, 0, 0] : [d[0] / len, d[1] / len, d[2] / len] + } + const first = n.path[0]! + const second = n.path[1]! + const last = n.path[n.path.length - 1]! + const prev = n.path[n.path.length - 2]! + return [ + { + id: 'start', + position: first, + direction: unit(first, second), + diameter: n.diameter, + system: n.system, + }, + { + id: 'end', + position: last, + direction: unit(last, prev), + diameter: n.diameter, + system: n.system, + }, + ] + }, + + floorplan: buildPipeSegmentFloorplan, + + // 2D selection-time path-point handles — the floor-plan twin of the 3D + // `affordanceTools.selection` handles. The builder emits an + // `endpoint-handle` per path vertex; this drags the matching point. + floorplanAffordances: { + 'move-path-point': createPathPointMoveAffordance('pipe-segment'), + }, + + // Selection-time path-point handles (drag to edit a committed run). + // Editor-only UI (reads gridSnapStep, renders DimensionPill), so it + // mounts via the editor's SelectionAffordanceManager — not `def.system`, + // which the viewer package mounts for the read-only route. + affordanceTools: { + selection: () => import('./selection'), + // Ghost-preview duplicate / move (the plumbing sibling of duct-segment's + // mover). Duplicate is pure drag-to-place: a translucent copy of the run, + // wrapped in a footprint bounding box, follows the cursor and only lands + // on the commit click — nothing is inserted into the scene before that. + move: () => import('./move-tool'), + }, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Start run' }, + { key: 'Click again', label: 'Place it (waste falls ¼″/ft)' }, + { key: 'Q', label: 'Waste / vent' }, + { key: '[ / ]', label: 'Pipe size down / up' }, + { key: 'Alt + drag', label: 'Vertical stack ↕, click to place' }, + { key: 'Shift', label: 'Free angle' }, + { key: 'Esc', label: 'Cancel start point' }, + ], + + presentation: { + label: 'DWV Pipe', + description: + 'Drain / waste / vent pipe run — waste lines fall at ¼″ per foot, vents run level or vertical.', + icon: { kind: 'url', src: '/icons/dwv-pipes.webp' }, + paletteSection: 'structure', + paletteOrder: 95, + }, + + mcp: { + description: + 'A DWV (drain-waste-vent) pipe run defined as a polyline. Waste runs slope downward (slope lives in the path Y coordinates); vents run level or vertical. Sized in nominal inches.', + }, +} diff --git a/packages/nodes/src/pipe-segment/floorplan.ts b/packages/nodes/src/pipe-segment/floorplan.ts new file mode 100644 index 00000000..1451019d --- /dev/null +++ b/packages/nodes/src/pipe-segment/floorplan.ts @@ -0,0 +1,99 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { INCHES_TO_METERS } from '../duct-segment/geometry' +import type { PipeSegmentNode } from './schema' + +const WASTE_COLOR = '#57534e' +const VENT_COLOR = '#78716c' + +/** + * Floor-plan representation of a DWV run, following drafting convention: + * waste lines draw SOLID at the pipe's width, vent lines draw DASHED and + * thin. Vertical stacks collapse to a circle. + */ +export function buildPipeSegmentFloorplan( + node: PipeSegmentNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (node.path.length < 2) return null + + const points: FloorplanPoint[] = [] + // Plan point k ← original path index indexMap[k] (stacks collapse to one + // plan point), so the path-point drag handle edits the right vertex. + const indexMap: number[] = [] + for (let i = 0; i < node.path.length; i++) { + const [x, , z] = node.path[i]! + const prev = points[points.length - 1] + if (prev && Math.abs(prev[0] - x) < 1e-6 && Math.abs(prev[1] - z) < 1e-6) continue + points.push([x, z]) + indexMap.push(i) + } + + const diameterM = node.diameter * INCHES_TO_METERS + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const isVent = node.system === 'vent' + const stroke = + showSelectedChrome && palette ? palette.selectedStroke : isVent ? VENT_COLOR : WASTE_COLOR + + // Vertical stack — a single plan point: hub circle. + if (points.length < 2) { + const p = points[0] ?? [node.path[0]![0], node.path[0]![2]] + return { + kind: 'group', + children: [ + { + kind: 'circle', + cx: p[0], + cy: p[1], + r: diameterM / 2 + 0.01, + fill: 'none', + stroke, + strokeWidth: 2, + vectorEffect: 'non-scaling-stroke', + opacity: 0.95, + }, + ], + } + } + + const children: FloorplanGeometry[] = [ + isVent + ? { + kind: 'polyline', + points, + stroke, + strokeWidth: 1.5, + vectorEffect: 'non-scaling-stroke', + strokeDasharray: '6 4', + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: 0.9, + } + : { + kind: 'polyline', + points, + stroke, + strokeWidth: diameterM, + strokeLinecap: 'round', + strokeLinejoin: 'round', + opacity: showSelectedChrome ? 0.95 : 0.85, + }, + ] + + // Selection chrome: one draggable handle per path vertex (2D twin of the + // 3D selection handles). Routes to the shared `move-path-point` affordance. + if (view?.selected) { + for (let k = 0; k < points.length; k++) { + children.push({ + kind: 'endpoint-handle', + point: points[k]!, + state: 'idle', + affordance: 'move-path-point', + payload: { pointIndex: indexMap[k]! }, + }) + } + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/pipe-segment/geometry.ts b/packages/nodes/src/pipe-segment/geometry.ts new file mode 100644 index 00000000..c4fd2831 --- /dev/null +++ b/packages/nodes/src/pipe-segment/geometry.ts @@ -0,0 +1,64 @@ +import { Group, Mesh, MeshStandardMaterial, SphereGeometry, Vector3 } from 'three' +import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' +import type { PipeSegmentNode } from './schema' + +const PVC_COLOR = '#f5f5f5' +const ABS_COLOR = '#3a3a3a' +const CAST_IRON_COLOR = '#54575c' +/** Vents read slightly translucent-matte so they don't visually compete + * with the water-carrying waste runs. */ +const VENT_OPACITY = 0.85 + +const RADIAL_SEGMENTS = 20 + +type PipeAppearance = { + pipeMaterial: 'pvc' | 'abs' | 'cast-iron' + system: 'waste' | 'vent' +} + +function getPipeColor(node: PipeAppearance): string { + if (node.pipeMaterial === 'abs') return ABS_COLOR + if (node.pipeMaterial === 'cast-iron') return CAST_IRON_COLOR + return PVC_COLOR +} + +export function createPipeMaterial(node: PipeAppearance): MeshStandardMaterial { + return new MeshStandardMaterial({ + color: getPipeColor(node), + metalness: node.pipeMaterial === 'cast-iron' ? 0.5 : 0.05, + roughness: node.pipeMaterial === 'cast-iron' ? 0.6 : 0.45, + transparent: node.system === 'vent', + opacity: node.system === 'vent' ? VENT_OPACITY : 1, + }) +} + +/** + * Pure geometry builder for a DWV pipe run: capped cylinder sections + * between consecutive path points with sphere hubs at interior joints + * (proper wyes / sanitary tees come in the next slice). Slope lives in + * the path's Y coordinates — nothing here is slope-aware. + */ +export function buildPipeSegmentGeometry(node: PipeSegmentNode): Group { + const group = new Group() + if (node.path.length < 2) return group + + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const material = createPipeMaterial(node) + const points = node.path.map(([x, y, z]) => new Vector3(x, y, z)) + + for (let i = 0; i < points.length - 1; i++) { + const a = points[i] as Vector3 + const b = points[i + 1] as Vector3 + const mesh = buildSection(a, b, radius, material, `pipe-section-${i}`) + if (mesh) group.add(mesh) + } + // Slightly proud hubs at interior joints — reads as a coupling. + for (let i = 1; i < points.length - 1; i++) { + const hub = new Mesh(new SphereGeometry(radius * 1.12, RADIAL_SEGMENTS, 12), material) + hub.name = `pipe-hub-${i}` + hub.position.copy(points[i] as Vector3) + group.add(hub) + } + + return group +} diff --git a/packages/nodes/src/pipe-segment/index.ts b/packages/nodes/src/pipe-segment/index.ts new file mode 100644 index 00000000..87fd9122 --- /dev/null +++ b/packages/nodes/src/pipe-segment/index.ts @@ -0,0 +1,3 @@ +export { pipeSegmentDefinition } from './definition' +export { buildPipeSegmentGeometry } from './geometry' +export { PipeSegmentNode } from './schema' diff --git a/packages/nodes/src/pipe-segment/move-tool.tsx b/packages/nodes/src/pipe-segment/move-tool.tsx new file mode 100644 index 00000000..cfa93e39 --- /dev/null +++ b/packages/nodes/src/pipe-segment/move-tool.tsx @@ -0,0 +1,302 @@ +'use client' + +import { + type AlignmentAnchor, + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + PipeSegmentNode, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { + DragBoundingBox, + EDITOR_LAYER, + markToolCancelConsumed, + stripPlacementMetadataFlags, + triggerSFX, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { + type Aabb2D, + collectGhostAlignmentCandidates, + resolveGhostAlignment, +} from '../shared/ghost-alignment' + +type Vec3 = [number, number, number] + +const GHOST_COLOR = '#818cf8' +const GHOST_OPACITY = 0.5 +const IN_TO_M = 0.0254 + +/** Snap a coordinate to the editor's live grid step. */ +function snapToGridStep(value: number): number { + const step = useEditor.getState().gridSnapStep + if (step <= 0) return value + return Math.round(value / step) * step +} + +function pathCenterXZ(path: readonly Vec3[]): [number, number] { + let x = 0 + let z = 0 + for (const p of path) { + x += p[0] + z += p[2] + } + const n = path.length || 1 + return [x / n, z / n] +} + +/** The pipe's radius (meters) — half the nominal diameter, used as the + * box / footprint padding and the ghost cylinder radius. */ +function pipeRadiusM(pipe: PipeSegmentNode): number { + return (pipe.diameter * IN_TO_M) / 2 +} + +/** XZ bounds of a path padded by the pipe's radius. */ +function pathAabb(path: readonly Vec3[], r: number): Aabb2D { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + for (const p of path) { + if (p[0] < minX) minX = p[0] + if (p[0] > maxX) maxX = p[0] + if (p[2] < minZ) minZ = p[2] + if (p[2] > maxZ) maxZ = p[2] + } + return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r } +} + +/** + * Ghost-preview duplicate / move tool for DWV pipe runs — the plumbing + * sibling of `MoveDuctSegmentTool`. Pipes are always round, so the ghost + * is a translucent cylinder per section (no rect branch). + * + * **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is + * inserted into the scene until the commit click. A translucent ghost of + * the run rides the cursor inside a footprint bounding box — the same + * affordance other items get — and Figma-style alignment guides snap the + * box's edges to nearby geometry. The next grid click calls `createNode`; + * Esc discards. The run's Y coords (slope) ride along untouched: the move + * only shifts XZ. + * + * **Move** (existing run): the real node's mesh is hidden while the same + * ghost + box tracks the cursor; the commit click writes the translated + * `path` and reveals it, Esc reveals it unchanged. + * + * Wired via `def.affordanceTools.move`. + */ +export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => { + const pipe = node as PipeSegmentNode + const originalPathRef = useRef(pipe.path.map((p) => [...p] as Vec3)) + + const isNew = + typeof node.metadata === 'object' && + node.metadata !== null && + !Array.isArray(node.metadata) && + (node.metadata as Record).isNew === true + + const [previewPath, setPreviewPath] = useState(originalPathRef.current) + const previewPathRef = useRef(originalPathRef.current) + const hasMovedRef = useRef(false) + const activatedAtRef = useRef(Date.now()) + const prevSnapRef = useRef<[number, number] | null>(null) + + useEffect(() => { + const nodeId = node.id as AnyNodeId + const originalPath = originalPathRef.current + const [centerX, centerZ] = pathCenterXZ(originalPath) + const r = pipeRadiusM(pipe) + const baseAabb = pathAabb(originalPath, r) + + useScene.temporal.getState().pause() + let committed = false + + const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates( + useScene.getState().nodes, + nodeId, + useViewer.getState().selection.levelId ?? node.parentId, + ) + + // Moving an existing run: hide its 3D MESH imperatively (NOT the store + // `visible` flag — the 2D floor plan skips `visible:false` nodes, so a + // store hide makes the run vanish in 2D / split view). The ghost stands + // in until commit; the real mesh is restored on cancel / unmount. + const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId] + const setMeshHidden = (hidden: boolean) => { + const obj = sceneRegistry.nodes.get(nodeId) + if (obj) obj.visible = !hidden + } + if (existedAtStart) setMeshHidden(true) + + const setPreview = (path: Vec3[]) => { + previewPathRef.current = path + setPreviewPath(path) + } + + const onMove = (event: GridEvent) => { + const bypass = event.nativeEvent?.shiftKey === true + const snap = bypass ? (v: number) => v : snapToGridStep + let dx = snap(event.localPosition[0] - centerX) + let dz = snap(event.localPosition[2] - centerZ) + + // Figma-style alignment: snap the run's footprint box edges onto + // nearby geometry and publish the guides (Shift bypass). + if (!bypass) { + const proposed: Aabb2D = { + minX: baseAabb.minX + dx, + maxX: baseAabb.maxX + dx, + minZ: baseAabb.minZ + dz, + maxZ: baseAabb.maxZ + dz, + } + const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates) + dx += sdx + dz += sdz + useAlignmentGuides.getState().set(guides) + } else { + useAlignmentGuides.getState().clear() + } + + const cur: [number, number] = [centerX + dx, centerZ + dz] + if ( + !bypass && + (!prevSnapRef.current || + prevSnapRef.current[0] !== cur[0] || + prevSnapRef.current[1] !== cur[1]) + ) { + triggerSFX('sfx:grid-snap') + } + prevSnapRef.current = cur + hasMovedRef.current = true + setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)) + } + + const commit = (event: GridEvent) => { + if (committed) return + if (Date.now() - activatedAtRef.current < 150) { + event.nativeEvent?.stopPropagation?.() + return + } + if (!hasMovedRef.current) { + event.nativeEvent?.stopPropagation?.() + return + } + committed = true + const finalPath = previewPathRef.current + + useScene.temporal.getState().resume() + let selectId = nodeId + if (isNew && !useScene.getState().nodes[nodeId]) { + const created = PipeSegmentNode.parse({ + ...(node as Record), + path: finalPath, + metadata: stripPlacementMetadataFlags(node.metadata), + visible: true, + }) + useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId) + selectId = created.id as AnyNodeId + } else { + useScene.getState().updateNode(nodeId, { path: finalPath } as Partial) + useScene.getState().markDirty(nodeId) + } + useScene.temporal.getState().pause() + setMeshHidden(false) + + useAlignmentGuides.getState().clear() + triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [selectId] }) + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + if (existedAtStart) { + setMeshHidden(false) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + } + useAlignmentGuides.getState().clear() + useScene.temporal.getState().resume() + markToolCancelConsumed() + useEditor.getState().setMovingNodeOrigin('3d') + useEditor.getState().setMovingNode(null) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', commit) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', commit) + emitter.off('tool:cancel', onCancel) + useAlignmentGuides.getState().clear() + if (existedAtStart) setMeshHidden(false) + useScene.temporal.getState().resume() + } + }, [pipe, isNew, node]) + + const segments: Array<{ a: Vec3; b: Vec3 }> = [] + for (let i = 0; i < previewPath.length - 1; i++) { + segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! }) + } + + // Footprint box spanning the whole run (axis-aligned), drawn around the + // ghost the same way items get one. Recomputed from the live preview path. + const r = pipeRadiusM(pipe) + const box = pathAabb(previewPath, r) + const boxY = previewPath[0]?.[1] ?? 0 + + return ( + + {segments.map((seg, i) => ( + + ))} + + + ) +} + +/** Translucent stand-in for one pipe section — mirrors the draw tool's + * `PreviewPipe` so the ghost matches what actually lands. */ +function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default MovePipeSegmentTool diff --git a/packages/nodes/src/pipe-segment/parametrics.ts b/packages/nodes/src/pipe-segment/parametrics.ts new file mode 100644 index 00000000..797c2e50 --- /dev/null +++ b/packages/nodes/src/pipe-segment/parametrics.ts @@ -0,0 +1,36 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { PipeSegmentNode } from './schema' + +export const pipeSegmentParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Drainage', + fields: [ + { + key: 'system', + kind: 'enum', + options: ['waste', 'vent'], + display: 'segmented', + }, + { + key: 'diameter', + kind: 'number', + unit: 'in', + min: 1.25, + max: 6, + step: 0.25, + }, + ], + }, + { + label: 'Construction', + fields: [ + { + key: 'pipeMaterial', + kind: 'enum', + options: ['pvc', 'abs', 'cast-iron'], + }, + ], + }, + ], +} diff --git a/packages/nodes/src/pipe-segment/schema.ts b/packages/nodes/src/pipe-segment/schema.ts new file mode 100644 index 00000000..b195086e --- /dev/null +++ b/packages/nodes/src/pipe-segment/schema.ts @@ -0,0 +1 @@ +export { PipeSegmentNode } from '@pascal-app/core' diff --git a/packages/nodes/src/pipe-segment/selection.tsx b/packages/nodes/src/pipe-segment/selection.tsx new file mode 100644 index 00000000..81f9e1f0 --- /dev/null +++ b/packages/nodes/src/pipe-segment/selection.tsx @@ -0,0 +1,362 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + analyzePortConnectivity, + type PipeSegmentNode, + type PortConnectivity, + pauseSceneHistory, + resolveConnectivityUpdates, + 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, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' + +/** Handle pip radius (meters). */ +const HANDLE_RADIUS = 0.09 +/** Port-snap radius for dragged run endpoints (meters, XZ). */ +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 DWV pipe runs: one draggable + * handle per path point. The plumbing sibling of the duct-segment + * affordance — same portal / constrained-drag / single-undo model, snapping + * to DWV ports instead of duct ports. + * + * Handles are PORTALED into the pipe's registered scene group so they + * share its exact frame — path coords are node-local, and the level / + * building transform above the group applies to the handles for free. + * + * Drag model: by default the point is CONSTRAINED to the axis the + * segment was drawn along. Holding **Alt** releases it into free + * horizontal-plane movement (endpoints port-snap onto nearby DWV ports). + * Holding **Shift** bypasses grid snapping for a precision drag. + */ +const PipeSegmentSelectionAffordance = () => { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const pipe = useScene((s) => { + if (selectedIds.length !== 1) return null + const node = s.nodes[selectedIds[0] as AnyNodeId] + return node?.type === 'pipe-segment' ? (node as PipeSegmentNode) : null + }) + + // Portal target: the pipe's registered group. Resolved with a rAF + // retry because registration happens on the renderer's mount, which + // can land a frame after selection. + const pipeId = pipe?.id ?? null + const [target, setTarget] = useState(null) + useEffect(() => { + if (!pipeId) { + setTarget(null) + return + } + let frameId = 0 + const resolve = () => { + const next = sceneRegistry.nodes.get(pipeId as AnyNodeId) ?? null + setTarget((cur) => (cur === next ? cur : next)) + if (!next) frameId = window.requestAnimationFrame(resolve) + } + resolve() + return () => window.cancelAnimationFrame(frameId) + }, [pipeId]) + + if (!pipe || !target) return null + return createPortal(, target, undefined) +} + +const PipePointHandles = ({ pipe, target }: { pipe: PipeSegmentNode; target: Object3D }) => { + const { camera, gl } = useThree() + const unit = useViewer((s) => s.unit) + const [draggingIndex, setDraggingIndex] = useState(null) + const [hoverIndex, setHoverIndex] = useState(null) + // Set while a drag is live; null otherwise. Holds everything the window + // pointer handlers need so they never read stale React state. + const dragRef = useRef<{ + index: number + initialPath: Point[] + current: Point + cleanup: () => void + // Connectivity snapshot taken at pointer-down: which fittings / pipes are + // mated to this run's endpoints, so they follow as the endpoint moves. + connectivity: PortConnectivity | null + } | 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 + } + + /** + * Signed distance along `axisWorld` (unit, through `anchorWorld`) of the + * point on that line closest to the cursor ray. Null when the ray runs + * (near-)parallel to the axis and the projection is unstable. + */ + 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 + } + + /** World-space position of a local path point. */ + const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2])) + /** Convert a world-space hit back into the pipe group's local frame. */ + const toLocal = (world: Vector3): Point => { + const local = target.worldToLocal(world.clone()) + return [local.x, local.y, local.z] + } + + // Follow-updates for fittings / pipes mated to this run's endpoints, given + // the run's live path. Endpoints whose position didn't change resolve to a + // zero delta, so only the dragged endpoint's partner actually moves. + const connectivityUpdatesForPath = ( + connectivity: PortConnectivity | null, + path: Point[], + ): { id: AnyNodeId; data: Partial }[] => { + if (!connectivity) return [] + const preview = { ...(pipe as Record), path } as AnyNode + return resolveConnectivityUpdates(connectivity, preview).filter( + (u) => useScene.getState().nodes[u.id], + ) + } + + const onHandleDown = (index: number) => (e: ThreeEvent) => { + e.stopPropagation() + const initialPath = pipe.path.map((p) => [...p] as Point) + const startPoint = initialPath[index]! + const connectivity = analyzePortConnectivity(pipe as AnyNode, useScene.getState().nodes) + pauseSceneHistory(useScene) + useViewer.getState().setInputDragging(true) + document.body.style.cursor = 'grabbing' + setDraggingIndex(index) + + const isEndpoint = index === 0 || index === initialPath.length - 1 + + // Axis the segment was drawn along, at this point: from the + // neighbouring path point toward the dragged one. The default drag + // is constrained to this line. + 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() + // World-space anchor + axis, derived once — the constraint line is + // fixed for the whole drag regardless of where the point currently is. + 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 + // Shift = precision: bypass grid snapping for a perfectly smooth + // drag (snap() is a no-op at step 0). + const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep + let next: Point | null = null + if (event.altKey) { + // Alt = freedom: slide on the horizontal plane at the point's + // height. Endpoints can port-snap here to mate onto a fitting. + 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: pipe.id, systems: DWV_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) + if (port) next = [port.position[0], port.position[1], port.position[2]] + } + } + } else { + // Default: constrained to the axis the segment was drawn along — + // slide the point closer / further along its own line. + 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 = pipe.path.map((p, i) => (i === drag.index ? next! : p)) as Point[] + // Drag the run + any fittings mated to the moved endpoint as one batch. + useScene + .getState() + .updateNodes([ + { id: pipe.id as AnyNodeId, data: { path } }, + ...connectivityUpdatesForPath(drag.connectivity, path), + ]) + } + + const onUp = () => { + const drag = dragRef.current + if (!drag) return + drag.cleanup() + dragRef.current = null + setDraggingIndex(null) + // Single-undo dance: revert (still paused), resume, re-apply the + // final path — plus any connected fitting moves — as one tracked batch. + const finalPath = drag.initialPath.map((p, i) => + i === drag.index ? drag.current : p, + ) as Point[] + const finalUpdates = connectivityUpdatesForPath(drag.connectivity, finalPath) + // Revert the run AND the followers to their pre-drag state while paused + // so history captures a clean before→after delta. + const revertUpdates = (drag.connectivity?.connections ?? []).flatMap((conn) => + conn.kind === 'rigid-node' + ? [{ id: conn.nodeId, data: { position: conn.startPosition } as Partial }] + : [{ id: conn.nodeId, data: { path: conn.startPath } as Partial }], + ) + useScene + .getState() + .updateNodes([ + { id: pipe.id as AnyNodeId, data: { path: drag.initialPath } }, + ...revertUpdates.filter((u) => useScene.getState().nodes[u.id]), + ]) + resumeSceneHistory(useScene) + const moved = finalPath[drag.index]!.some( + (v, axis) => v !== drag.initialPath[drag.index]![axis], + ) + if (moved) { + useScene + .getState() + .updateNodes([{ id: pipe.id as AnyNodeId, data: { path: finalPath } }, ...finalUpdates]) + } + } + + 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, connectivity } + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onUp) + } + + return ( + + {pipe.path.map((p, i) => { + const active = draggingIndex === i + const hovered = hoverIndex === i + return ( + { + 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} + > + + + + ) + })} + {draggingIndex !== null && + pipe.path[draggingIndex] && + (() => { + // Same pill as the draw tool: signed per-axis deltas from the + // drag-start position, dominant axis emphasised. + const point = pipe.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 ( + + ({ + key: axis, + prefix: axis.toUpperCase(), + value: deltas[i]!, + signed: true, + }))} + primary={primary} + unit={unit} + /> + + ) + })()} + + ) +} + +export default PipeSegmentSelectionAffordance diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx new file mode 100644 index 00000000..07735912 --- /dev/null +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -0,0 +1,691 @@ +'use client' + +import { type AnyNode, emitter, type GridEvent, PipeSegmentNode, useScene } from '@pascal-app/core' +import { + CursorSphere, + DimensionPill, + EDITOR_LAYER, + markToolCancelConsumed, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useRef, useState } from 'react' +import { Vector3 } from 'three' +import { + planPipeBranchTap, + planPipeCrossAtRunBody, + planPipeElbowAtPort, +} from '../shared/auto-fitting' +import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { + collectScenePorts, + DWV_PORT_SYSTEMS, + findNearestPortXZ, + findNearestRunBodyXZ, + findRunBodyCrossingXZ, + type RunBodyHit, + type ScenePort, +} from '../shared/ports' +import { pipeSegmentDefinition } from './definition' + +/** + * Slope-aware two-click placement tool for DWV pipe runs — the plumbing + * sibling of the duct tool. + * + * - **First click** anchors the run start (port snap joins onto an + * existing pipe end — DWV ports only, duct/refrigerant collars are + * invisible to it). The start inherits the snapped port's height. + * - **Second click** commits a two-point pipe and re-arms. + * - **Slope**: runs draw LEVEL by default. **S** toggles slope mode, + * where waste runs fall at ¼" per foot (1:48) of horizontal + * distance, the IPC default for residential drains. When sloped, a + * freely placed start is RAISED so the run falls onto the grid plane + * (nothing clips below); a port/body-snapped start keeps its fixed + * height and the end drops instead. Vent runs always stay level. + * The pill shows the live drop in the Y part. + * - **Q** toggles waste ↔ vent. **[ / ]** steps the pipe size through + * nominal DWV diameters. + * - Hold **Alt** → vertical mode (stacks): XZ locks to the start, + * mouse vertical motion drives Y, click commits the riser. + * - 45° XZ angle lock from the start; **Shift** frees the angle and + * grid snap. + * - Esc clears an anchored start point. + */ +const PREVIEW_OPACITY = 0.55 +/** Nominal residential DWV sizes (inches). */ +const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const +/** IPC default drain slope — ¼" per foot (1:48). */ +const DRAIN_SLOPE = 1 / 48 +/** Snap radius (meters, XZ) for joining onto an existing pipe end. */ +const PORT_SNAP_RADIUS_M = 0.5 +/** Snap radius (meters, XZ) for tapping the side of an existing run. */ +const BODY_SNAP_RADIUS_M = 0.3 +const ANGLE_STEP_RAD = Math.PI / 4 +const ALT_PIXELS_PER_METER = 100 +const ALT_Y_MIN_M = -3 +const ALT_Y_MAX_M = 10 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function dist2(a: readonly [number, number, number], b: readonly [number, number, number]): number { + const dx = a[0] - b[0] + const dy = a[1] - b[1] + const dz = a[2] - b[2] + return dx * dx + dy * dy + dz * dz +} + +function findNearbyPort(point: [number, number, number]): ScenePort | null { + return findNearestPortXZ( + point, + collectScenePorts({ systems: DWV_PORT_SYSTEMS }), + PORT_SNAP_RADIUS_M, + ) +} + +function projectToAngleLock( + from: [number, number, number], + raw: [number, number, number], +): [number, number, number] { + const dx = raw[0] - from[0] + const dz = raw[2] - from[2] + const len = Math.hypot(dx, dz) + if (len < 1e-4) return [from[0], from[1], from[2]] + const theta = Math.atan2(dz, dx) + const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD + const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped) + const d = Math.max(0, proj) + return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d] +} + +const PipeSegmentTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) + const [system, setSystem] = useState<'waste' | 'vent'>('waste') + const [sloped, setSloped] = useState(false) + const [diameter, setDiameter] = useState( + (pipeSegmentDefinition.defaults() as { diameter: number }).diameter, + ) + const [draftStart, setDraftStart] = useState<[number, number, number] | null>(null) + const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null) + const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null) + const [altActive, setAltActive] = useState(false) + + const startRef = useRef(draftStart) + startRef.current = draftStart + const systemRef = useRef(system) + systemRef.current = system + const slopedRef = useRef(sloped) + slopedRef.current = sloped + const diameterRef = useRef(diameter) + diameterRef.current = diameter + // Port / run-body the anchored start snapped onto — read at commit so + // joints mint bends (corner) or wyes / sanitary tees (body tap). + const startPortRef = useRef(null) + const startBodyRef = useRef(null) + const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null) + const lastClientYRef = useRef(null) + + useEffect(() => { + if (!activeLevelId) return + + /** Corner-bend gate: joints onto another PIPE run's open end. */ + const bendPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => { + if (!port) return null + const owner = useScene.getState().nodes[port.nodeId] + if (owner?.type !== 'pipe-segment') return null + const plan = planPipeElbowAtPort(port, awayDir, diameterRef.current, owner.pipeMaterial) + if (!plan) return null + // Trim the run's snapped endpoint back to the bend'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], + ) + const original = path[index]! + const originalLen = Math.hypot( + original[0] - neighbor[0], + original[1] - neighbor[1], + original[2] - neighbor[2], + ) + if (remaining < 0.05 || remaining >= originalLen) return null + path[index] = plan.trimmedPortPoint + return { ...plan, trim: { id: port.nodeId, data: { path } as Partial } } + } + + const commitSegment = ( + rawStart: [number, number, number], + end: [number, number, number], + endPort: ScenePort | null = null, + endBody: RunBodyHit | null = null, + ) => { + // Free waste start: lift it by the drain fall so the run lands ON + // the grid plane instead of sinking below it. Snapped starts are + // height-fixed (fixture drain, run end), so their end drops instead. + let start = rawStart + if ( + slopedRef.current && + systemRef.current === 'waste' && + !startPortRef.current && + !startBodyRef.current && + !endPort + ) { + const run = Math.hypot(end[0] - rawStart[0], end[2] - rawStart[2]) + start = [rawStart[0], rawStart[1] + run * DRAIN_SLOPE, rawStart[2]] + } + const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2]) + if (length < 1e-4) return + const dir: [number, number, number] = [ + (end[0] - start[0]) / length, + (end[1] - start[1]) / length, + (end[2] - start[2]) / length, + ] + + const startPlan = bendPlanFor(startPortRef.current, dir) + const endPlan = bendPlanFor(endPort, [-dir[0], -dir[1], -dir[2]]) + // Body tap (wye / sanitary tee) when the start landed on a run's side. + const body = startPlan ? null : startBodyRef.current + const bodyOwner = body ? useScene.getState().nodes[body.nodeId] : null + const tapPlan = + body && bodyOwner?.type === 'pipe-segment' + ? planPipeBranchTap(bodyOwner, body, dir, diameterRef.current) + : null + // End body tap: the END landed on a run's side — split that trunk and + // the new run ends at the branch collar, the branch leaving back + // toward the drawn run (along -dir, since dir points start→end). + const endTapBody = endPlan ? null : endBody + const endTapOwner = endTapBody ? useScene.getState().nodes[endTapBody.nodeId] : null + const endTapPlan = + endTapBody && endTapOwner?.type === 'pipe-segment' + ? planPipeBranchTap( + endTapOwner, + endTapBody, + [-dir[0], -dir[1], -dir[2]], + diameterRef.current, + ) + : null + // Both ends tapping the SAME run would split one polyline twice in a + // single change — drop the end tap and let the end butt-join instead. + const endTap = endTapPlan && endTapBody?.nodeId === body?.nodeId ? null : endTapPlan + + let pipeStart = startPlan?.collarPoint ?? tapPlan?.branchCollar ?? start + let pipeEnd = endPlan?.collarPoint ?? endTap?.branchCollar ?? end + const remaining = Math.hypot( + pipeEnd[0] - pipeStart[0], + pipeEnd[1] - pipeStart[1], + pipeEnd[2] - pipeStart[2], + ) + let bends = [startPlan, endPlan].filter((p) => p !== null) + let tap = tapPlan + let endTapFinal = endTap + + // Cross tap: the drawn run passes straight THROUGH a run's body + // (interior crossing, not an end touch). Split that run and the drawn + // pipe 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, { + kinds: ['pipe-segment'], + }) + const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null + const crossTappedElsewhere = + crossHit?.nodeId === body?.nodeId || crossHit?.nodeId === endTapBody?.nodeId + let cross = + crossHit && !crossTappedElsewhere && crossOwner?.type === 'pipe-segment' + ? planPipeCrossAtRunBody(crossOwner, crossHit, dir, diameterRef.current) + : null + + if (remaining <= 0.05) { + bends = [] + tap = null + endTapFinal = null + cross = null + pipeStart = start + pipeEnd = end + } + + const makePipe = (from: [number, number, number], to: [number, number, number]) => + PipeSegmentNode.parse({ + ...pipeSegmentDefinition.defaults(), + name: systemRef.current === 'vent' ? 'Vent' : 'Drain', + path: [from, to], + diameter: diameterRef.current, + system: systemRef.current, + }) + // A cross splits the drawn run into two halves that meet its opposed + // branch collars; otherwise it's one pipe end-to-end. Degenerate + // halves (the crossing too near an end) are dropped. + const pipes = cross + ? [ + dist2(pipeStart, cross.branchCollarNear) > 0.05 * 0.05 + ? makePipe(pipeStart, cross.branchCollarNear) + : null, + dist2(cross.branchCollarFar, pipeEnd) > 0.05 * 0.05 + ? makePipe(cross.branchCollarFar, pipeEnd) + : null, + ].filter((p) => p !== null) + : [makePipe(pipeStart, pipeEnd)] + useScene.getState().applyNodeChanges({ + create: [ + ...bends.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })), + ...(tap + ? [ + { node: tap.fitting, parentId: activeLevelId }, + { node: tap.runTail, parentId: activeLevelId }, + ] + : []), + ...(endTapFinal + ? [ + { node: endTapFinal.fitting, parentId: activeLevelId }, + { node: endTapFinal.runTail, parentId: activeLevelId }, + ] + : []), + ...(cross + ? [ + { node: cross.fitting, parentId: activeLevelId }, + { node: cross.runTail, parentId: activeLevelId }, + ] + : []), + ...pipes.map((node) => ({ node, parentId: activeLevelId })), + ], + update: [ + ...bends.map((plan) => plan.trim), + ...(tap ? [tap.runUpdate as { id: AnyNode['id']; data: Partial }] : []), + ...(endTapFinal + ? [endTapFinal.runUpdate as { id: AnyNode['id']; data: Partial }] + : []), + ...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial }] : []), + ], + }) + triggerSFX('sfx:item-place') + setDraftStart(null) + setSnapTarget(null) + startPortRef.current = null + startBodyRef.current = null + altAnchorRef.current = null + setAltActive(false) + } + + /** Apply the drain fall to an XZ-resolved end point. Only snapped + * starts (fixture drain, run end/body) drop the end — they're + * height-fixed. A free start keeps the end on the grid plane and + * gets LIFTED at commit instead, so the run never sinks below it. */ + const applySlope = ( + start: [number, number, number], + end: [number, number, number], + ): [number, number, number] => { + if (!slopedRef.current || systemRef.current !== 'waste') return end + if (!startPortRef.current && !startBodyRef.current) return end + const run = Math.hypot(end[0] - start[0], end[2] - start[2]) + return [end[0], start[1] - run * DRAIN_SLOPE, end[2]] + } + + const resolveSnappedPoint = ( + event: GridEvent, + ): { + point: [number, number, number] + snapped: [number, number, number] | null + port: ScenePort | null + body: RunBodyHit | null + } => { + const start = startRef.current + if (!start) { + const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] + const step = useEditor.getState().gridSnapStep + const shift = event.nativeEvent?.shiftKey === true + if (event.nativeEvent?.altKey !== true) { + const port = findNearbyPort(raw) + if (port) { + const p: [number, number, number] = [ + port.position[0], + port.position[1], + port.position[2], + ] + return { point: p, snapped: p, port, body: null } + } + // No open end nearby — try the side of a run (wye / santee tap). + // Probe with a grid-snapped cursor so the tap steps along the run + // like every other placement; Shift frees it to ride smoothly. + const probe: [number, number, number] = shift + ? raw + : [snap(raw[0], step), 0, snap(raw[2], step)] + const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { + kinds: ['pipe-segment'], + }) + if (body) return { point: body.point, snapped: body.point, port: null, body } + } + return { + point: [snap(raw[0], step), 0, snap(raw[2], step)], + snapped: null, + port: null, + body: null, + } + } + const rawXZ: [number, number, number] = [ + event.localPosition[0], + start[1], + event.localPosition[2], + ] + const shift = event.nativeEvent?.shiftKey === true + const angled = shift ? rawXZ : projectToAngleLock(start, rawXZ) + const step = useEditor.getState().gridSnapStep + if (event.nativeEvent?.altKey !== true && !shift) { + const port = findNearbyPort(rawXZ) + if (port) { + const p: [number, number, number] = [port.position[0], port.position[1], port.position[2]] + return { point: p, snapped: p, port, body: null } + } + // No open end nearby — landing on the side of a run taps a wye / + // sanitary tee there (mirror of the first-point tap). Probe with a + // grid-snapped cursor so the tap steps along the run; checked against + // the cursor, not the 45° projection, so a slightly-off trunk captures. + const probe: [number, number, number] = [ + snap(rawXZ[0], step), + rawXZ[1], + snap(rawXZ[2], step), + ] + const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { kinds: ['pipe-segment'] }) + if (body) return { point: body.point, snapped: body.point, port: null, body } + } + let end: [number, number, number] + if (shift) { + end = [snap(angled[0], step), angled[1], snap(angled[2], step)] + } else { + // Snap the run LENGTH along the locked ray, not each axis — an + // off-grid start (port / body snap) plus per-axis rounding pulls + // the end off the 45° ray, bending the run as the cursor moves. + const dx = angled[0] - start[0] + const dz = angled[2] - start[2] + const len = Math.hypot(dx, dz) + if (len < 1e-6) { + end = angled + } else { + const s = snap(len, step) / len + end = [start[0] + dx * s, angled[1], start[2] + dz * s] + } + } + return { point: applySlope(start, end), snapped: null, port: null, body: null } + } + + const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => { + const anchor = altAnchorRef.current + const start = startRef.current + if (!anchor || !start) return null + const step = useEditor.getState().gridSnapStep + const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER + const snappedDy = snap(dy, step) + const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) + return [start[0], y, start[2]] + } + + // Resolve the cursor point (port / body / grid / angle snap) then layer + // Figma-style alignment so a run lines up with other runs, fittings, and + // items as it's drawn. Free point (first vertex / Shift) snaps; an + // angle-locked continuation shows the guide passively. Port / body snap or + // Alt bypasses alignment. + const resolveAlignedPoint = (event: GridEvent) => { + const r = resolveSnappedPoint(event) + const hasStart = !!startRef.current + const shift = event.nativeEvent?.shiftKey === true + const alt = event.nativeEvent?.altKey === true + const point = alignDrawPoint(r.point, { + applySnap: !hasStart || shift, + bypass: alt || r.snapped !== null, + }) + return { ...r, point } + } + + const onMove = (event: GridEvent) => { + const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY + if (typeof clientY === 'number') lastClientYRef.current = clientY + if (altAnchorRef.current && typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point) { + clearDrawAlignment() + setCursorPos(point) + setSnapTarget(null) + return + } + } + const { point, snapped } = resolveAlignedPoint(event) + setCursorPos(point) + setSnapTarget(snapped) + } + + const onClick = (event: GridEvent) => { + const start = startRef.current + if (altAnchorRef.current && start) { + const clientY = + (event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current + if (typeof clientY === 'number') { + const point = resolveAltVerticalPoint(clientY) + if (point && Math.abs(point[1] - start[1]) >= 1e-4) commitSegment(start, point) + } + return + } + const { point, port, body } = resolveAlignedPoint(event) + if (!start) { + // First click: anchor the start, remembering the port / run body + // it snapped to so the commit can mint a bend / wye. + triggerSFX('sfx:grid-snap') + startPortRef.current = port + startBodyRef.current = port ? null : body + setDraftStart(point) + return + } + commitSegment(start, point, port, port ? null : body) + } + + const enterAltMode = () => { + const start = startRef.current + if (!start || lastClientYRef.current === null) return + if (altAnchorRef.current) return + altAnchorRef.current = { clientY: lastClientYRef.current, baseY: start[1] } + setAltActive(true) + } + + const exitAltMode = () => { + if (!altAnchorRef.current) return + altAnchorRef.current = null + setAltActive(false) + } + + const stepDiameter = (step: 1 | -1) => { + const sizes = PIPE_DIAMETERS_IN + const current = diameterRef.current + let nearest = 0 + for (let i = 1; i < sizes.length; i++) { + if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i + } + const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]! + if (next === current) return + setDiameter(next) + triggerSFX('sfx:grid-snap') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.key === 'Alt') { + e.preventDefault() + enterAltMode() + } else if (e.key === '[') { + e.preventDefault() + stepDiameter(-1) + } else if (e.key === ']') { + e.preventDefault() + stepDiameter(1) + } else if (e.key === 'q' || e.key === 'Q') { + e.preventDefault() + setSystem((s) => (s === 'waste' ? 'vent' : 'waste')) + triggerSFX('sfx:grid-snap') + } else if (e.key === 's' || e.key === 'S') { + e.preventDefault() + setSloped((s) => !s) + triggerSFX('sfx:grid-snap') + } + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + e.preventDefault() + exitAltMode() + } + } + + const onCancel = () => { + clearDrawAlignment() + if (!startRef.current) return + markToolCancelConsumed() + setDraftStart(null) + setCursorPos(null) + setSnapTarget(null) + startPortRef.current = null + startBodyRef.current = null + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + altAnchorRef.current = null + clearDrawAlignment() + } + }, [activeLevelId]) + + if (!activeLevelId) return null + + // Free waste start lifts at commit so the run falls ONTO the grid — + // mirror that here so the preview line / pill match the placed pipe. + // A snapped end (snapTarget set) keeps the start where it is. + const displayStart = + draftStart && + cursorPos && + sloped && + system === 'waste' && + !startPortRef.current && + !startBodyRef.current && + !snapTarget && + !altActive + ? ([ + draftStart[0], + draftStart[1] + + Math.hypot(cursorPos[0] - draftStart[0], cursorPos[2] - draftStart[2]) * DRAIN_SLOPE, + draftStart[2], + ] as [number, number, number]) + : draftStart + + const pillParts = cursorPos + ? [ + ...(['x', 'y', 'z'] as const).map((axis, i) => ({ + key: axis, + prefix: axis.toUpperCase(), + value: displayStart ? cursorPos[i]! - displayStart[i]! : cursorPos[i]!, + signed: !!displayStart, + })), + { key: 'diameter', prefix: 'Ø', value: diameter * 0.0254, signed: false }, + ] + : null + const pillPrimary = draftStart && cursorPos ? (altActive ? 'y' : 'y') : undefined + + return ( + + {/* Cursor marker — the same ground ring + vertical line + tool-icon + badge the duct draw tool shows in 3D (icon resolved from the active + `pipe-segment` structure-tools entry). In 2D the floorplan overlay + draws this for every tool; in 3D each tool renders its own. The + dimension pill rides just above the cursor. */} + {cursorPos && ( + <> + + {pillParts && ( + + +
+ +
+ {system === 'waste' + ? sloped + ? 'Waste · ¼″/ft fall' + : 'Waste · level' + : 'Vent · level'}{' '} + · Q system{system === 'waste' ? ' · S slope' : ''} +
+
+ +
+ )} + + )} + {snapTarget && ( + + + + + )} + {displayStart && ( + + + + + )} + {displayStart && cursorPos && ( + + )} +
+ ) +} + +function PreviewPipe({ + a, + b, + diameterIn, +}: { + a: [number, number, number] + b: [number, number, number] + diameterIn: number +}) { + const start = new Vector3(...a) + const end = new Vector3(...b) + const dir = new Vector3().subVectors(end, start) + const length = dir.length() + if (length < 1e-4) return null + dir.normalize() + const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5) + const radius = (diameterIn * 0.0254) / 2 + return ( + { + if (!m) return + m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir) + }} + > + + + + ) +} + +export default PipeSegmentTool diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts new file mode 100644 index 00000000..541afb06 --- /dev/null +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -0,0 +1,71 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildPipeTrapFloorplan } from './floorplan' +import { buildPipeTrapGeometry } from './geometry' +import { pipeTrapParametrics } from './parametrics' +import { getPipeTrapPorts } from './ports' +import { PipeTrapNode } from './schema' + +/** + * DWV P-trap — the water-seal fitting on the waste line. Placed by its + * own click tool; the pipe tool then draws the trap arm off the outlet. + * Modeled explicitly so the IPC 909.1 trap-arm rule has a node to + * validate. + */ +export const pipeTrapDefinition: NodeDefinition = { + kind: 'pipe-trap', + schemaVersion: 1, + schema: PipeTrapNode, + category: 'utility', + distributionRole: 'fitting', + portConnectivityFollow: false, // trap is anchored; dragging a connected run stretches the arm, not the trap + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + diameter: 1.5, + pipeMaterial: 'pvc', + armLengthM: 0, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'y', 'z'], gridSnap: true, portSnap: { systems: ['waste'] } }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + }, + + parametrics: pipeTrapParametrics, + + geometry: buildPipeTrapGeometry, + geometryKey: (n) => JSON.stringify([n.diameter, n.pipeMaterial, n.armLengthM]), + + ports: getPipeTrapPorts, + + floorplan: buildPipeTrapFloorplan, + + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place trap' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Shift', label: 'Smooth (no grid snap)' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Trap', + description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.', + icon: { kind: 'iconify', name: 'lucide:spline' }, + paletteSection: 'structure', + paletteOrder: 98, + }, + + mcp: { + description: + 'A DWV P-trap with inlet (up) and outlet (trap arm) ports. Position is level-local meters; rotation is yaw radians. armLengthM is the trap-arm developed length checked against IPC 909.1.', + }, +} diff --git a/packages/nodes/src/pipe-trap/floorplan.ts b/packages/nodes/src/pipe-trap/floorplan.ts new file mode 100644 index 00000000..9a7a8126 --- /dev/null +++ b/packages/nodes/src/pipe-trap/floorplan.ts @@ -0,0 +1,53 @@ +import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import { getPipeTrapPorts } from './ports' +import type { PipeTrapNode } from './schema' + +const PIPE_STROKE = '#57534e' + +/** + * Floor-plan symbol — the conventional trap glyph: a short stub at the + * inlet (the fixture drop, drawn as a dot since it's vertical) and a + * solid line for the trap arm out to the outlet. Reads as the P-trap's + * arm in plan. + */ +export function buildPipeTrapFloorplan( + node: PipeTrapNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const ports = getPipeTrapPorts(node) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + + const view = ctx.viewState + const palette = view?.palette + const showSelectedChrome = (view?.selected || view?.highlighted) ?? false + const stroke = showSelectedChrome && palette ? palette.selectedStroke : PIPE_STROKE + + const inletXZ: FloorplanPoint = [inlet.position[0], inlet.position[2]] + const outletXZ: FloorplanPoint = [outlet.position[0], outlet.position[2]] + + const children: FloorplanGeometry[] = [ + { + kind: 'polyline', + points: [inletXZ, outletXZ], + stroke, + strokeWidth: showSelectedChrome ? 2.5 : 1.8, + vectorEffect: 'non-scaling-stroke', + opacity: 0.9, + }, + { + kind: 'circle', + cx: inletXZ[0], + cy: inletXZ[1], + r: 0.04, + fill: stroke, + opacity: 0.9, + }, + ] + + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [node.position[0], node.position[2]] }) + } + + return { kind: 'group', children } +} diff --git a/packages/nodes/src/pipe-trap/geometry.ts b/packages/nodes/src/pipe-trap/geometry.ts new file mode 100644 index 00000000..56f78821 --- /dev/null +++ b/packages/nodes/src/pipe-trap/geometry.ts @@ -0,0 +1,69 @@ +import { Group, Mesh, TorusGeometry, Vector3 } from 'three' +import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry' +import { createPipeMaterial } from '../pipe-segment/geometry' +import type { PipeTrapNode } from './schema' + +const BEND_SEGMENTS = 24 + +/** Inlet drop and arm reach in pipe radii — keeps the trap proportional + * to its size without per-size tuning. */ +const INLET_DROP_RADII = 2.6 +const ARM_REACH_RADII = 3.2 + +/** + * P-trap geometry in the LOCAL frame (origin at the trap weir, the low + * point of the U). Inlet stub rises +Y to the fixture tailpiece; a + * half-torus U-bend turns the flow; the trap arm runs +X toward the + * vented waste line. `` applies position + yaw. + */ +export function buildPipeTrapGeometry(node: PipeTrapNode): Group { + const group = new Group() + const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' }) + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const bendR = radius * 1.6 + + // U-bend: half torus in the XY plane, opening upward. Sits so its two + // tops are at y = bendR (the inlet riser and the arm rise). + const bend = new Mesh(new TorusGeometry(bendR, radius, 12, BEND_SEGMENTS, Math.PI), material) + bend.rotation.z = Math.PI // open side up + bend.position.set(bendR, bendR, 0) + bend.name = 'pipe-trap-bend' + group.add(bend) + + // Inlet riser: from the left top of the U straight up to the fixture. + const inletDrop = radius * INLET_DROP_RADII + const inletTop = new Vector3(0, bendR + inletDrop, 0) + const inletStub = buildSection( + new Vector3(0, bendR, 0), + inletTop, + radius, + material, + 'pipe-trap-inlet', + ) + if (inletStub) group.add(inletStub) + + // Trap arm: from the right top of the U horizontally along +X. + const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM) + const armStart = new Vector3(bendR * 2, bendR, 0) + const armEnd = new Vector3(bendR * 2 + armReach, bendR, 0) + const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm') + if (arm) group.add(arm) + + return group +} + +/** Local-frame port positions (before position/yaw): inlet at the top + * of the riser facing +Y, outlet at the end of the arm facing +X. */ +export function localTrapPorts(node: PipeTrapNode): { + inlet: Vector3 + outlet: Vector3 +} { + const radius = (node.diameter * INCHES_TO_METERS) / 2 + const bendR = radius * 1.6 + const inletDrop = radius * INLET_DROP_RADII + const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM) + return { + inlet: new Vector3(0, bendR + inletDrop, 0), + outlet: new Vector3(bendR * 2 + armReach, bendR, 0), + } +} diff --git a/packages/nodes/src/pipe-trap/index.ts b/packages/nodes/src/pipe-trap/index.ts new file mode 100644 index 00000000..94928a2e --- /dev/null +++ b/packages/nodes/src/pipe-trap/index.ts @@ -0,0 +1,4 @@ +export { pipeTrapDefinition } from './definition' +export { buildPipeTrapGeometry } from './geometry' +export { getPipeTrapPorts } from './ports' +export { PipeTrapNode } from './schema' diff --git a/packages/nodes/src/pipe-trap/parametrics.ts b/packages/nodes/src/pipe-trap/parametrics.ts new file mode 100644 index 00000000..502377cb --- /dev/null +++ b/packages/nodes/src/pipe-trap/parametrics.ts @@ -0,0 +1,19 @@ +import type { ParametricDescriptor } from '@pascal-app/core' +import type { PipeTrapNode } from './schema' + +export const pipeTrapParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Trap', + fields: [ + { key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 4, step: 0.25 }, + { key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] }, + { key: 'armLengthM', kind: 'number', unit: 'm', min: 0, max: 4, step: 0.05 }, + ], + }, + { + label: 'Placement', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], +} diff --git a/packages/nodes/src/pipe-trap/ports.ts b/packages/nodes/src/pipe-trap/ports.ts new file mode 100644 index 00000000..ce7a954e --- /dev/null +++ b/packages/nodes/src/pipe-trap/ports.ts @@ -0,0 +1,35 @@ +import type { NodePort } from '@pascal-app/core' +import { Vector3 } from 'three' +import { localTrapPorts } from './geometry' +import type { PipeTrapNode } from './schema' + +/** + * `def.ports` — the trap's inlet (up, to the fixture) and outlet (the + * trap arm, toward the vented waste line), transformed by position + + * yaw into level-local space. Both carry the trap diameter and the + * 'waste' system tag so the pipe tool and system graph treat them like + * any other DWV joint. + */ +export function getPipeTrapPorts(node: PipeTrapNode): NodePort[] { + const { inlet, outlet } = localTrapPorts(node) + const yaw = node.rotation + const offset = new Vector3(node.position[0], node.position[1], node.position[2]) + const place = (local: Vector3, dir: Vector3): NodePort => { + const position = local + .clone() + .applyAxisAngle(new Vector3(0, 1, 0), yaw) + .add(offset) + const direction = dir + .clone() + .applyAxisAngle(new Vector3(0, 1, 0), yaw) + .normalize() + return { + id: local === inlet ? 'inlet' : 'outlet', + position: [position.x, position.y, position.z] as const, + direction: [direction.x, direction.y, direction.z] as const, + diameter: node.diameter, + system: 'waste', + } + } + return [place(inlet, new Vector3(0, 1, 0)), place(outlet, new Vector3(1, 0, 0))] +} diff --git a/packages/nodes/src/pipe-trap/schema.ts b/packages/nodes/src/pipe-trap/schema.ts new file mode 100644 index 00000000..d99c585b --- /dev/null +++ b/packages/nodes/src/pipe-trap/schema.ts @@ -0,0 +1 @@ +export { PipeTrapNode } from '@pascal-app/core' diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx new file mode 100644 index 00000000..5de8795f --- /dev/null +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -0,0 +1,135 @@ +'use client' + +import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core' +import { triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { pipeTrapDefinition } from './definition' +import { buildPipeTrapGeometry } from './geometry' + +const PREVIEW_OPACITY = 0.55 +const ROTATE_STEP_RAD = Math.PI / 4 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +/** + * Click-place tool for P-traps. The ghost follows the cursor on the + * floor. **R / T** rotate the arm ±45°, **Shift** disables grid snap. + * The pipe tool then draws the trap arm off the outlet toward the vent. + */ +const PipeTrapTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const [yaw, setYaw] = useState(0) + const [diameter] = useState(1.5) + const yawRef = useRef(0) + const diameterRef = useRef(diameter) + diameterRef.current = diameter + + const previewNode = useMemo( + () => + PipeTrapNode.parse({ + ...pipeTrapDefinition.defaults(), + diameter, + }), + [diameter], + ) + const ghost = useMemo(() => { + const group = buildPipeTrapGeometry(previewNode) + group.traverse((child) => { + const mesh = child as { material?: { transparent: boolean; opacity: number } } + if (mesh.material) { + mesh.material.transparent = true + mesh.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent) => { + const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep + return { + position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [ + number, + number, + number, + ], + diameter: diameterRef.current, + } + } + + const onMove = (event: GridEvent) => { + setCursor(resolve(event).position) + } + + const onClick = (event: GridEvent) => { + const r = resolve(event) + const trap = PipeTrapNode.parse({ + ...pipeTrapDefinition.defaults(), + diameter: r.diameter, + position: r.position, + rotation: yawRef.current, + }) + useScene.getState().createNode(trap, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [trap.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + const key = e.key + if (key === 'r' || key === 'R' || key === 't' || key === 'T') { + e.preventDefault() + e.stopPropagation() + const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + triggerSFX('sfx:item-rotate') + } + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + } + }, [activeLevelId]) + + if (!activeLevelId || !cursor) return null + + return ( + + + + + +
+ {diameter}" Trap + + · + + R/T rotate +
+ +
+ ) +} + +export default PipeTrapTool diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index ec702f5f..9d11c863 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -205,7 +205,7 @@ export const ridgeVentDefinition: NodeDefinition = { presentation: { label: 'Ridge Vent', description: 'Ventilation strip running along the ridge of a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 121, }, diff --git a/packages/nodes/src/ridge-vent/panel.tsx b/packages/nodes/src/ridge-vent/panel.tsx index dd0f2e7c..957c7795 100644 --- a/packages/nodes/src/ridge-vent/panel.tsx +++ b/packages/nodes/src/ridge-vent/panel.tsx @@ -128,7 +128,7 @@ export default function RidgeVentPanel() { return ( { +const RidgeVentPreview = ({ node, invalid }: { node: RidgeVentNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildRidgeVentGeometry(node), [node.length, node.width, node.height, node.style, node.endCaps], @@ -14,17 +16,17 @@ const RidgeVentPreview = ({ node }: { node: RidgeVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0xff_ff_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, emissiveIntensity: 0.12, roughness: 0.85, metalness: 0.05, transparent: true, - opacity: 0.55, + opacity: invalid ? 0.4 : 0.55, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 73886595..15386ed9 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -88,6 +88,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { : segmentStore : undefined + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildRidgeVentGeometry(node), [node.length, node.width, node.height, node.style, node.endCaps], diff --git a/packages/nodes/src/ridge-vent/tool.tsx b/packages/nodes/src/ridge-vent/tool.tsx index f0e82fed..f7369355 100644 --- a/packages/nodes/src/ridge-vent/tool.tsx +++ b/packages/nodes/src/ridge-vent/tool.tsx @@ -140,6 +140,7 @@ const RidgeVentTool = () => { <> } isValidRoofTarget={(event) => { const hit = resolveRoofSegmentHit( event.node as RoofNode, @@ -150,7 +151,6 @@ const RidgeVentTool = () => { return !!hit && !!resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) }} onInvalidTarget={() => setPreviewPos(null)} - size={[2, 0.15, 0.35]} /> {activeBuildingId && previewPos && ( diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts index 71d64914..ad0b50ae 100644 --- a/packages/nodes/src/roof-segment/definition.ts +++ b/packages/nodes/src/roof-segment/definition.ts @@ -313,7 +313,7 @@ export const roofSegmentDefinition: NodeDefinition = { presentation: { label: 'Roof Segment', description: 'A single pitched plane of a parent roof.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 101, }, diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 9a8a7ddb..907f55fb 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -121,7 +121,7 @@ export default function RoofSegmentPanel() { return ( { // slot 1 → 'wall' (deck top & shingle eave bands) // slot 2 → 'wall' (interior) // slot 3 → 'top' (shingle / roof surface) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const customMaterial = useMemo(() => { const resolveSlot = (role: RoofSegmentSurfaceMaterialRole): THREE.Material | null => { const parentSpec = parentNode ? getEffectiveRoofSurfaceMaterial(parentNode, role) : undefined diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index fcc70b6e..8860e79f 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -179,7 +179,7 @@ export const roofDefinition: NodeDefinition = { presentation: { label: 'Roof', description: 'A pitched / hip / gable roof composed of one or more segments.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 100, }, diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 094926c1..6424af86 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -249,7 +249,7 @@ export default function RoofPanel() { return ( = { presentation: { label: 'Scan', description: 'A captured mesh (LiDAR / photogrammetry) imported as a scene reference.', - icon: { kind: 'url', src: '/icons/mesh.png' }, + icon: { kind: 'url', src: '/icons/mesh.webp' }, paletteSection: 'site', paletteOrder: 40, }, diff --git a/packages/nodes/src/shared/auto-fitting.test.ts b/packages/nodes/src/shared/auto-fitting.test.ts new file mode 100644 index 00000000..81447fcd --- /dev/null +++ b/packages/nodes/src/shared/auto-fitting.test.ts @@ -0,0 +1,659 @@ +import { describe, expect, test } from 'bun:test' +import { getDuctFittingPorts } from '../duct-fitting/ports' +import { type DuctProfile, planElbowAtPort, planElbowRealign } from './auto-fitting' +import type { ScenePort } from './ports' + +type Point = [number, number, number] + +function port(position: Point, direction: Point): ScenePort { + return { + id: 'end', + nodeId: 'duct-segment_test' as ScenePort['nodeId'], + position, + direction, + diameter: 6, + system: 'supply', + } +} + +const ROUND_6: DuctProfile = { shape: 'round', diameter: 6, width: 14, height: 8 } + +function dist(a: readonly number[], b: readonly number[]): number { + return Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!) +} + +function dot(a: readonly number[], b: readonly number[]): number { + return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]! +} + +/** + * The real invariant: run the planned elbow back through the fitting + * kind's OWN port math and check the joint composes — junction centered + * on the drawn corner, inlet collar sitting where the trimmed run now + * ends (facing back into it), outlet sitting on the returned collar + * point facing along the new run. + */ +function expectMated(joint: ScenePort, away: Point) { + const plan = planElbowAtPort(joint, away, ROUND_6) + expect(plan).not.toBeNull() + const ports = getDuctFittingPorts(plan!.fitting) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + + expect(dist(plan!.fitting.position, joint.position)).toBeLessThan(1e-6) + expect(dist(inlet.position, plan!.trimmedPortPoint)).toBeLessThan(1e-6) + expect(dot(inlet.direction, joint.direction)).toBeCloseTo(-1, 6) + expect(dist(outlet.position, plan!.collarPoint)).toBeLessThan(1e-6) + expect(dot(outlet.direction, away)).toBeCloseTo(1, 6) + return plan! +} + +describe('planElbowAtPort', () => { + test('90° horizontal turn (+X run turning to +Z)', () => { + const plan = expectMated(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1]) + expect(plan.fitting.angle).toBeCloseTo(90, 6) + }) + + test('45° horizontal turn', () => { + const d = Math.SQRT1_2 + const plan = expectMated(port([3, 2.4, 0], [1, 0, 0]), [d, 0, d]) + expect(plan.fitting.angle).toBeCloseTo(45, 6) + }) + + test('vertical riser turn (horizontal run turning straight up)', () => { + const plan = expectMated(port([3, 0, 1], [1, 0, 0]), [0, 1, 0]) + expect(plan.fitting.angle).toBeCloseTo(90, 6) + }) + + test('riser topping out into a horizontal run', () => { + expectMated(port([3, 2.4, 1], [0, 1, 0]), [0, 0, -1]) + }) + + test('straight continuation → no fitting', () => { + expect(planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull() + }) + + test('shallow 10° turn → no fitting (below the 15° elbow minimum)', () => { + const t = (10 * Math.PI) / 180 + expect( + planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [Math.cos(t), 0, Math.sin(t)], ROUND_6), + ).toBeNull() + }) + + test('doubling back past 90° → no fitting', () => { + const t = (135 * Math.PI) / 180 + expect( + planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [Math.cos(t), 0, Math.sin(t)], ROUND_6), + ).toBeNull() + }) + + test('rect profile: elbow carries the trunk W×H and equivalent diameter', () => { + const rect: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 } + const plan = planElbowAtPort(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1], rect) + expect(plan).not.toBeNull() + expect(plan!.fitting.shape).toBe('rect') + expect(plan!.fitting.width).toBe(14) + expect(plan!.fitting.height).toBe(8) + expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6) + }) + + test('oval profile: elbow carries the trunk W×H and oval equivalent diameter', () => { + const oval: DuctProfile = { shape: 'oval', diameter: 6, width: 14, height: 8 } + const plan = planElbowAtPort(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1], oval) + expect(plan).not.toBeNull() + expect(plan!.fitting.shape).toBe('oval') + expect(plan!.fitting.width).toBe(14) + expect(plan!.fitting.height).toBe(8) + // Flat-oval area: (14 − 8) × 8 + π(8/2)² + const area = (14 - 8) * 8 + Math.PI * 16 + expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt(area / Math.PI), 6) + }) + + test('junction on the corner; trim and collar one leg out on each side', () => { + const plan = expectMated(port([0, 0, 0], [1, 0, 0]), [0, 0, 1]) + // Junction exactly at the drawn corner. + expect(dist(plan.fitting.position, [0, 0, 0])).toBeLessThan(1e-6) + // Existing run (arriving along +X) trims back along -X... + expect(plan.trimmedPortPoint[0]).toBeLessThan(0) + expect(plan.trimmedPortPoint[1]).toBeCloseTo(0, 6) + expect(plan.trimmedPortPoint[2]).toBeCloseTo(0, 6) + // ...and the new run starts one leg out along +Z. + expect(plan.collarPoint[0]).toBeCloseTo(0, 6) + expect(plan.collarPoint[2]).toBeGreaterThan(0) + // Symmetric legs. + expect(dist(plan.trimmedPortPoint, [0, 0, 0])).toBeCloseTo(dist(plan.collarPoint, [0, 0, 0]), 6) + }) +}) + +import { DuctFittingNode, DuctSegmentNode } from '@pascal-app/core' +import { planCrossAtRunBody, planTeeAtRunBody } from './auto-fitting' +import type { RunBodyHit } from './ports' + +function trunk(path: Point[]): DuctSegmentNode { + return DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Trunk', + path, + diameter: 8, + ductMaterial: 'sheet-metal', + insulationR: 0, + system: 'supply', + }) +} + +function bodyHit(node: DuctSegmentNode, segmentIndex: number, point: Point): RunBodyHit { + return { nodeId: node.id, segmentIndex, point } +} + +describe('planTeeAtRunBody', () => { + test('mid-trunk tap: junction on the hit, run legs mate the split halves', () => { + const run = trunk([ + [0, 2.4, 0], + [6, 2.4, 0], + ]) + const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6) + expect(plan).not.toBeNull() + + const ports = getDuctFittingPorts(plan!.fitting) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + + // Junction exactly on the centerline hit. + expect(dist(plan!.fitting.position, [3, 2.4, 0])).toBeLessThan(1e-6) + // Trunk keeps the upstream half, ending at the inlet collar. + const upstream = plan!.trunkUpdate.data.path + expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6) + expect(dot(inlet.direction, [-1, 0, 0])).toBeCloseTo(1, 6) + // Tail carries the rest, starting at the outlet collar. + expect(dist(plan!.trunkTail.path[0]!, outlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.trunkTail.path[1]!, [6, 2.4, 0])).toBeLessThan(1e-6) + // Branch collar square to the run, where the new duct starts. + expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6) + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + // Tee carries trunk diameter on the run, branch diameter on the collar. + expect(plan!.fitting.diameter).toBe(8) + expect(plan!.fitting.diameter2).toBe(6) + }) + + test('45° drawn branch leaves square (projected perpendicular)', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + const d = Math.SQRT1_2 + const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [d, 0, d], ROUND_6) + expect(plan).not.toBeNull() + const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')! + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + }) + + test('tap too close to a run end → null (use the end port instead)', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planTeeAtRunBody(run, bodyHit(run, 0, [0.1, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull() + expect(planTeeAtRunBody(run, bodyHit(run, 0, [5.95, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull() + }) + + test('branch parallel to the trunk → null', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull() + }) + + test('vertical drop off a horizontal trunk', () => { + const run = trunk([ + [0, 2.4, 0], + [6, 2.4, 0], + ]) + const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, -1, 0], ROUND_6) + expect(plan).not.toBeNull() + const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')! + expect(dot(branch.direction, [0, -1, 0])).toBeCloseTo(1, 6) + expect(plan!.branchCollar[1]).toBeLessThan(2.4) + }) + + test('rect trunk: tee sized to the equivalent diameter, tail stays rect', () => { + const rect = DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Trunk', + path: [ + [0, 2.4, 0], + [6, 2.4, 0], + ], + shape: 'rect', + diameter: 6, + width: 14, + height: 8, + ductMaterial: 'sheet-metal', + insulationR: 0, + system: 'supply', + }) + const plan = planTeeAtRunBody(rect, bodyHit(rect, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6) + expect(plan).not.toBeNull() + // Tee run legs carry the area-equivalent round size of 14×8. + expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6) + expect(plan!.fitting.diameter2).toBe(6) + // The downstream half keeps the trunk's rect profile. + expect(plan!.trunkTail.shape).toBe('rect') + expect(plan!.trunkTail.width).toBe(14) + expect(plan!.trunkTail.height).toBe(8) + }) + + test('rect branch: tee carries the branch W×H profile and equivalent diameter', () => { + const run = trunk([ + [0, 2.4, 0], + [6, 2.4, 0], + ]) + const rectBranch: DuctProfile = { shape: 'rect', diameter: 6, width: 12, height: 6 } + const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], rectBranch) + expect(plan).not.toBeNull() + expect(plan!.fitting.shape2).toBe('rect') + expect(plan!.fitting.width2).toBe(12) + expect(plan!.fitting.height2).toBe(6) + expect(plan!.fitting.diameter2).toBeCloseTo(2 * Math.sqrt((12 * 6) / Math.PI), 6) + }) + + test('oval branch: tee carries the branch W×H profile and oval equivalent diameter', () => { + const run = trunk([ + [0, 2.4, 0], + [6, 2.4, 0], + ]) + const ovalBranch: DuctProfile = { shape: 'oval', diameter: 6, width: 12, height: 6 } + const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ovalBranch) + expect(plan).not.toBeNull() + expect(plan!.fitting.shape2).toBe('oval') + expect(plan!.fitting.width2).toBe(12) + expect(plan!.fitting.height2).toBe(6) + const area = (12 - 6) * 6 + Math.PI * 9 + expect(plan!.fitting.diameter2).toBeCloseTo(2 * Math.sqrt(area / Math.PI), 6) + }) + + test('polyline trunk: split lands in the hit segment, other points preserved', () => { + const run = trunk([ + [0, 0, 0], + [4, 0, 0], + [4, 0, 4], + ]) + const plan = planTeeAtRunBody(run, bodyHit(run, 1, [4, 0, 2]), [1, 0, 0], ROUND_6) + expect(plan).not.toBeNull() + // Upstream half keeps both leading points. + expect(plan!.trunkUpdate.data.path.length).toBe(3) + expect(dist(plan!.trunkUpdate.data.path[0]!, [0, 0, 0])).toBeLessThan(1e-6) + expect(dist(plan!.trunkUpdate.data.path[1]!, [4, 0, 0])).toBeLessThan(1e-6) + // Tail runs from past the tap to the original end. + expect(dist(plan!.trunkTail.path[1]!, [4, 0, 4])).toBeLessThan(1e-6) + }) +}) + +describe('planCrossAtRunBody', () => { + test('drawn run through a trunk: junction on the hit, four legs mate', () => { + const run = trunk([ + [0, 2.4, 0], + [6, 2.4, 0], + ]) + // Drawn run goes -Z → +Z straight through the trunk at x=3. + const plan = planCrossAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6) + expect(plan).not.toBeNull() + + const ports = getDuctFittingPorts(plan!.fitting) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + const branch2 = ports.find((p) => p.id === 'branch2')! + + // Junction exactly on the centerline hit. + expect(dist(plan!.fitting.position, [3, 2.4, 0])).toBeLessThan(1e-6) + // Run legs along the trunk axis; trunk split halves mate them. + const upstream = plan!.trunkUpdate.data.path + expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.trunkTail.path[0]!, outlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.trunkTail.path[1]!, [6, 2.4, 0])).toBeLessThan(1e-6) + // Opposed branches square to the run; collars where the drawn halves meet. + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6) + expect(dist(plan!.branchCollarFar, branch.position)).toBeLessThan(1e-6) + expect(dist(plan!.branchCollarNear, branch2.position)).toBeLessThan(1e-6) + // Cross carries trunk diameter on the run, branch diameter on the collars. + expect(plan!.fitting.diameter).toBe(8) + expect(plan!.fitting.diameter2).toBe(6) + }) + + test('near / far collars sit on opposite sides of the trunk', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + const plan = planCrossAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [0, 0, 1], ROUND_6) + expect(plan).not.toBeNull() + // awayDir is +Z, so the far collar (drawn end side) is +Z, near is -Z. + expect(plan!.branchCollarFar[2]).toBeGreaterThan(0) + expect(plan!.branchCollarNear[2]).toBeLessThan(0) + }) + + test('crossing too close to a trunk end → null', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planCrossAtRunBody(run, bodyHit(run, 0, [0.1, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull() + expect(planCrossAtRunBody(run, bodyHit(run, 0, [5.95, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull() + }) + + test('drawn run parallel to the trunk → null', () => { + const run = trunk([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planCrossAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull() + }) + + test('rect trunk: cross sized to the equivalent diameter, tail stays rect', () => { + const rect = DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Trunk', + path: [ + [0, 2.4, 0], + [6, 2.4, 0], + ], + shape: 'rect', + diameter: 6, + width: 14, + height: 8, + ductMaterial: 'sheet-metal', + insulationR: 0, + system: 'supply', + }) + const plan = planCrossAtRunBody(rect, bodyHit(rect, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6) + expect(plan).not.toBeNull() + expect(plan!.fitting.shape).toBe('rect') + expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6) + expect(plan!.trunkTail.shape).toBe('rect') + expect(plan!.trunkTail.width).toBe(14) + }) +}) + +describe('cross ports', () => { + function cross(): DuctFittingNode { + return DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Cross', + fittingType: 'cross', + diameter: 8, + diameter2: 6, + system: 'supply', + }) + } + + test('four opposed ports: run ±X at diameter, branches ±Z at diameter2', () => { + const ports = getDuctFittingPorts(cross()) + expect(ports).toHaveLength(4) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + const branch2 = ports.find((p) => p.id === 'branch2')! + expect(dot(inlet.direction, [-1, 0, 0])).toBeCloseTo(1, 6) + expect(dot(outlet.direction, [1, 0, 0])).toBeCloseTo(1, 6) + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6) + expect(inlet.diameter).toBe(8) + expect(branch.diameter).toBe(6) + expect(branch2.diameter).toBe(6) + }) +}) + +describe('tee branchAngle (lateral)', () => { + function tee(branchAngle: number): DuctFittingNode { + return DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Tee', + fittingType: 'tee', + diameter: 8, + diameter2: 6, + branchAngle, + system: 'supply', + }) + } + + test('90° branch leaves square to the run (+Z), run legs untouched', () => { + const ports = getDuctFittingPorts(tee(90)) + const branch = ports.find((p) => p.id === 'branch')! + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(dot(ports.find((p) => p.id === 'inlet')!.direction, [-1, 0, 0])).toBeCloseTo(1, 6) + expect(dot(ports.find((p) => p.id === 'outlet')!.direction, [1, 0, 0])).toBeCloseTo(1, 6) + }) + + test('45° lateral sweeps the branch downstream toward the outlet', () => { + const d = Math.SQRT1_2 + const branch = getDuctFittingPorts(tee(45)).find((p) => p.id === 'branch')! + // Leans equally toward +X (outlet) and +Z; collar sits along that ray. + expect(dot(branch.direction, [d, 0, d])).toBeCloseTo(1, 6) + expect(branch.position[0]).toBeGreaterThan(0) + expect(branch.position[2]).toBeGreaterThan(0) + }) + + test('135° lateral leans the branch upstream toward the inlet', () => { + const d = Math.SQRT1_2 + const branch = getDuctFittingPorts(tee(135)).find((p) => p.id === 'branch')! + // Mirror of 45°: leans toward -X (inlet) and +Z. + expect(dot(branch.direction, [-d, 0, d])).toBeCloseTo(1, 6) + expect(branch.position[0]).toBeLessThan(0) + expect(branch.position[2]).toBeGreaterThan(0) + }) +}) + +describe('planElbowRealign', () => { + // A 90° elbow as the draw tool mints it: horizontal run arrives along + // +X (inlet mated), free outlet pointing +Z. + function existingElbow() { + const plan = planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [0, 0, 1], ROUND_6)! + return plan.fitting + } + + function realigned(elbow: ReturnType, away: Point) { + const plan = planElbowRealign(elbow, 'outlet', away) + expect(plan).not.toBeNull() + const patched = { ...elbow, ...plan!.update.data } as typeof elbow + return { plan: plan!, ports: getDuctFittingPorts(patched) } + } + + test('free collar swings to the incoming run; mated collar stays put', () => { + const elbow = existingElbow() + const before = getDuctFittingPorts(elbow) + const inletBefore = before.find((p) => p.id === 'inlet')! + + // Incoming slope: up at 60° from the trunk plane. + const away: Point = [0, Math.sin(Math.PI / 3), Math.cos(Math.PI / 3)] + const { plan, ports } = realigned(elbow, away) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + + // Mated inlet collar unchanged — the horizontal run stays connected. + expect(dist(inlet.position, inletBefore.position)).toBeLessThan(1e-6) + expect(dot(inlet.direction, inletBefore.direction)).toBeCloseTo(1, 6) + // Free outlet now faces the slope, collar one leg out along it. + expect(dot(outlet.direction, away)).toBeCloseTo(1, 6) + expect(dist(outlet.position, plan.collarPoint)).toBeLessThan(1e-6) + }) + + test('straight-on arrival keeps the same geometry (no-op realign)', () => { + const elbow = existingElbow() + const { ports } = realigned(elbow, [0, 0, 1]) + const outlet = ports.find((p) => p.id === 'outlet')! + expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6) + }) + + test('arrival needing a turn outside 15–90° → null', () => { + const elbow = existingElbow() + // Away nearly opposite the fixed inlet direction → turn < 15°. + expect(planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])).toBeNull() + // Away aligned WITH the fixed collar direction → turn > 90°. + expect(planElbowRealign(elbow, 'outlet', [-0.99, 0, 0.14])).toBeNull() + }) + + test('non-elbow fittings are left alone', () => { + const elbow = existingElbow() + const tee = { ...elbow, fittingType: 'tee' as const } + expect(planElbowRealign(tee, 'outlet', [0, 1, 0])).toBeNull() + }) +}) + +import { PipeFittingNode, PipeSegmentNode } from '@pascal-app/core' +import { getPipeFittingPorts } from '../pipe-fitting/ports' +import { planPipeBranchTap, planPipeCrossAtRunBody } from './auto-fitting' + +function pipeRun(path: Point[]): PipeSegmentNode { + return PipeSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Drain', + path, + diameter: 2, + system: 'waste', + }) +} + +function pipeBodyHit(node: PipeSegmentNode, segmentIndex: number, point: Point): RunBodyHit { + return { nodeId: node.id, segmentIndex, point } +} + +describe('planPipeBranchTap', () => { + test('horizontal drain tap mints a SQUARE sanitary tee (not a wye)', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2) + expect(plan).not.toBeNull() + expect(plan!.fitting.fittingType).toBe('sanitary-tee') + const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')! + // Branch leaves square to the run regardless of the drawn lead-in. + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + }) + + test('45° drawn branch still enters square (projected perpendicular)', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + const d = Math.SQRT1_2 + const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [d, 0, d], 2) + expect(plan).not.toBeNull() + const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')! + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + }) + + test('junction on the hit, run legs mate the split halves', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2) + expect(plan).not.toBeNull() + const ports = getPipeFittingPorts(plan!.fitting) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + expect(dist(plan!.fitting.position, [3, 0, 0])).toBeLessThan(1e-6) + const upstream = plan!.runUpdate.data.path + expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6) + }) + + test('tap too close to a run end → null', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planPipeBranchTap(run, pipeBodyHit(run, 0, [0.02, 0, 0]), [0, 0, 1], 2)).toBeNull() + }) +}) + +describe('planPipeCrossAtRunBody', () => { + test('drawn run through a run: junction on the hit, four legs mate', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + const plan = planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2) + expect(plan).not.toBeNull() + expect(plan!.fitting.fittingType).toBe('cross') + const ports = getPipeFittingPorts(plan!.fitting) + expect(ports).toHaveLength(4) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + const branch = ports.find((p) => p.id === 'branch')! + const branch2 = ports.find((p) => p.id === 'branch2')! + expect(dist(plan!.fitting.position, [3, 0, 0])).toBeLessThan(1e-6) + const upstream = plan!.runUpdate.data.path + expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6) + // awayDir +Z → far collar (drawn end) on +Z branch, near on -Z branch2. + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6) + expect(dist(plan!.branchCollarFar, branch.position)).toBeLessThan(1e-6) + expect(dist(plan!.branchCollarNear, branch2.position)).toBeLessThan(1e-6) + }) + + test('crossing too close to a run end → null', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [0.02, 0, 0]), [0, 0, 1], 2)).toBeNull() + }) + + test('drawn run parallel to the run → null', () => { + const run = pipeRun([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [3, 0, 0]), [1, 0, 0], 2)).toBeNull() + }) +}) + +describe('cross pipe ports', () => { + test('four opposed ports: run ±X at diameter, branches ±Z at diameter2', () => { + const cross = PipeFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Cross', + fittingType: 'cross', + diameter: 3, + diameter2: 2, + system: 'waste', + }) + const ports = getPipeFittingPorts(cross) + expect(ports).toHaveLength(4) + const branch = ports.find((p) => p.id === 'branch')! + const branch2 = ports.find((p) => p.id === 'branch2')! + expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6) + expect(branch.diameter).toBe(2) + expect(branch2.diameter).toBe(2) + }) +}) diff --git a/packages/nodes/src/shared/auto-fitting.ts b/packages/nodes/src/shared/auto-fitting.ts new file mode 100644 index 00000000..6d14c351 --- /dev/null +++ b/packages/nodes/src/shared/auto-fitting.ts @@ -0,0 +1,799 @@ +import { + DuctFittingNode, + DuctSegmentNode, + PipeFittingNode, + PipeSegmentNode, +} from '@pascal-app/core' +import { Euler, Matrix4, Quaternion, Vector3 } from 'three' +import { fittingLegLength } from '../duct-fitting/ports' +import { + ductPortDiameterIn, + equivalentDiameterIn, + ovalEquivalentDiameterIn, +} from '../duct-segment/geometry' +import { pipeFittingLegLength } from '../pipe-fitting/ports' +import type { RunBodyHit, ScenePort } from './ports' + +/** Turns shallower than this read as a straight continuation — butt-join + * the runs instead of minting a fitting. Matches the elbow schema's + * minimum angle so the planned fitting is always exactly buildable. */ +const MIN_TURN_RAD = (15 * Math.PI) / 180 +/** Elbows top out at 90°; anything sharper (doubling back) gets no + * fitting. Half a degree of slack absorbs float noise on right angles. */ +const MAX_TURN_RAD = (90.5 * Math.PI) / 180 + +type Point = [number, number, number] + +/** Cross-section a planned fitting (and the duct drawing it) carries. */ +export type DuctProfile = { + shape: 'round' | 'rect' | 'oval' + /** Round size in inches (ignored for rect / oval — the equivalent is derived). */ + diameter: number + /** Rect / oval profile in inches. */ + width: number + height: number +} + +/** Effective round-size (inches) a profile presents at joints. */ +export function profileDiameterIn(profile: DuctProfile): number { + if (profile.shape === 'rect') { + return Math.min(48, equivalentDiameterIn(profile.width, profile.height)) + } + if (profile.shape === 'oval') { + return Math.min(48, ovalEquivalentDiameterIn(profile.width, profile.height)) + } + return profile.diameter +} + +export type ElbowJointPlan = { + /** Parsed elbow node, its junction centered ON the drawn corner point, + * oriented so the inlet faces the existing run and the outlet faces + * the new one. */ + fitting: DuctFittingNode + /** The elbow's outlet collar — where the new duct should start (or end) + * instead of the corner point, so duct meets metal instead of + * overlapping the fitting. */ + collarPoint: Point + /** Where the EXISTING run's endpoint must move (pulled back one leg + * from the corner) so the elbow's inlet collar replaces that stretch + * of duct — keeping the visual corner exactly where it was drawn. */ + trimmedPortPoint: Point +} + +/** Orthonormal basis from a primary direction and a coplanar reference. */ +function frame(primary: Vector3, reference: Vector3): Matrix4 | null { + const x = primary.clone().normalize() + const z = new Vector3().crossVectors(x, reference) + if (z.lengthSq() < 1e-10) return null + z.normalize() + const y = new Vector3().crossVectors(z, x) + return new Matrix4().makeBasis(x, y, z) +} + +/** + * Plan the elbow that joins an existing run's open port to a new run + * leaving the joint along `awayDir`. + * + * Geometry: the elbow's local inlet faces -X and its outlet is turned + * `angle`° in the local XZ plane (see the duct-fitting schema). For a + * turn of θ between the port's outward direction and `awayDir`, an elbow + * with `angle = θ` mates both exactly; the rotation is whatever maps the + * local (inlet, outlet) direction pair onto the world (port, away) pair — + * which also covers vertical turns (horizontal run → riser), since the + * mapping is a full 3D rotation, not just yaw. + * + * Returns null when no fitting belongs at the joint: near-straight + * continuation (butt-join is fine), a back-turn sharper than 90°, or a + * degenerate direction pair. + */ +/** + * Domain-agnostic corner-joint math: where an elbow-shaped fitting (any + * kind whose local inlet faces -X with the outlet turned `angle`° in + * XZ) lands when joining `port` to a run leaving along `awayDir`, with + * legs of `legM` meters. The junction sits exactly ON the corner; the + * caller trims the existing run to `trimmedPortPoint` and starts the + * new one at `collarPoint`. + */ +export type CornerJointGeometry = { + angleDeg: number + rotation: Point + junction: Point + collarPoint: Point + trimmedPortPoint: Point +} + +export function planCornerJoint( + port: Pick, + awayDir: Point, + legM: number, +): CornerJointGeometry | null { + const portDir = new Vector3(...port.direction).normalize() + const away = new Vector3(...awayDir).normalize() + if (portDir.lengthSq() < 1e-10 || away.lengthSq() < 1e-10) return null + + const turn = portDir.angleTo(away) + if (turn < MIN_TURN_RAD || turn > MAX_TURN_RAD) return null + const angleDeg = Math.min(90, (turn * 180) / Math.PI) + + // Rotation mapping the local pair onto the world pair: local +X (the + // inlet axis, flow direction) → portDir, local outlet → awayDir. Both + // pairs subtend the same angle, so a shared-plane basis transfer is + // exact — vertical turns included. + const outletLocal = new Vector3(Math.cos(turn), 0, Math.sin(turn)) + const localFrame = frame(new Vector3(1, 0, 0), outletLocal) + const worldFrame = frame(portDir, away) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const junction = new Vector3(...port.position) + const collar = junction.clone().addScaledVector(away, legM) + const trimmed = junction.clone().addScaledVector(portDir, -legM) + + return { + angleDeg, + rotation: [euler.x, euler.y, euler.z], + junction: [junction.x, junction.y, junction.z], + collarPoint: [collar.x, collar.y, collar.z], + trimmedPortPoint: [trimmed.x, trimmed.y, trimmed.z], + } +} + +export function planElbowAtPort( + port: ScenePort, + awayDir: Point, + profile: DuctProfile, +): ElbowJointPlan | null { + const joint = planCornerJoint(port, awayDir, fittingLegLength(profileDiameterIn(profile))) + if (!joint) return null + + const system = port.system === 'return' ? 'return' : 'supply' + // Built from the schema directly (defaults fill the rest) — importing + // the fitting's definition here would drag the editor package into the + // module graph, which test runners and non-editor embedders can't load. + const fitting = DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Elbow', + fittingType: 'elbow', + shape: profile.shape, + width: profile.width, + height: profile.height, + angle: joint.angleDeg, + diameter: profileDiameterIn(profile), + diameter2: profileDiameterIn(profile), + // Corner elbows are sheet metal even on flex runs (adjustable elbows). + ductMaterial: 'sheet-metal', + system, + position: joint.junction, + rotation: joint.rotation, + }) + + return { + fitting, + collarPoint: joint.collarPoint, + trimmedPortPoint: joint.trimmedPortPoint, + } +} + +// ─── Tee taps (branch off a trunk's body) ──────────────────────────── + +export type TeeTapPlan = { + /** Parsed tee node, its junction centered ON the tap point, run legs + * along the trunk and branch collar toward the new run. */ + fitting: DuctFittingNode + /** The tee's branch collar — where the new duct should start. */ + branchCollar: Point + /** Trunk rewritten to END one run-leg before the tap point. */ + trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } } + /** New run carrying the rest of the trunk, starting one run-leg after + * the tap point. Created alongside the tee. */ + trunkTail: DuctSegmentNode +} + +/** + * Plan the tee that taps a branch off the SIDE of an existing run. + * + * The trunk is split at the tap point: the original node keeps the + * upstream half (trimmed one leg short), a new duct-segment node carries + * the downstream half (starting one leg after), and the tee's run legs + * bridge the gap with its junction exactly on the centerline hit. The + * branch collar points along `awayDir` projected perpendicular to the + * trunk axis — a tee's branch is square to its run, so a 45° drawn + * branch leaves square and the drawn duct continues from the collar. + * + * Returns null when the tap can't be built: too close to the segment's + * ends (no room for the run legs — join the end port instead), or the + * branch direction is parallel to the trunk. + */ +export function planTeeAtRunBody( + trunk: DuctSegmentNode, + hit: RunBodyHit, + awayDir: Point, + branch: DuctProfile, +): TeeTapPlan | null { + const a = trunk.path[hit.segmentIndex] + const b = trunk.path[hit.segmentIndex + 1] + if (!a || !b) return null + const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + if (axis.lengthSq() < 1e-10) return null + axis.normalize() + + // Branch leaves square to the run: project the drawn direction onto + // the plane perpendicular to the trunk axis. + const away = new Vector3(...awayDir) + const branchDir = away.clone().addScaledVector(axis, -away.dot(axis)) + if (branchDir.lengthSq() < 1e-6) return null + branchDir.normalize() + + // Room check: both run legs must fit inside the hit segment with a + // margin of real duct on each side. + // Rect trunks present their area-equivalent round size at joints + // (clamped to the fitting schema's 48" ceiling). + const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk)) + const branchDiameterIn = Math.min(48, profileDiameterIn(branch)) + const legRun = fittingLegLength(trunkDiameterIn) + const legBranch = fittingLegLength(branchDiameterIn) + const P = new Vector3(...hit.point) + const upstream = P.distanceTo(new Vector3(...a)) + const downstream = P.distanceTo(new Vector3(...b)) + const MIN_STUB = 0.08 + if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null + + // Local +X (the run) → axis, local +Z (the branch) → branchDir. Both + // pairs are perpendicular, so the basis transfer is exact. + const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1)) + const worldFrame = frame(axis, branchDir) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const inletTrim = P.clone().addScaledVector(axis, -legRun) + const outletTrim = P.clone().addScaledVector(axis, legRun) + const collar = P.clone().addScaledVector(branchDir, legBranch) + + const fitting = DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Tee', + fittingType: 'tee', + shape: trunk.shape, + width: trunk.width, + height: trunk.height, + diameter: trunkDiameterIn, + shape2: branch.shape, + width2: branch.width, + height2: branch.height, + diameter2: branchDiameterIn, + ductMaterial: 'sheet-metal', + system: trunk.system, + position: [P.x, P.y, P.z], + rotation: [euler.x, euler.y, euler.z], + }) + + // Split the polyline: original keeps the upstream points + the inlet + // trim; the tail node starts at the outlet trim and carries the rest. + const upstreamPath: Point[] = [ + ...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point), + [inletTrim.x, inletTrim.y, inletTrim.z], + ] + const tailPath: Point[] = [ + [outletTrim.x, outletTrim.y, outletTrim.z], + ...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point), + ] + + const trunkTail = DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: trunk.name ?? 'Duct run', + path: tailPath, + shape: trunk.shape, + diameter: trunk.diameter, + width: trunk.width, + height: trunk.height, + roll: trunk.roll, + ductMaterial: trunk.ductMaterial, + insulated: trunk.insulated, + insulationR: trunk.insulationR, + system: trunk.system, + }) + + return { + fitting, + branchCollar: [collar.x, collar.y, collar.z], + trunkUpdate: { id: trunk.id, data: { path: upstreamPath } }, + trunkTail, + } +} + +// ─── Cross taps (drawn run passes THROUGH a trunk's body) ──────────── + +export type CrossTapPlan = { + /** Parsed cross node, junction ON the crossing point, run legs along + * the trunk and two opposed branch legs along the drawn run. */ + fitting: DuctFittingNode + /** Branch collar on the START side of the drawn run — the first half + * of the drawn duct ENDS here. */ + branchCollarNear: Point + /** Branch collar on the END side of the drawn run — the second half + * of the drawn duct STARTS here. */ + branchCollarFar: Point + /** Trunk rewritten to END one run-leg before the crossing. */ + trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } } + /** New run carrying the rest of the trunk, starting one run-leg past + * the crossing. Created alongside the cross. */ + trunkTail: DuctSegmentNode +} + +/** + * Plan the four-way cross where a drawn run passes straight THROUGH the + * SIDE of an existing run. Like a tee tap, the trunk is split at the + * crossing (original keeps the upstream half, a new node carries the + * downstream half, both pulled one run-leg back). The drawn run is split + * by the CALLER into two halves that meet the cross's two opposed branch + * collars — `branchCollarNear` toward `awayDir`'s origin (the drawn + * start) and `branchCollarFar` along `awayDir` (the drawn end). + * + * `awayDir` is the drawn run's direction (start → end). Its component + * perpendicular to the trunk axis sets the branch axis; a drawn run that + * isn't square to the trunk still gets a square cross (the off-square + * lead-ins are absorbed by the drawn duct halves). Returns null when the + * crossing is too near a trunk end (no room for the run legs) or the + * drawn run is parallel to the trunk. + */ +export function planCrossAtRunBody( + trunk: DuctSegmentNode, + hit: RunBodyHit, + awayDir: Point, + branch: DuctProfile, +): CrossTapPlan | null { + const a = trunk.path[hit.segmentIndex] + const b = trunk.path[hit.segmentIndex + 1] + if (!a || !b) return null + const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + if (axis.lengthSq() < 1e-10) return null + axis.normalize() + + // Branch axis: the drawn direction projected square to the trunk. + const away = new Vector3(...awayDir) + const branchDir = away.clone().addScaledVector(axis, -away.dot(axis)) + if (branchDir.lengthSq() < 1e-6) return null + branchDir.normalize() + + const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk)) + const branchDiameterIn = Math.min(48, profileDiameterIn(branch)) + const legRun = fittingLegLength(trunkDiameterIn) + const legBranch = fittingLegLength(branchDiameterIn) + const P = new Vector3(...hit.point) + const upstream = P.distanceTo(new Vector3(...a)) + const downstream = P.distanceTo(new Vector3(...b)) + const MIN_STUB = 0.08 + if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null + + // Local +X (the run) → axis, local +Z (the branch +Z leg) → branchDir. + const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1)) + const worldFrame = frame(axis, branchDir) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const inletTrim = P.clone().addScaledVector(axis, -legRun) + const outletTrim = P.clone().addScaledVector(axis, legRun) + // +Z branch (`branch`) faces along branchDir = the drawn END side; + // -Z branch (`branch2`) faces the drawn START side. + const collarFar = P.clone().addScaledVector(branchDir, legBranch) + const collarNear = P.clone().addScaledVector(branchDir, -legBranch) + + const fitting = DuctFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Cross', + fittingType: 'cross', + shape: trunk.shape, + width: trunk.width, + height: trunk.height, + diameter: trunkDiameterIn, + shape2: branch.shape, + width2: branch.width, + height2: branch.height, + diameter2: branchDiameterIn, + ductMaterial: 'sheet-metal', + system: trunk.system, + position: [P.x, P.y, P.z], + rotation: [euler.x, euler.y, euler.z], + }) + + const upstreamPath: Point[] = [ + ...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point), + [inletTrim.x, inletTrim.y, inletTrim.z], + ] + const tailPath: Point[] = [ + [outletTrim.x, outletTrim.y, outletTrim.z], + ...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point), + ] + + const trunkTail = DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: trunk.name ?? 'Duct run', + path: tailPath, + shape: trunk.shape, + diameter: trunk.diameter, + width: trunk.width, + height: trunk.height, + roll: trunk.roll, + ductMaterial: trunk.ductMaterial, + insulated: trunk.insulated, + insulationR: trunk.insulationR, + system: trunk.system, + }) + + return { + fitting, + branchCollarNear: [collarNear.x, collarNear.y, collarNear.z], + branchCollarFar: [collarFar.x, collarFar.y, collarFar.z], + trunkUpdate: { id: trunk.id, data: { path: upstreamPath } }, + trunkTail, + } +} + +// ─── Elbow realignment (run drawn onto an existing fitting's collar) ── + +export type ElbowRealignPlan = { + /** Patch for the existing elbow: new turn angle + orientation. */ + update: { id: DuctFittingNode['id']; data: { angle: number; rotation: Point } } + /** Where the free collar lands — the new duct starts (or ends) here. */ + collarPoint: Point +} + +/** + * Re-aim an existing elbow whose open collar a new run just snapped + * onto. The junction stays put and the OTHER collar keeps its exact + * position + direction (it's mated to something), while the snapped + * collar swings to face the incoming run — the elbow's `angle` adjusts + * to whatever turn that requires. + * + * Geometry: with the fixed collar's outward direction f and the desired + * free direction `awayDir`, the elbow's local inlet/outlet pair subtends + * 180° − angle, so the new turn is θ = 180° − ∠(f, away). Buildable only + * while θ stays in the elbow's 15–90° range — otherwise null and the + * caller leaves the joint as a plain butt joint. + */ +export function planElbowRealign( + elbow: DuctFittingNode, + snappedPortId: string, + awayDir: Point, +): ElbowRealignPlan | null { + if (elbow.fittingType !== 'elbow') return null + if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null + + const away = new Vector3(...awayDir) + if (away.lengthSq() < 1e-10) return null + away.normalize() + + // Current world directions of both collars. + const currentRotation = new Quaternion().setFromEuler( + new Euler(elbow.rotation[0], elbow.rotation[1], elbow.rotation[2]), + ) + const turnCur = (elbow.angle * Math.PI) / 180 + const inletWorld = new Vector3(-1, 0, 0).applyQuaternion(currentRotation) + const outletWorld = new Vector3(Math.cos(turnCur), 0, Math.sin(turnCur)).applyQuaternion( + currentRotation, + ) + const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld + + // New turn from the fixed collar / free collar pair. + const spread = fixedWorld.angleTo(away) + const turnNew = Math.PI - spread + if (turnNew < MIN_TURN_RAD || turnNew > MAX_TURN_RAD) return null + + // Local outward pair at the new angle, ordered (fixed, free) to match + // the world pair. + const inletLocal = new Vector3(-1, 0, 0) + const outletLocal = new Vector3(Math.cos(turnNew), 0, Math.sin(turnNew)) + const fixedLocal = snappedPortId === 'inlet' ? outletLocal : inletLocal + const freeLocal = snappedPortId === 'inlet' ? inletLocal : outletLocal + + const localFrame = frame(fixedLocal, freeLocal) + const worldFrame = frame(fixedWorld, away) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const leg = fittingLegLength(elbow.diameter) + const collar = new Vector3(...elbow.position).addScaledVector(away, leg) + + return { + update: { + id: elbow.id, + data: { + angle: Math.min(90, (turnNew * 180) / Math.PI), + rotation: [euler.x, euler.y, euler.z], + }, + }, + collarPoint: [collar.x, collar.y, collar.z], + } +} + +// ─── DWV pipe joints ───────────────────────────────────────────────── + +export type PipeElbowPlan = { + fitting: PipeFittingNode + collarPoint: Point + trimmedPortPoint: Point +} + +/** + * Elbow (bend) joining an existing DWV run's open port to a new run — + * same corner geometry as the duct elbow, minted as a pipe fitting. + */ +export function planPipeElbowAtPort( + port: ScenePort, + awayDir: Point, + diameterIn: number, + pipeMaterial: PipeFittingNode['pipeMaterial'] = 'pvc', +): PipeElbowPlan | null { + const joint = planCornerJoint(port, awayDir, pipeFittingLegLength(diameterIn)) + if (!joint) return null + + const fitting = PipeFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Bend', + fittingType: 'elbow', + angle: joint.angleDeg, + diameter: diameterIn, + diameter2: diameterIn, + pipeMaterial, + system: port.system === 'vent' ? 'vent' : 'waste', + position: joint.junction, + rotation: joint.rotation, + }) + + return { + fitting, + collarPoint: joint.collarPoint, + trimmedPortPoint: joint.trimmedPortPoint, + } +} + +export type PipeBranchTapPlan = { + /** Parsed wye / sanitary tee, junction ON the tap point. */ + fitting: PipeFittingNode + /** The branch collar — where the new run starts. */ + branchCollar: Point + /** Tapped run rewritten to END one run-leg before the tap. */ + runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } } + /** New run carrying the rest of the tapped run. */ + runTail: PipeSegmentNode +} + +/** + * Plan the branch fitting that taps a new run into the SIDE of an + * existing DWV run — a **sanitary tee**: the branch enters SQUARE off the + * run (same T as the duct tee tap), facing the drawn branch's side. + * + * The run splits like a duct tee tap: original keeps the upstream half, + * a new node carries the downstream half, both trimmed one run-leg from + * the tap point. + */ +export function planPipeBranchTap( + run: PipeSegmentNode, + hit: RunBodyHit, + awayDir: Point, + branchDiameterIn: number, +): PipeBranchTapPlan | null { + const a = run.path[hit.segmentIndex] + const b = run.path[hit.segmentIndex + 1] + if (!a || !b) return null + const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + if (axis.lengthSq() < 1e-10) return null + axis.normalize() + + // Branch axis: the drawn direction projected square to the run, so the + // tee enters perpendicular regardless of the lead-in angle. + const away = new Vector3(...awayDir) + const branchDir = away.clone().addScaledVector(axis, -away.dot(axis)) + if (branchDir.lengthSq() < 1e-6) return null + branchDir.normalize() + + const legRun = pipeFittingLegLength(run.diameter) + const legBranch = pipeFittingLegLength(branchDiameterIn) + const P = new Vector3(...hit.point) + const upstream = P.distanceTo(new Vector3(...a)) + const downstream = P.distanceTo(new Vector3(...b)) + const MIN_STUB = 0.05 + if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null + + // Local +X (run) → axis, local +Z (branch) → branchDir. Both pairs are + // perpendicular, so the basis transfer is exact and the santee's square + // +Z branch lands on branchDir. + const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1)) + const worldFrame = frame(axis, branchDir) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const inletTrim = P.clone().addScaledVector(axis, -legRun) + const outletTrim = P.clone().addScaledVector(axis, legRun) + const collar = P.clone().addScaledVector(branchDir, legBranch) + + const fitting = PipeFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Sanitary tee', + fittingType: 'sanitary-tee', + diameter: run.diameter, + diameter2: branchDiameterIn, + pipeMaterial: run.pipeMaterial, + system: run.system, + position: [P.x, P.y, P.z], + rotation: [euler.x, euler.y, euler.z], + }) + + const upstreamPath: Point[] = [ + ...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point), + [inletTrim.x, inletTrim.y, inletTrim.z], + ] + const tailPath: Point[] = [ + [outletTrim.x, outletTrim.y, outletTrim.z], + ...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point), + ] + + const runTail = PipeSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: run.name ?? 'Drain', + path: tailPath, + diameter: run.diameter, + pipeMaterial: run.pipeMaterial, + system: run.system, + }) + + return { + fitting, + branchCollar: [collar.x, collar.y, collar.z], + runUpdate: { id: run.id, data: { path: upstreamPath } }, + runTail, + } +} + +export type PipeCrossTapPlan = { + /** Parsed cross node, junction ON the crossing point, run legs along + * the run and two opposed branch legs along the drawn run. */ + fitting: PipeFittingNode + /** Branch collar on the START side of the drawn run — the first half + * of the drawn pipe ENDS here. */ + branchCollarNear: Point + /** Branch collar on the END side of the drawn run — the second half + * of the drawn pipe STARTS here. */ + branchCollarFar: Point + /** Tapped run rewritten to END one run-leg before the crossing. */ + runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } } + /** New run carrying the rest of the tapped run. */ + runTail: PipeSegmentNode +} + +/** + * Plan the four-way DWV cross where a drawn run passes straight THROUGH + * the SIDE of an existing run — the pipe sibling of `planCrossAtRunBody`. + * The run splits at the crossing (original keeps the upstream half, a new + * node carries the downstream half, both pulled one run-leg back). The + * drawn run is split by the CALLER into two halves meeting the cross's + * opposed branch collars — `branchCollarNear` toward the drawn start, + * `branchCollarFar` along the drawn end. Returns null when the crossing + * is too near a run end or the drawn run is parallel to the run. + */ +export function planPipeCrossAtRunBody( + run: PipeSegmentNode, + hit: RunBodyHit, + awayDir: Point, + branchDiameterIn: number, +): PipeCrossTapPlan | null { + const a = run.path[hit.segmentIndex] + const b = run.path[hit.segmentIndex + 1] + if (!a || !b) return null + const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]) + if (axis.lengthSq() < 1e-10) return null + axis.normalize() + + // Branch axis: the drawn direction projected square to the run. + const away = new Vector3(...awayDir) + const branchDir = away.clone().addScaledVector(axis, -away.dot(axis)) + if (branchDir.lengthSq() < 1e-6) return null + branchDir.normalize() + + const legRun = pipeFittingLegLength(run.diameter) + const legBranch = pipeFittingLegLength(branchDiameterIn) + const P = new Vector3(...hit.point) + const upstream = P.distanceTo(new Vector3(...a)) + const downstream = P.distanceTo(new Vector3(...b)) + const MIN_STUB = 0.05 + if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null + + // Local +X (run) → axis, local +Z (the branch +Z leg) → branchDir. + const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1)) + const worldFrame = frame(axis, branchDir) + if (!localFrame || !worldFrame) return null + const rotation = new Quaternion().setFromRotationMatrix( + worldFrame.multiply(localFrame.transpose()), + ) + const euler = new Euler().setFromQuaternion(rotation) + + const inletTrim = P.clone().addScaledVector(axis, -legRun) + const outletTrim = P.clone().addScaledVector(axis, legRun) + // +Z branch faces along branchDir = the drawn END side; -Z branch2 + // faces the drawn START side. + const collarFar = P.clone().addScaledVector(branchDir, legBranch) + const collarNear = P.clone().addScaledVector(branchDir, -legBranch) + + const fitting = PipeFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Cross', + fittingType: 'cross', + diameter: run.diameter, + diameter2: branchDiameterIn, + pipeMaterial: run.pipeMaterial, + system: run.system, + position: [P.x, P.y, P.z], + rotation: [euler.x, euler.y, euler.z], + }) + + const upstreamPath: Point[] = [ + ...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point), + [inletTrim.x, inletTrim.y, inletTrim.z], + ] + const tailPath: Point[] = [ + [outletTrim.x, outletTrim.y, outletTrim.z], + ...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point), + ] + + const runTail = PipeSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: run.name ?? 'Drain', + path: tailPath, + diameter: run.diameter, + pipeMaterial: run.pipeMaterial, + system: run.system, + }) + + return { + fitting, + branchCollarNear: [collarNear.x, collarNear.y, collarNear.z], + branchCollarFar: [collarFar.x, collarFar.y, collarFar.z], + runUpdate: { id: run.id, data: { path: upstreamPath } }, + runTail, + } +} diff --git a/packages/nodes/src/shared/draw-alignment.ts b/packages/nodes/src/shared/draw-alignment.ts new file mode 100644 index 00000000..516650a3 --- /dev/null +++ b/packages/nodes/src/shared/draw-alignment.ts @@ -0,0 +1,35 @@ +'use client' + +import { alignFloorplanDraftPoint, useAlignmentGuides } from '@pascal-app/editor' + +type Vec3 = [number, number, number] + +/** + * Layer Figma-style alignment guides onto a draw-tool cursor point so HVAC / + * DWV runs and equipment line up with each other (and every other node) while + * being drawn — the same feedback walls get. + * + * Treats the point as a single corner anchor, gathers candidates from the live + * scene (every kind contributes via `nodeAlignmentAnchors`), publishes the + * guides to `useAlignmentGuides` (rendered in BOTH the 2D floor plan and the + * 3D view), and returns the point with the snap applied. Y is preserved — only + * XZ is aligned. + * + * - `applySnap: false` publishes the guide passively without pulling the point + * off a constrained ray (e.g. an angle-locked run continuation). + * - `bypass: true` clears guides and returns the point untouched (Alt, or when + * a stronger port / run-body snap already won). + */ +export function alignDrawPoint(point: Vec3, opts: { applySnap: boolean; bypass?: boolean }): Vec3 { + if (opts.bypass) { + useAlignmentGuides.getState().clear() + return point + } + const [x, z] = alignFloorplanDraftPoint([point[0], point[2]], { applySnap: opts.applySnap }) + return [x, point[1], z] +} + +/** Drop any alignment guides this tool published (cancel / commit / unmount). */ +export function clearDrawAlignment(): void { + useAlignmentGuides.getState().clear() +} diff --git a/packages/nodes/src/shared/fitting-rotation.ts b/packages/nodes/src/shared/fitting-rotation.ts new file mode 100644 index 00000000..67a328ae --- /dev/null +++ b/packages/nodes/src/shared/fitting-rotation.ts @@ -0,0 +1,50 @@ +import { type AnyNode, useScene } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { Euler, Quaternion, Vector3 } from 'three' +import type { DuctFittingNode } from '../duct-fitting/schema' + +/** R/T rotation step — 45°, matching the editor's default rotate. */ +export const ROTATE_STEP_RAD = Math.PI / 4 + +export type RotationAxis = 'x' | 'y' | 'z' + +export const AXIS_VECTORS: Record = { + x: new Vector3(1, 0, 0), + y: new Vector3(0, 1, 0), + z: new Vector3(0, 0, 1), +} + +// The active axis lives on `useEditor` (not a module store) so the +// floating action menu — which can't import this package — surfaces it +// in the pill above a selected fitting. Tool + keyboard actions share +// the same state, so Alt-cycling in either context drives both. +export const getRotationAxis = (): RotationAxis => useEditor.getState().rotationAxis +export const cycleRotationAxis = (): RotationAxis => useEditor.getState().cycleRotationAxis() + +/** + * Compose a world-frame rotation around `axis` onto an existing euler. + * World-frame (premultiply) so the axes the user cycles through always + * mean the screen-space X/Y/Z they expect, regardless of how the fitting + * is already turned. + */ +export function rotateEulerWorld( + rotation: readonly [number, number, number], + axis: RotationAxis, + steps: 1 | -1, +): [number, number, number] { + const current = new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2])) + const turn = new Quaternion().setFromAxisAngle(AXIS_VECTORS[axis], steps * ROTATE_STEP_RAD) + const euler = new Euler().setFromQuaternion(turn.multiply(current)) + return [euler.x, euler.y, euler.z] +} + +/** + * R / T keyboard action for a placed fitting — rotate ±45° around the + * shared active axis (Alt cycles it; see `selection.tsx`). + */ +export function rotateFittingNode(node: AnyNode, steps: 1 | -1): void { + const fitting = node as DuctFittingNode + useScene.getState().updateNode(fitting.id, { + rotation: rotateEulerWorld(fitting.rotation, getRotationAxis(), steps), + }) +} diff --git a/packages/nodes/src/shared/ghost-alignment.ts b/packages/nodes/src/shared/ghost-alignment.ts new file mode 100644 index 00000000..db1d549f --- /dev/null +++ b/packages/nodes/src/shared/ghost-alignment.ts @@ -0,0 +1,49 @@ +import { + type AlignmentAnchor, + type AnyNode, + bboxCornerAnchors, + collectAlignmentAnchors, + resolveAlignment, +} from '@pascal-app/core' + +/** XZ axis-aligned bounds (level-local meters). */ +export type Aabb2D = { minX: number; minZ: number; maxX: number; maxZ: number } + +/** + * Figma-style alignment-snap threshold (meters), matching the generic + * `MoveRegistryNodeTool` and the 2D overlay — 8 cm gives a magnetic pull + * without fighting grid snap. + */ +export const GHOST_ALIGNMENT_THRESHOLD_M = 0.08 + +/** + * Alignment anchors of every OTHER node on the level — gathered once at + * drag-start (the scene graph is stable during an imperative ghost drag). + */ +export function collectGhostAlignmentCandidates( + nodes: Readonly>, + excludeId: string, + levelId: string | null | undefined, +): AlignmentAnchor[] { + return collectAlignmentAnchors(nodes, excludeId, levelId) +} + +/** + * Resolve the alignment snap for a moving ghost whose footprint is the + * axis-aligned box `aabb`. Returns the XZ delta that snaps the box's edges + * onto a candidate plus the guide lines to publish (relative to the box's + * corners — "placement guideline shown relative to the bounding box"). + */ +export function resolveGhostAlignment( + nodeId: string, + aabb: Aabb2D, + candidates: AlignmentAnchor[], +): { dx: number; dz: number; guides: ReturnType['guides'] } { + const moving = bboxCornerAnchors(nodeId, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) + const result = resolveAlignment({ + moving, + candidates, + threshold: GHOST_ALIGNMENT_THRESHOLD_M, + }) + return { dx: result.snap?.dx ?? 0, dz: result.snap?.dz ?? 0, guides: result.guides } +} diff --git a/packages/nodes/src/shared/ghost-materials.ts b/packages/nodes/src/shared/ghost-materials.ts new file mode 100644 index 00000000..d2dc4c30 --- /dev/null +++ b/packages/nodes/src/shared/ghost-materials.ts @@ -0,0 +1,89 @@ +'use client' + +import type { Material, Mesh, Object3D, Raycaster } from 'three' + +export const INVALID_GHOST_COLOR = 0xef_44_44 +export const VALID_GHOST_COLOR = 0x22_c5_5e + +const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {} + +/** + * Apply ghost material treatment to a preview mesh tree. + * + * Traverses the object tree, disables raycasting on all descendants (prevents + * cursor-ray starvation), and clones visible mesh materials to set translucency. + * + * Tint by state: + * - `invalid` (red, opacity ~0.4): off-host or colliding — can't place here. + * - `valid` (green, opacity ~0.45): on a host and placeable — the "go" cue. + * - neither (opacity ~0.5, original color): a plain translucent preview. + * `invalid` wins if both are passed. + * + * Skips: meshes whose material.visible === false (door/window root hitbox) and + * children named 'cutout'. + * + * Returns cleanup that disposes only the cloned materials (never originals or geometry). + * + * @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh) + * @param opts - { invalid?, valid? } placement-state tint + * @returns Cleanup function that disposes the cloned materials + */ +export function applyGhost( + root: Object3D, + opts?: { invalid?: boolean; valid?: boolean }, +): () => void { + const invalid = opts?.invalid ?? false + const valid = !invalid && (opts?.valid ?? false) + const cloned: Material[] = [] + + root.traverse((obj) => { + // Disable raycast on every descendant to prevent cursor-ray starvation. + obj.raycast = NO_RAYCAST + + const mesh = obj as Mesh + if (!mesh.isMesh) return + if (mesh.name === 'cutout') return + + const original = mesh.material + const wasArray = Array.isArray(original) + + const cloneOne = (mat: Material): Material | null => { + // Skip invisible materials (door/window root hitbox). + if ((mat as { visible?: boolean }).visible === false) return null + const clone = mat.clone() + clone.transparent = true + clone.depthWrite = false + if (invalid) { + ;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR) + ;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex( + INVALID_GHOST_COLOR, + ) + clone.opacity = 0.4 + } else if (valid) { + ;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(VALID_GHOST_COLOR) + ;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex( + VALID_GHOST_COLOR, + ) + clone.opacity = 0.45 + } else { + clone.opacity = 0.5 + } + cloned.push(clone) + return clone + } + + if (wasArray) { + const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null) + if (clonedMats.length > 0) mesh.material = clonedMats + } else { + const clone = cloneOne(original) + if (clone) mesh.material = clone + } + }) + + return () => { + for (const mat of cloned) { + mat.dispose() + } + } +} diff --git a/packages/nodes/src/shared/level-offset-group.tsx b/packages/nodes/src/shared/level-offset-group.tsx new file mode 100644 index 00000000..3228e6b6 --- /dev/null +++ b/packages/nodes/src/shared/level-offset-group.tsx @@ -0,0 +1,34 @@ +'use client' + +import { type AnyNodeId, sceneRegistry } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { type ReactNode, useRef } from 'react' +import type { Group } from 'three' + +/** + * Wraps a placement tool's preview/ghost so it rides the active level's + * stacked elevation. + * + * Placement tools are mounted inside the building-local group (see the editor's + * ToolManager), which carries no per-floor elevation. But their points, ports, + * and committed paths are level-local (Y=0 = the floor) and the committed nodes + * parent to the level mesh, which DOES carry the stacked Y offset. Without this + * the ghost renders at world ground on upper floors while the cursor raycast + * rides the floor plane — they drift apart. Tracking the level mesh's Y here + * (the same value the grid plane follows) keeps the preview on the floor being + * drawn, with no change to any tool's level-local math. + */ +export function LevelOffsetGroup({ children }: { children: ReactNode }) { + const activeLevelId = useViewer((s) => s.selection.levelId) + const ref = useRef(null) + + useFrame(() => { + const group = ref.current + if (!group) return + const levelMesh = activeLevelId ? sceneRegistry.nodes.get(activeLevelId as AnyNodeId) : null + group.position.y = levelMesh ? levelMesh.position.y : 0 + }) + + return {children} +} diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts new file mode 100644 index 00000000..1729e923 --- /dev/null +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -0,0 +1,259 @@ +// Runtime glue between the pure `computeOpeningGuides` geometry (core) and the +// editor's 3D guide store, used by the door/window move + placement tools. Lives +// in `nodes` (not core) because it talks to the editor store; kept thin so each +// tool's per-tick hook is a single call. + +import { + type AnyNode, + type AnyNodeId, + computeOpeningGuides, + detectVerticalAlignment, + type OpeningSpan, + sceneRegistry, + spatialGridManager, + useScene, + type WallNode, +} from '@pascal-app/core' +import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor' + +// Parity with `snapLocalXToNeighbors`' along-wall threshold. +const SILL_SNAP_THRESHOLD_M = 0.08 +// Hide a dimension that has collapsed to nothing (sill flush to the floor, or +// head flush to the wall top) so it doesn't render a zero-length "0m" pill. +const MIN_DIMENSION_M = 0.02 + +/** Maps a wall-local point (s along the wall, y above the wall base) to the move + * tool's render frame — the caller passes its own `wallLocalToWorld` closure so + * the guides land in exactly the same (building-local) frame as the drag cursor. */ +type ToWorld = (s: number, y: number) => [number, number, number] + +/** The moving opening's same-wall neighbours, as wall-local spans. */ +export function collectOpeningSiblings( + wall: WallNode, + movingId: string, + nodes: Record, +): OpeningSpan[] { + const out: OpeningSpan[] = [] + const childIds = Array.isArray(wall.children) ? wall.children : [] + for (const childId of childIds) { + if (childId === movingId) continue + const node = nodes[childId as AnyNodeId] + if (!node || (node.type !== 'door' && node.type !== 'window')) continue + out.push({ + id: node.id, + centerS: node.position[0], + width: node.width, + centerY: node.position[1], + height: node.height, + }) + } + return out +} + +/** + * Vertical sill/centre/top snap for a window — the chosen "snap + guide" + * behaviour. Returns the snapped wall-local Y when a sibling sill/centre/top is + * within threshold, else null so the caller falls back to the grid. Mirrors + * `snapLocalXToNeighbors` on the vertical axis. + */ +export function resolveSillSnap(args: { + wall: WallNode + movingId: string + localX: number + localY: number + width: number + height: number + nodes: Record +}): number | null { + const siblings = collectOpeningSiblings(args.wall, args.movingId, args.nodes) + const match = detectVerticalAlignment( + { + id: args.movingId, + centerS: args.localX, + width: args.width, + centerY: args.localY, + height: args.height, + }, + siblings, + SILL_SNAP_THRESHOLD_M, + ) + return match ? args.localY + match.snap : null +} + +/** Compute and publish the 3D opening guides for the current drag tick. */ +export function publishOpeningGuides3D(args: { + wall: WallNode + movingId: string + centerS: number + centerY: number + width: number + height: number + includeVertical: boolean + toWorld: ToWorld + nodes: Record +}): void { + const { wall, centerS, centerY, width, toWorld } = args + const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1]) + const wallHeight = wall.height ?? 2.5 + const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes) + const guides = computeOpeningGuides({ + moving: { id: args.movingId, centerS, width, centerY, height: args.height }, + siblings, + wall: { length: wallLength, height: wallHeight }, + includeVertical: args.includeVertical, + }) + + const out: OpeningGuide3D[] = [] + + // Stable `id`s keyed on the guide's semantic role (not list position) so the + // 3D layer can keep a persisting slot's element + `` pill mounted as the + // set churns each tick — see `OpeningGuide3D`. + if (guides.sillHead) { + if (guides.sillHead.sill > MIN_DIMENSION_M) { + out.push({ + kind: 'dimension', + id: 'sill', + from: toWorld(centerS, 0), + to: toWorld(centerS, guides.sillHead.bottomY), + value: guides.sillHead.sill, + }) + } + if (guides.sillHead.head > MIN_DIMENSION_M) { + out.push({ + kind: 'dimension', + id: 'head', + from: toWorld(centerS, guides.sillHead.topY), + to: toWorld(centerS, wallHeight), + value: guides.sillHead.head, + }) + } + } + + for (const gap of guides.gaps) { + out.push({ + kind: 'dimension', + id: `gap:${gap.side}`, + from: toWorld(gap.fromS, centerY), + to: toWorld(gap.toS, centerY), + value: gap.distance, + }) + } + + if (guides.vertical) { + const target = siblings.find((s) => s.id === guides.vertical?.targetId) + if (target) { + const lo = Math.min(centerS - width / 2, target.centerS - target.width / 2) + const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2) + out.push({ + kind: 'align-line', + id: 'vertical', + from: toWorld(lo, guides.vertical.y), + to: toWorld(hi, guides.vertical.y), + }) + } + } + + if (guides.equalSpacing) { + const { gap, segments } = guides.equalSpacing + segments.forEach((seg, i) => { + out.push({ + kind: 'badge', + id: `spacing:${i}`, + at: toWorld((seg.fromS + seg.toS) / 2, centerY), + value: gap, + }) + }) + } + + useOpeningGuides.getState().set(out) +} + +export function clearOpeningGuides3D(): void { + useOpeningGuides.getState().clear() +} + +/** Wall-local (s along the wall, y above the wall base) → the move tool's render + * frame, given the level Y offset + slab elevation. Shared by the wall-event + * publisher (which already has them) and the resize publisher (which derives + * them from the scene). Same frame as `wallLocalToWorld`. */ +function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld { + const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0]) + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return (s, y) => [ + wall.start[0] + s * cos, + slabElevation + y + levelYOffset, + wall.start[1] + s * sin, + ] +} + +/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the + * scene, for callers without a wall event — i.e. the resize handles. */ +export function wallToWorld(wall: WallNode): ToWorld { + const levelId = wall.parentId as AnyNodeId | undefined + const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0 + const slabElevation = spatialGridManager.getSlabElevationForWall( + wall.parentId ?? '', + wall.start, + wall.end, + ) + return makeWallToWorld(wall, levelYOffset, slabElevation) +} + +/** + * Publish 3D opening guides for an opening being placed or moved on a wall via a + * wall event. The caller passes the level Y + slab elevation it already computed + * for the drag cursor, so the guides share the cursor's frame exactly — the one + * place the door/window move + placement tools publish from. + */ +export function publishOpeningGuidesForWallEvent(args: { + wall: WallNode + movingId: string + centerS: number + centerY: number + width: number + height: number + includeVertical: boolean + levelYOffset: number + slabElevation: number +}): void { + const { wall, levelYOffset, slabElevation, ...rest } = args + publishOpeningGuides3D({ + ...rest, + wall, + nodes: useScene.getState().nodes, + toWorld: makeWallToWorld(wall, levelYOffset, slabElevation), + }) +} + +/** + * Publish 3D opening guides for an opening being RESIZED via a handle arrow. + * Resolves the host wall + transform from the scene (no wall event), then reuses + * the shared publish. Doors pass `includeVertical: false` (they sit on the + * floor); windows pass `true` so a height drag also shows the live sill/head. + */ +export function publishOpeningResizeGuides( + node: { + id: string + parentId?: string | null + position: readonly [number, number, number] + width: number + height: number + }, + includeVertical: boolean, +): void { + const nodes = useScene.getState().nodes + const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (wall?.type !== 'wall') return + publishOpeningGuides3D({ + wall, + movingId: node.id, + centerS: node.position[0], + centerY: node.position[1], + width: node.width, + height: node.height, + includeVertical, + nodes, + toWorld: wallToWorld(wall), + }) +} diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 04960755..42d375fb 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -1,10 +1,13 @@ import { type AnyNode, type AnyNodeId, + computeOpeningGuides, type DoorNode, type FloorplanGeometry, + type FloorplanPoint, type GeometryContext, isCurvedWall, + type OpeningSpan, type WallNode, type WindowNode, } from '@pascal-app/core' @@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions( // walls) via ctx.resolve to compute the centroid. const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ) - const halfWidth = opening.width / 2 - const startDist = opening.position[0] - halfWidth - const endDist = opening.position[0] + halfWidth + const wallThickness = wall.thickness ?? 0.1 + const halfThickness = wallThickness / 2 + const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32 - // Walk wall.children to find adjacent openings (door OR window). - // ctx.siblings only includes same-kind nodes; doors + windows need - // each other so we go via the parent's children directly. + // Outer-face projection for the placement dimensions (so extension lines stay + // short and the layout matches the legacy treatment); centreline projection + // for the equal-spacing badges, which sit on the solid wall between openings. + const facePoint = (along: number): readonly [number, number] => [ + x1 + dirX * along + outwardNormal[0] * halfThickness, + z1 + dirZ * along + outwardNormal[1] * halfThickness, + ] + const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along] + const round = (value: number) => Number.parseFloat(value.toFixed(2)) + + // This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes + // same-kind nodes; doors and windows need each other, so resolve the wall's + // children directly. const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[] - let leftBoundary: number | null = null - let rightBoundary: number | null = null + const siblings: OpeningSpan[] = [] for (const childId of childIds) { if (childId === opening.id) continue const sibling = ctx.resolve(childId) as AnyNode | undefined if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue const sib = sibling as DoorNode | WindowNode - const sibStart = sib.position[0] - sib.width / 2 - const sibEnd = sib.position[0] + sib.width / 2 - if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) { - leftBoundary = sibEnd - } - if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) { - rightBoundary = sibStart - } + siblings.push({ + id: sib.id, + centerS: sib.position[0], + width: sib.width, + centerY: sib.position[1], + height: sib.height, + }) } - const leftFromDist = leftBoundary ?? 0 - const rightToDist = rightBoundary ?? wallLength - - // Place the dimension line at a constant offset from the wall's - // outer face — same value the legacy uses for its placement - // measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The - // dimension's `start` / `end` are points on that outer face (not - // the wall centerline), so the extension lines stay short and the - // overall layout matches the legacy treatment 1:1. - const wallThickness = wall.thickness ?? 0.1 - const halfThickness = wallThickness / 2 - const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32 - - // Project a point on the wall axis at distance `along` onto the - // wall's outer face by adding `halfThickness * outwardNormal`. - const facePoint = (along: number): readonly [number, number] => [ - x1 + dirX * along + outwardNormal[0] * halfThickness, - z1 + dirZ * along + outwardNormal[1] * halfThickness, - ] + const guides = computeOpeningGuides({ + moving: { + id: opening.id, + centerS: opening.position[0], + width: opening.width, + centerY: opening.position[1], + height: opening.height, + }, + siblings, + wall: { length: wallLength, height: wall.height ?? 2.5 }, + // The 2D plan is top-down: sill/head height and vertical alignment aren't + // representable here — those belong to the 3D viewport. + includeVertical: false, + }) const out: FloorplanGeometry[] = [] - const leftDistance = startDist - leftFromDist - if (leftDistance >= 0.01) { + // Edge-to-edge clearance to the nearest neighbour (or wall end) on each side. + for (const gap of guides.gaps) { + const lo = Math.min(gap.fromS, gap.toS) + const hi = Math.max(gap.fromS, gap.toS) out.push({ kind: 'dimension', - start: facePoint(leftFromDist), - end: facePoint(startDist), + start: facePoint(lo), + end: facePoint(hi), offsetNormal: outwardNormal, offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, extensionOvershoot: 0.12, - text: `${Number.parseFloat(leftDistance.toFixed(2))}m`, + text: `${round(gap.distance)}m`, stroke: '#f97316', }) } - const rightDistance = rightToDist - endDist - if (rightDistance >= 0.01) { - out.push({ - kind: 'dimension', - start: facePoint(endDist), - end: facePoint(rightToDist), - offsetNormal: outwardNormal, - offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET, - extensionOvershoot: 0.12, - text: `${Number.parseFloat(rightDistance.toFixed(2))}m`, - stroke: '#f97316', - }) + // Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline. + if (guides.equalSpacing) { + const wallAngle = Math.atan2(dz, dx) + const text = `${round(guides.equalSpacing.gap)}m` + for (const seg of guides.equalSpacing.segments) { + out.push({ + kind: 'equal-spacing-badge', + point: centrePoint((seg.fromS + seg.toS) / 2), + text, + angle: wallAngle, + }) + } } return out diff --git a/packages/nodes/src/shared/path-offset.ts b/packages/nodes/src/shared/path-offset.ts new file mode 100644 index 00000000..1c8946f4 --- /dev/null +++ b/packages/nodes/src/shared/path-offset.ts @@ -0,0 +1,76 @@ +import { Vector3 } from 'three' + +type Point = [number, number, number] + +const UP = new Vector3(0, 1, 0) +const FALLBACK_PERP = new Vector3(1, 0, 0) + +/** Cap on the miter-length multiplier so a sharp turn doesn't shoot the + * corner off to infinity — past this we'd want a bevel, but MEP runs bend + * gently enough that clamping is invisible. */ +const MITER_LIMIT = 4 + +/** + * Horizontal side vector for each path segment — the axis a parallel line is + * pushed apart along, kept HORIZONTAL so the offset never tilts. A vertical + * (riser) segment has no horizontal heading of its own, so it inherits the + * side vector from the nearest segment that does; this keeps the offset line + * beside the source as the run climbs instead of rotating about the bend. + * Falls back to the X axis only if the whole path is vertical. + */ +function segmentSides(points: Vector3[]): Vector3[] { + const sides: (Vector3 | null)[] = [] + for (let i = 0; i < points.length - 1; i++) { + const dir = new Vector3().subVectors(points[i + 1]!, points[i]!) + const horizontal = new Vector3(dir.x, 0, dir.z) + sides.push(horizontal.lengthSq() < 1e-9 ? null : horizontal.normalize().cross(UP).normalize()) + } + // Forward then backward fill so vertical segments adopt a real heading. + for (let i = 1; i < sides.length; i++) if (!sides[i]) sides[i] = sides[i - 1] ?? null + for (let i = sides.length - 2; i >= 0; i--) if (!sides[i]) sides[i] = sides[i + 1] ?? null + return sides.map((s) => s ?? FALLBACK_PERP.clone()) +} + +/** + * Per-vertex offset vectors for shifting a path sideways into a parallel line. + * At an interior vertex the offset follows the angle bisector of the two + * adjacent segment side vectors, scaled by `1/cos(half-angle)` so the offset + * segments on either side of the bend meet exactly at one miter point (a plain + * per-segment side leaves them crossing/gapping). Endpoints use their single + * segment's side. Side vectors are horizontal, so the offset is too — a + * horizontal→vertical bend keeps the same side (cos 1, no expansion), leaving + * the parallel line side by side up the riser. + */ +function miterOffsets(points: Vector3[], offset: number): Vector3[] { + const sides = segmentSides(points) + return points.map((_p, i) => { + const sIn = i > 0 ? sides[i - 1]! : null + const sOut = i < sides.length ? sides[i]! : null + if (sIn && sOut) { + const bisector = sIn.clone().add(sOut) + // s_in == -s_out → a 180° switchback; the bisector vanishes, so just + // run straight out on one side. + if (bisector.lengthSq() < 1e-9) return sIn.clone().multiplyScalar(offset) + bisector.normalize() + const cos = bisector.dot(sIn) + const scale = Math.min(MITER_LIMIT, 1 / Math.max(cos, 1 / MITER_LIMIT)) + return bisector.multiplyScalar(offset * scale) + } + return (sIn ?? sOut)!.clone().multiplyScalar(offset) + }) +} + +/** + * Offset a polyline horizontally by `offset` meters to one side, mitered at + * bends so the parallel line meets cleanly. Positive `offset` shifts along the + * `+UP × heading` side of each segment; negative flips to the other side. Used + * to lay a thin line beside an existing run (the liquid-line follow-trace). + */ +export function offsetPathHorizontal(path: readonly Point[], offset: number): Point[] { + const points = path.map(([x, y, z]) => new Vector3(x, y, z)) + const offsets = miterOffsets(points, offset) + return points.map((p, i) => { + const o = p.clone().add(offsets[i]!) + return [o.x, o.y, o.z] as Point + }) +} diff --git a/packages/nodes/src/shared/path-point-affordance.ts b/packages/nodes/src/shared/path-point-affordance.ts new file mode 100644 index 00000000..74dbc5b6 --- /dev/null +++ b/packages/nodes/src/shared/path-point-affordance.ts @@ -0,0 +1,68 @@ +import { + type AnyNodeId, + type FloorplanAffordance, + type FloorplanAffordanceSession, + useScene, +} from '@pascal-app/core' +import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor' + +/** + * Shared "drag a path point" floor-plan affordance for polyline + * distribution kinds (duct-segment / pipe-segment / lineset). It is the + * 2D counterpart of their 3D `affordanceTools.selection` handles: one + * draggable handle per path vertex, moved freely on the plan (XZ) with + * grid snap (Shift bypasses). The vertex's Y (elevation / slope) is held + * fixed — plan editing never changes height. + * + * Wired via `def.floorplanAffordances['move-path-point']`; the floor-plan + * builders emit `endpoint-handle` primitives carrying `{ pointIndex }` so + * the dispatcher routes pointer-downs here. + */ +export type PathPointPayload = { pointIndex: number } + +type PathShape = { path: ReadonlyArray } + +export function createPathPointMoveAffordance( + kind: string, +): FloorplanAffordance { + const inert: FloorplanAffordanceSession = { + affectedIds: [], + apply() {}, + canCommit() { + return false + }, + } + return { + start({ node, payload }): FloorplanAffordanceSession { + const { pointIndex } = payload as PathPointPayload + const initialPath = node.path.map((p) => [...p] as [number, number, number]) + const target = initialPath[pointIndex] + if (!target) return { ...inert, affectedIds: [node.id] } + // Hold the dragged vertex's elevation — the plan move only shifts XZ. + const y = target[1] + + return { + affectedIds: [node.id], + apply({ planPoint, modifiers }) { + // Plan coords map x→world X, y→world Z. + const raw: WallPlanPoint = [planPoint[0], planPoint[1]] + const [sx, sz] = modifiers.shiftKey ? raw : snapPointToGrid(raw) + const nextPath = initialPath.map((p, i) => + i === pointIndex ? ([sx, y, sz] as [number, number, number]) : p, + ) + useScene + .getState() + .updateNodes([{ id: node.id, data: { path: nextPath } as Partial as never }]) + }, + canCommit() { + const final = useScene.getState().nodes[node.id] as N | undefined + return ( + !!final && + (final as unknown as { type: string }).type === kind && + final.path.length >= 2 + ) + }, + } + }, + } +} diff --git a/packages/nodes/src/shared/pipe-auto-fitting.test.ts b/packages/nodes/src/shared/pipe-auto-fitting.test.ts new file mode 100644 index 00000000..17cab0f1 --- /dev/null +++ b/packages/nodes/src/shared/pipe-auto-fitting.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test' +import { PipeSegmentNode } from '@pascal-app/core' +import { getPipeFittingPorts } from '../pipe-fitting/ports' +import { planPipeBranchTap, planPipeElbowAtPort } from './auto-fitting' +import type { RunBodyHit, ScenePort } from './ports' + +type Point = [number, number, number] + +function port(position: Point, direction: Point): ScenePort { + return { + id: 'end', + nodeId: 'pipe-segment_test' as ScenePort['nodeId'], + position, + direction, + diameter: 2, + system: 'waste', + } +} + +function drain(path: Point[], system: 'waste' | 'vent' = 'waste'): PipeSegmentNode { + return PipeSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + name: 'Drain', + path, + diameter: 3, + pipeMaterial: 'pvc', + system, + }) +} + +function dist(a: readonly number[], b: readonly number[]): number { + return Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!) +} + +function dot(a: readonly number[], b: readonly number[]): number { + return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]! +} + +describe('planPipeElbowAtPort', () => { + test('90° bend mates both collars through the fitting port math', () => { + const plan = planPipeElbowAtPort(port([3, -0.05, 0], [1, 0, 0]), [0, 0, 1], 2) + expect(plan).not.toBeNull() + const ports = getPipeFittingPorts(plan!.fitting) + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + expect(dist(plan!.fitting.position, [3, -0.05, 0])).toBeLessThan(1e-6) + expect(dist(inlet.position, plan!.trimmedPortPoint)).toBeLessThan(1e-6) + expect(dot(inlet.direction, [1, 0, 0])).toBeCloseTo(-1, 6) + expect(dist(outlet.position, plan!.collarPoint)).toBeLessThan(1e-6) + expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6) + expect(plan!.fitting.system).toBe('waste') + }) + + test('straight continuation → no fitting', () => { + expect(planPipeElbowAtPort(port([3, 0, 0], [1, 0, 0]), [1, 0, 0], 2)).toBeNull() + }) +}) + +describe('planPipeBranchTap', () => { + function hit(node: PipeSegmentNode, segmentIndex: number, point: Point): RunBodyHit { + return { nodeId: node.id, segmentIndex, point } + } + + test('horizontal drain tap → square sanitary tee', () => { + const run = drain([ + [0, 0, 0], + [6, -0.125, 0], + ]) + const plan = planPipeBranchTap(run, hit(run, 0, [3, -0.0625, 0]), [0, 0, 1], 2) + expect(plan).not.toBeNull() + // DWV side taps mint a SQUARE sanitary tee (see planPipeBranchTap / + // PipeFittingNode schema): the branch enters perpendicular to the run + // regardless of the drawn lead-in angle, matching the duct tee tap. + expect(plan!.fitting.fittingType).toBe('sanitary-tee') + + const ports = getPipeFittingPorts(plan!.fitting) + const branch = ports.find((p) => p.id === 'branch')! + const inlet = ports.find((p) => p.id === 'inlet')! + const outlet = ports.find((p) => p.id === 'outlet')! + // Branch leaves square to the run axis (the projected-perpendicular entry). + const axis = [6 / Math.hypot(6, 0.125), -0.125 / Math.hypot(6, 0.125), 0] + expect(Math.abs(dot(branch.direction, axis))).toBeLessThan(1e-6) + expect(branch.direction[2]).toBeGreaterThan(0.6) + // Split halves mate the run collars. + const upstream = plan!.runUpdate.data.path + expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6) + expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6) + // Branch starts at the collar. + expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6) + }) + + test('vertical stack tap → sanitary tee, branch square', () => { + const stack = drain([ + [0, 0, 0], + [0, 3, 0], + ]) + const plan = planPipeBranchTap(stack, hit(stack, 0, [0, 1.5, 0]), [1, 0, 0], 2) + expect(plan).not.toBeNull() + expect(plan!.fitting.fittingType).toBe('sanitary-tee') + const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')! + expect(dot(branch.direction, [1, 0, 0])).toBeCloseTo(1, 6) + expect(Math.abs(branch.direction[1])).toBeLessThan(1e-6) + }) + + test('tap too close to a run end → null', () => { + const run = drain([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planPipeBranchTap(run, hit(run, 0, [0.05, 0, 0]), [0, 0, 1], 2)).toBeNull() + }) + + test('branch parallel to the run → null', () => { + const run = drain([ + [0, 0, 0], + [6, 0, 0], + ]) + expect(planPipeBranchTap(run, hit(run, 0, [3, 0, 0]), [1, 0, 0], 2)).toBeNull() + }) +}) diff --git a/packages/nodes/src/shared/port-connectivity-pipe.test.ts b/packages/nodes/src/shared/port-connectivity-pipe.test.ts new file mode 100644 index 00000000..5590832e --- /dev/null +++ b/packages/nodes/src/shared/port-connectivity-pipe.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + analyzePortConnectivity, + DuctSegmentNode, + loadPlugin, + nodeRegistry, + PipeFittingNode, + PipeSegmentNode, + PipeTrapNode, + resolveConnectivityUpdates, +} from '@pascal-app/core' +import { builtinPlugin } from '../index' + +type Port = { id: string; position: [number, number, number] } + +function portsOf(kind: string, node: AnyNode): ReadonlyArray { + return nodeRegistry.get(kind)!.ports!(node) as ReadonlyArray +} + +function wasteTee(): PipeFittingNode { + return PipeFittingNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + fittingType: 'sanitary-tee', + diameter: 2, + diameter2: 2, + pipeMaterial: 'pvc', + system: 'waste', + position: [0, 0, 0], + rotation: [0, 0, 0], + }) +} + +function pipeRunFrom(point: [number, number, number], system: 'waste' | 'vent' = 'waste') { + return PipeSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + diameter: 2, + pipeMaterial: 'pvc', + system, + path: [point, [point[0] + 3, point[1], point[2]]], + }) +} + +/** + * Regression coverage for the generalized (HVAC duct + DWV pipe) + * port-connectivity service. Before PR #402's follow-up fix the service + * only tracked `duct-segment` / `duct-fitting`, so moving a `pipe-fitting` + * left attached `pipe-segment` endpoints behind. These tests assert the + * role-based generalization carries pipe runs along without fusing unrelated + * systems or anchored trap fixtures. + */ +describe('port connectivity — DWV pipe family', () => { + beforeEach(async () => { + nodeRegistry._reset() + await loadPlugin(builtinPlugin) + }) + + afterEach(() => { + nodeRegistry._reset() + }) + + test('moving a pipe-fitting stretches the connected pipe-segment endpoint', () => { + // A sanitary tee at the origin; its run ports sit on ±X at the hub legs. + const fitting = wasteTee() + const outlet = portsOf('pipe-fitting', fitting as AnyNode).find((p) => p.id === 'outlet')! + + // A pipe run whose START port coincides with the fitting's outlet collar. + const run = pipeRunFrom(outlet.position) + + const nodes: Record = { + [fitting.id]: fitting as AnyNode, + [run.id]: run as AnyNode, + } + + const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes) + // The run must be picked up as a stretchable endpoint partner. + const endpoint = connectivity.connections.find( + (c) => c.kind === 'duct-endpoint' && c.nodeId === run.id, + ) + expect(endpoint).toBeDefined() + + // Move the fitting +1m in Z; the run's mated endpoint should follow. + const moved = { ...(fitting as Record), position: [0, 0, 1] } as AnyNode + const updates = resolveConnectivityUpdates(connectivity, moved) + const runUpdate = updates.find((u) => u.id === run.id) + expect(runUpdate).toBeDefined() + const newPath = (runUpdate!.data as { path: [number, number, number][] }).path + // Tracked endpoint moved by the same +1m in Z; far end stayed put. + expect(newPath[0]![2]).toBeCloseTo(outlet.position[2] + 1, 6) + expect(newPath[1]![2]).toBeCloseTo(outlet.position[2], 6) + }) + + test('incompatible systems do not fuse (a supply duct is not dragged by a waste fitting)', () => { + const fitting = wasteTee() + const outlet = portsOf('pipe-fitting', fitting as AnyNode).find((p) => p.id === 'outlet')! + + // A supply duct sharing the same point but a different distribution system. + const duct = DuctSegmentNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + diameter: 6, + ductMaterial: 'flex', + system: 'supply', + path: [outlet.position, [outlet.position[0] + 3, outlet.position[1], outlet.position[2]]], + }) + + const nodes: Record = { + [fitting.id]: fitting as AnyNode, + [duct.id]: duct as AnyNode, + } + const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes) + expect(connectivity.connections.find((c) => c.nodeId === duct.id)).toBeUndefined() + }) + + test('pipe-trap is anchored when a connected pipe endpoint moves', () => { + const trap = PipeTrapNode.parse({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + diameter: 1.5, + pipeMaterial: 'pvc', + armLengthM: 0, + }) + const outlet = portsOf('pipe-trap', trap as AnyNode).find((p) => p.id === 'outlet')! + const run = pipeRunFrom(outlet.position) + + const nodes: Record = { + [trap.id]: trap as AnyNode, + [run.id]: run as AnyNode, + } + + // Moving the run endpoint must not translate the fixed-position trap. + const runConnectivity = analyzePortConnectivity(run as AnyNode, nodes) + expect(runConnectivity.connections.find((c) => c.nodeId === trap.id)).toBeUndefined() + + // Moving the trap itself still stretches the connected run endpoint. + const trapConnectivity = analyzePortConnectivity(trap as AnyNode, nodes) + expect(trapConnectivity.connections.find((c) => c.nodeId === run.id)).toBeDefined() + }) +}) diff --git a/packages/nodes/src/shared/ports.ts b/packages/nodes/src/shared/ports.ts new file mode 100644 index 00000000..4200c028 --- /dev/null +++ b/packages/nodes/src/shared/ports.ts @@ -0,0 +1,200 @@ +import { type AnyNodeId, type NodePort, nodeRegistry, useScene } from '@pascal-app/core' + +/** A port plus the scene node that owns it. */ +export type ScenePort = NodePort & { nodeId: AnyNodeId } + +/** Air-loop port systems — what duct runs and fittings snap to. */ +export const DUCT_PORT_SYSTEMS = ['supply', 'return'] as const +/** DWV port systems — what drain / waste / vent pipe runs snap to. */ +export const DWV_PORT_SYSTEMS = ['waste', 'vent'] as const +/** Refrigerant-loop port system — what linesets snap to. */ +export const REFRIGERANT_PORT_SYSTEMS = ['refrigerant'] as const + +/** + * Filter narrowing which ports a tool will snap to. + * - `excludeNodeId` skips the node currently being drawn/placed so a + * tool doesn't snap to its own preview. + * - `systems` keeps only ports on the listed distribution loops — duct + * tools pass the air loops so they ignore refrigerant service ports; + * the lineset tool passes `'refrigerant'` so it ignores duct collars. + * A port with no `system` matches any filter. + */ +export type PortFilter = { + excludeNodeId?: AnyNodeId + systems?: readonly string[] +} + +/** + * Gather every typed port in the scene by asking each node's registered + * `def.ports`. Positions are level-local meters (the kind applies its own + * transform inside `def.ports`). + */ +export function collectScenePorts(filter: PortFilter = {}): ScenePort[] { + const { excludeNodeId, systems } = filter + const { nodes } = useScene.getState() + const result: ScenePort[] = [] + for (const node of Object.values(nodes)) { + if (!node || node.id === excludeNodeId) continue + const ports = nodeRegistry.get(node.type)?.ports?.(node) + if (!ports) continue + for (const port of ports) { + if (systems && port.system !== undefined && !systems.includes(port.system)) continue + result.push({ ...port, nodeId: node.id }) + } + } + return result +} + +/** + * Nearest port within `radius` of `point` on the XZ plane. Y is ignored — + * grid events ride the floor plane while ports usually hang at duct + * height, so a vertical-distance check would make elevated ports + * unreachable. The snap adopts the port's full 3D position. + */ +export function findNearestPortXZ( + point: readonly [number, number, number], + ports: ScenePort[], + radius: number, +): ScenePort | null { + let best: ScenePort | null = null + let bestDistSq = radius * radius + for (const port of ports) { + const dx = port.position[0] - point[0] + const dz = port.position[2] - point[2] + const distSq = dx * dx + dz * dz + if (distSq <= bestDistSq) { + bestDistSq = distSq + best = port + } + } + return best +} + +// ─── Run-body hits ─────────────────────────────────────────────────── + +/** Closest-point hit on a duct run's centerline (not its end ports). */ +export type RunBodyHit = { + nodeId: AnyNodeId + /** Polyline segment hit — between `path[segmentIndex]` and `path[segmentIndex + 1]`. */ + segmentIndex: number + /** Closest point on the centerline, level-local meters (Y interpolated). */ + point: [number, number, number] +} + +/** + * Nearest point on any duct-segment CENTERLINE within `radius` of `point` + * on the XZ plane — how a branch taps the side of a trunk. Same XZ-only + * distance convention as `findNearestPortXZ` (grid events ride the floor, + * runs hang at duct height); the hit adopts the centerline's full 3D + * position. Vertical risers project to a point in XZ and are skipped — + * tapping those isn't meaningful. + */ +export function findNearestRunBodyXZ( + point: readonly [number, number, number], + radius: number, + filter: { excludeNodeId?: AnyNodeId; kinds?: readonly string[] } = {}, +): RunBodyHit | null { + const kinds = filter.kinds ?? ['duct-segment'] + const { nodes } = useScene.getState() + let best: RunBodyHit | null = null + let bestDistSq = radius * radius + for (const node of Object.values(nodes)) { + if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue + const path = (node as { path?: Array }).path + if (!path) continue + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + const abx = b[0] - a[0] + const abz = b[2] - a[2] + const lenSq = abx * abx + abz * abz + if (lenSq < 1e-8) continue // vertical riser — no XZ extent + const t = Math.min( + 1, + Math.max(0, ((point[0] - a[0]) * abx + (point[2] - a[2]) * abz) / lenSq), + ) + const cx = a[0] + abx * t + const cz = a[2] + abz * t + const dx = point[0] - cx + const dz = point[2] - cz + const distSq = dx * dx + dz * dz + if (distSq <= bestDistSq) { + bestDistSq = distSq + best = { + nodeId: node.id, + segmentIndex: i, + point: [cx, a[1] + (b[1] - a[1]) * t, cz], + } + } + } + } + return best +} + +/** + * Where a drawn segment `start`→`end` crosses straight THROUGH an + * existing run's centerline in XZ — the four-way (cross) case, as + * opposed to ending ON a run (the tee case). The crossing must be + * INTERIOR to both: strictly between the drawn segment's ends (so the + * run truly passes through, not just touches at a tip — those are tee + * taps) and strictly inside the hit trunk segment, clear of its joints + * by `endMargin` meters so the run legs have room. The hit's `point` + * adopts the trunk centerline's interpolated 3D position (the drawn run + * snaps onto the trunk's height). Returns the nearest such crossing, or + * null. Vertical risers (no XZ extent) are skipped, same as the body + * query. + */ +export function findRunBodyCrossingXZ( + start: readonly [number, number, number], + end: readonly [number, number, number], + endMargin: number, + filter: { excludeNodeId?: AnyNodeId; kinds?: readonly string[] } = {}, +): RunBodyHit | null { + const kinds = filter.kinds ?? ['duct-segment'] + const { nodes } = useScene.getState() + const dx = end[0] - start[0] + const dz = end[2] - start[2] + const drawnLenSq = dx * dx + dz * dz + if (drawnLenSq < 1e-8) return null + const drawnLen = Math.sqrt(drawnLenSq) + // Interior margins as a fraction of each segment's length. + const drawnPad = Math.min(0.45, endMargin / drawnLen) + let best: RunBodyHit | null = null + let bestScore = Number.POSITIVE_INFINITY + for (const node of Object.values(nodes)) { + if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue + const path = (node as { path?: Array }).path + if (!path) continue + for (let i = 0; i < path.length - 1; i++) { + const a = path[i]! + const b = path[i + 1]! + const ex = b[0] - a[0] + const ez = b[2] - a[2] + const runLenSq = ex * ex + ez * ez + if (runLenSq < 1e-8) continue // vertical riser — no XZ extent + // Solve start + s·d = a + t·e in XZ. denom is the 2D cross of the + // two directions; ~0 means parallel (no single crossing). + const denom = dx * ez - dz * ex + if (Math.abs(denom) < 1e-9) continue + const wx = a[0] - start[0] + const wz = a[2] - start[2] + const s = (wx * ez - wz * ex) / denom + const t = (wx * dz - wz * dx) / denom + const runLen = Math.sqrt(runLenSq) + const runPad = Math.min(0.45, endMargin / runLen) + // Strictly interior to both segments, clear of the trunk's joints. + if (s <= drawnPad || s >= 1 - drawnPad) continue + if (t <= runPad || t >= 1 - runPad) continue + // Prefer the crossing nearest the drawn start (first run hit). + if (s < bestScore) { + bestScore = s + best = { + nodeId: node.id, + segmentIndex: i, + point: [a[0] + ex * t, a[1] + (b[1] - a[1]) * t, a[2] + ez * t], + } + } + } + } + return best +} diff --git a/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx index 1c7d9576..9064236a 100644 --- a/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx +++ b/packages/nodes/src/shared/roof-attachment-fallback-preview.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, } from '@pascal-app/core' import { DragBoundingBox } from '@pascal-app/editor' -import { useEffect, useRef, useState } from 'react' +import { type ReactNode, useEffect, useRef, useState } from 'react' import { Vector3 } from 'three' const INVALID_PREVIEW_COLOR = 0xef_44_44 @@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter' export function RoofAttachmentFallbackPreview({ activeBuildingId, + ghost, isValidRoofTarget, lift = 0, onInvalidTarget, @@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({ validTarget = 'roof', }: { activeBuildingId: string | null | undefined + ghost?: ReactNode isValidRoofTarget?: (event: RoofEvent) => boolean lift?: number onInvalidTarget?: () => void - size: [number, number, number] + size?: [number, number, number] validTarget?: ValidTarget }) { const [position, setPosition] = useState<[number, number, number] | null>(null) @@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({ if (!(activeBuildingId && position)) return null + // When ghost is provided, render the ghost instead of DragBoundingBox + if (ghost) { + return {ghost} + } + + // Fallback to DragBoundingBox for callers not yet migrated + if (!size) return null return ( , +): AnyNodeId | null { + const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) + if (roofLevelId) return roofLevelId + const parent = node.parentId ? nodes[node.parentId] : undefined + if (!parent) return null + if (parent.type === 'level') return parent.id as AnyNodeId + return (parent.parentId as AnyNodeId | null) ?? null +} + /** * Level-plan [x, z] of a roof-hosted node — its face-local center mapped * through the face frame, then composed through the segment's and roof's diff --git a/packages/nodes/src/shared/wall-attach-target.ts b/packages/nodes/src/shared/wall-attach-target.ts index f2bc6be2..0c6dc53e 100644 --- a/packages/nodes/src/shared/wall-attach-target.ts +++ b/packages/nodes/src/shared/wall-attach-target.ts @@ -1,9 +1,12 @@ import { type AnyNode, type AnyNodeId, + collectLevelWallSegments, getScaledDimensions, type ItemNode, - isCurvedWall, + nearestWallSegment, + useScene, + WALL_SNAP_DISTANCE_M, type WallNode, } from '@pascal-app/core' @@ -22,8 +25,6 @@ import { * rejects curved walls (mitering + arc + opening would tear in 3D). */ -const WALL_SNAP_DISTANCE_M = 1.5 - export type WallHit = { wall: WallNode /** Distance along the wall from `start` (clamped to [0, length]). */ @@ -61,10 +62,14 @@ export function projectWallLocalPointToPlan( } /** - * Walk every wall under `parentLevelId` and return the closest one to - * `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`. - * `excludeWallId` skips a specific wall (e.g. the current parent during - * a re-parent flow if you want a "must change" guard). + * Return the single closest wall under `parentLevelId` to `planPoint` — the + * wall whose segment-Voronoi cell the point lies in — or `null` if nothing is + * within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall. + * + * The nearest-segment scan + curved-wall filter live in core + * (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D + * Voronoi debug overlay classifies points with the exact same math — the + * overlay is then a faithful picture of where this snaps. */ export function findClosestWallInPlan( planPoint: readonly [number, number], @@ -72,78 +77,36 @@ export function findClosestWallInPlan( parentLevelId: AnyNodeId | null, excludeWallId?: AnyNodeId, ): WallHit | null { - if (!parentLevelId) return null - const level = nodes[parentLevelId] - const childIds = (level as unknown as { children?: AnyNodeId[] })?.children - if (!Array.isArray(childIds)) return null + const segments = collectLevelWallSegments(nodes, parentLevelId) + const closest = nearestWallSegment( + segments, + planPoint[0], + planPoint[1], + WALL_SNAP_DISTANCE_M, + excludeWallId, + ) + if (!closest) return null - let best: WallHit | null = null + const { segment, along, perp } = closest + // Side determination, calibrated to the 3D wall convention. In wall-local + // space the wall extends along +X and its +Z axis is the front-face normal; + // `perp >= 0` is consistently the front side (see `closestOnSegment`). + const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back' + // Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back. + // The node is parented to the wall, so this composes with the wall's own + // rotation at render — never a world-space rotation here. + const itemRotation = side === 'front' ? 0 : Math.PI - for (const childId of childIds) { - const node = nodes[childId] - if (!node || node.type !== 'wall') continue - if (childId === excludeWallId) continue - const wall = node as WallNode - if (isCurvedWall(wall)) continue - - const sx = wall.start[0] - const sy = wall.start[1] - const dx = wall.end[0] - sx - const dy = wall.end[1] - sy - const wallLength = Math.hypot(dx, dy) - if (wallLength < 1e-6) continue - - const dirX = dx / wallLength - const dirY = dy / wallLength - - // Project pointer onto wall axis. - const px = planPoint[0] - sx - const py = planPoint[1] - sy - const along = px * dirX + py * dirY - const perpRaw = px * -dirY + py * dirX // signed perpendicular distance - const clampedAlong = Math.max(0, Math.min(wallLength, along)) - - // Distance from the pointer to the wall segment (not just the line). - const closestPointX = sx + dirX * clampedAlong - const closestPointY = sy + dirY * clampedAlong - const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY) - if (distance > WALL_SNAP_DISTANCE_M) continue - if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue - - // Side determination, calibrated to the 3D wall convention. In - // wall-local space the wall extends along +X and its +Z axis is the - // front-face normal. After `mesh.rotation.y = -wallAngle`: - // - For a wall going `+X` in plan (wallAngle=0): wall-local +Z - // maps to world +Z = plan +Y, so the front face is on plan +Y. - // `perpRaw = py` is positive → front. - // - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z - // maps to world -X = plan -X, so the front face is on plan -X. - // `perpRaw = -px` is positive there → front. - // So `perpRaw >= 0` is consistently the front side. The earlier - // labelling had this flipped, which produced rotations that were - // off by 90° on non-horizontal walls. - const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back' - - // Rotation in wall-local space — matches 3D `calculateItemRotation`: - // 0 when the item faces the front normal (+Z), π for the back. The - // node is parented to the wall, so this composes with the wall's - // own rotation when rendered. Don't return a world-space rotation - // here — the consumer writes this straight into `node.rotation[1]`. - const itemRotation = side === 'front' ? 0 : Math.PI - - best = { - wall, - localX: clampedAlong, - perpDistance: perpRaw, - side, - dirX, - dirY, - wallLength, - itemRotation, - } + return { + wall: segment.wall, + localX: along, + perpDistance: perp, + side, + dirX: segment.dirX, + dirY: segment.dirY, + wallLength: segment.length, + itemRotation, } - - return best } /** Figma-style along-wall alignment threshold (meters) — parity with the @@ -225,3 +188,104 @@ export function snapLocalXToNeighbors(args: { return bestDelta === null ? null : localX + bestDelta } + +/** + * Does a wall-hosted opening of `width × height` centred at `(clampedX, + * clampedY)` (wall-local) overlap any OTHER child of `wallId` (door / window / + * wall-mounted item)? AABB test in the wall's local face plane. `ignoreId` + * excludes the moving node itself. Returns `true` (blocked) if the wall is + * gone. + * + * Single source of truth for door + window placement collision — door-math and + * window-math had byte-identical copies of this. Y conventions differ per kind + * (items store bottom Y; doors/windows store centre Y), handled inline. + */ +export function hasWallChildOverlap( + wallId: string, + clampedX: number, + clampedY: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined + if (!wallNode) return true + const halfW = width / 2 + const halfH = height / 2 + const newBottom = clampedY - halfH + const newTop = clampedY + halfH + const newLeft = clampedX - halfW + const newRight = clampedX + halfW + + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child) continue + + let childLeft: number + let childRight: number + let childBottom: number + let childTop: number + + if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = getScaledDimensions(item) + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + childTop = item.position[1] + h + } else if (child.type === 'window') { + const win = child as { position: [number, number, number]; width: number; height: number } + childLeft = win.position[0] - win.width / 2 + childRight = win.position[0] + win.width / 2 + childBottom = win.position[1] - win.height / 2 // windows store centre Y + childTop = win.position[1] + win.height / 2 + } else if (child.type === 'door') { + const door = child as { position: [number, number, number]; width: number; height: number } + childLeft = door.position[0] - door.width / 2 + childRight = door.position[0] + door.width / 2 + childBottom = door.position[1] - door.height / 2 // doors store centre Y + childTop = door.position[1] + door.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} + +/** Placement state for a wall-hosted opening — the SINGLE decision the preview + * tint and the commit gate both consume so they can never disagree. */ +export type OpeningPlacement = { + /** Geometric overlap with another wall child (independent of modifiers). */ + collides: boolean + /** May the opening be committed here? `true` unless it collides and the user + * isn't force-placing. */ + placeable: boolean + /** Ghost tint: green when placeable, red when not. */ + tint: 'valid' | 'invalid' +} + +/** + * Resolve the placement state from the raw collision result and whether the + * user is force-placing (Shift). Force-place lifts the collision block, so the + * opening becomes placeable AND the tint goes green — the preview and the + * commit gate stay in lockstep because both read this one result. + */ +export function resolveOpeningPlacement(args: { + collides: boolean + forcePlace: boolean +}): OpeningPlacement { + const placeable = !args.collides || args.forcePlace + return { + collides: args.collides, + placeable, + tint: placeable ? 'valid' : 'invalid', + } +} diff --git a/packages/nodes/src/shared/wall-opening-alignment.ts b/packages/nodes/src/shared/wall-opening-alignment.ts index 48f8cc30..0a184f2f 100644 --- a/packages/nodes/src/shared/wall-opening-alignment.ts +++ b/packages/nodes/src/shared/wall-opening-alignment.ts @@ -23,6 +23,12 @@ const MIN_AXIS_COMPONENT = 0.5 * guide on bypass / no-match. Returns the localX to use (X-clamped to the wall * given `width`). `bypass` disables alignment; `bypassSnap` also skips the * half-metre fallback. + * + * `freePlace` (Shift) is the "place anywhere, but still show me where I'd + * align" mode: the opening lands at the EXACT raw cursor (no grid snap, no + * jump-to-guide), yet the alignment guides are still computed and shown so the + * user keeps the visual reference while overriding the magnetic pull. It + * supersedes `bypass`/`bypassSnap` when set. */ export function resolveWallSlideAlignment(args: { wallNode: WallNode @@ -31,9 +37,55 @@ export function resolveWallSlideAlignment(args: { candidates: readonly AlignmentAnchor[] bypass: boolean bypassSnap?: boolean + freePlace?: boolean }): number { - const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args - const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX) + const { + wallNode, + rawLocalX, + width, + candidates, + bypass, + bypassSnap = false, + freePlace = false, + } = args + const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX) + + const dxAxis = wallNode.end[0] - wallNode.start[0] + const dzAxis = wallNode.end[1] - wallNode.start[1] + const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis) + + // Shift / free-place: land at the raw cursor but still publish the guides so + // the user sees alignment relationships without being snapped to them. The + // guides are re-resolved at the freely-placed point so they connect to the + // opening, not the snap target. + if (freePlace) { + if (candidates.length === 0 || axisLength < 1e-6) { + useAlignmentGuides.getState().clear() + return base + } + const c = dxAxis / axisLength + const s = dzAxis / axisLength + const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base)) + const shown = resolveAlignment({ + moving: [ + { + nodeId: '__wall-opening-draft__', + kind: 'corner', + x: wallNode.start[0] + placedX * c, + z: wallNode.start[1] + placedX * s, + }, + ], + candidates, + threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M, + }) + const axisGuides = shown.guides.filter( + (g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT, + ) + if (axisGuides.length === 0) useAlignmentGuides.getState().clear() + else useAlignmentGuides.getState().set(axisGuides) + return placedX + } + if (bypass || candidates.length === 0) { useAlignmentGuides.getState().clear() return base diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 0ac3f986..8819f0f9 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -270,7 +270,7 @@ export const shelfDefinition: NodeDefinition = { presentation: { label: 'Shelf', description: 'A configurable shelving unit. Items host on each row.', - icon: { kind: 'url', src: '/icons/shelf.png' }, + icon: { kind: 'url', src: '/icons/shelf.webp' }, paletteSection: 'furnish', paletteOrder: 30, }, diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts index 512d9216..c944325c 100644 --- a/packages/nodes/src/site/definition.ts +++ b/packages/nodes/src/site/definition.ts @@ -41,7 +41,7 @@ export const siteDefinition: NodeDefinition = { presentation: { label: 'Site', description: 'The top-level container holding buildings, zones, and the property boundary.', - icon: { kind: 'url', src: '/icons/site-flag.png' }, + icon: { kind: 'url', src: '/icons/site-flag.webp' }, paletteSection: 'site', paletteOrder: 5, }, diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts index a32a3f7e..fa7161fb 100644 --- a/packages/nodes/src/skylight/definition.ts +++ b/packages/nodes/src/skylight/definition.ts @@ -271,7 +271,7 @@ export const skylightDefinition: NodeDefinition = { presentation: { label: 'Skylight', description: 'Framed glass opening on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 124, }, diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index ed4d90d0..2eaba0cb 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -258,7 +258,7 @@ export default function SkylightPanel() { return ( { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const SkylightPreview = ({ node, invalid }: { node: SkylightNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + const frame = useMemo( () => buildFrameGeometry({ @@ -48,8 +61,8 @@ const SkylightPreview = ({ node }: { node: SkylightNode }) => { return ( - {}} /> - {}} /> + {}} /> + {}} /> ) } diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 4111cd77..f4038470 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -652,6 +652,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { node.glassMaterialPreset, ]) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const surfaceFrame = useMemo(() => { if (!segment) return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } return getRoofOuterSurfaceFrameAtPoint(segment, node.position[0] ?? 0, node.position[2] ?? 0) diff --git a/packages/nodes/src/skylight/tool.tsx b/packages/nodes/src/skylight/tool.tsx index 73d3933a..5d6b005c 100644 --- a/packages/nodes/src/skylight/tool.tsx +++ b/packages/nodes/src/skylight/tool.tsx @@ -114,11 +114,11 @@ const SkylightTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.2, 0.2, 1]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts index fc0ab43c..d8f698bc 100644 --- a/packages/nodes/src/slab/definition.ts +++ b/packages/nodes/src/slab/definition.ts @@ -214,7 +214,7 @@ export const slabDefinition: NodeDefinition = { presentation: { label: 'Slab', description: 'A polygon-bounded floor surface that hosts items on top.', - icon: { kind: 'url', src: '/icons/floor.png' }, + icon: { kind: 'url', src: '/icons/floor.webp' }, paletteSection: 'structure', paletteOrder: 30, }, diff --git a/packages/nodes/src/slab/panel.tsx b/packages/nodes/src/slab/panel.tsx index fca302c2..fcbd6003 100644 --- a/packages/nodes/src/slab/panel.tsx +++ b/packages/nodes/src/slab/panel.tsx @@ -152,7 +152,7 @@ export function SlabPanel() { return ( { if (!cursorRef.current) return const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] const bypassSnap = shiftPressed.current || event.nativeEvent?.shiftKey === true - const gridX = Math.round(rawPoint[0] * 2) / 2 - const gridZ = Math.round(rawPoint[1] * 2) / 2 - const gridPosition: [number, number] = bypassSnap ? rawPoint : [gridX, gridZ] + const gridPosition: [number, number] = bypassSnap + ? rawPoint + : [...snapPointToGrid(rawPoint, useEditor.getState().gridSnapStep)] setCursorPosition(gridPosition) setLevelY(event.localPosition[1]) const lastPoint = points[points.length - 1] diff --git a/packages/nodes/src/solar-panel/definition.ts b/packages/nodes/src/solar-panel/definition.ts index 0c129b78..e38ab4a1 100644 --- a/packages/nodes/src/solar-panel/definition.ts +++ b/packages/nodes/src/solar-panel/definition.ts @@ -272,7 +272,7 @@ export const solarPanelDefinition: NodeDefinition = { presentation: { label: 'Solar Panel', description: 'Grid of photovoltaic panels mounted on a roof segment.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 123, }, diff --git a/packages/nodes/src/solar-panel/panel.tsx b/packages/nodes/src/solar-panel/panel.tsx index 099510cc..92351bc1 100644 --- a/packages/nodes/src/solar-panel/panel.tsx +++ b/packages/nodes/src/solar-panel/panel.tsx @@ -181,7 +181,7 @@ export default function SolarPanelPanel() { return ( { +const invalidGhostMaterial = new THREE.MeshStandardMaterial({ + color: INVALID_GHOST_COLOR, + emissive: INVALID_GHOST_COLOR, + emissiveIntensity: 0.12, + roughness: 0.5, + transparent: true, + opacity: 0.4, + depthWrite: false, +}) + +const SolarPanelPreview = ({ node, invalid }: { node: SolarPanelNode; invalid?: boolean }) => { + const material = invalid ? invalidGhostMaterial : ghostMaterial + + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildSolarPanelGeometry(node), [ @@ -38,7 +52,7 @@ const SolarPanelPreview = ({ node }: { node: SolarPanelNode }) => { return ( { /* preview should not intercept the cursor */ }} diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 663a5608..997c29b0 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -98,6 +98,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { : segment : undefined + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildSolarPanelGeometry(node), [ @@ -138,6 +139,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { // the tilt normal flow from here, so a wall-height or pitch change // re-seats and re-orients the panel automatically. `segmentOverrides` // is in the deps so a live drag re-derives the frame mid-drag. + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const surfaceFrame = useMemo(() => { if (!effectiveSegment) { return { point: new THREE.Vector3(), normal: new THREE.Vector3(0, 1, 0) } diff --git a/packages/nodes/src/solar-panel/tool.tsx b/packages/nodes/src/solar-panel/tool.tsx index 7af8aeab..f397b7ed 100644 --- a/packages/nodes/src/solar-panel/tool.tsx +++ b/packages/nodes/src/solar-panel/tool.tsx @@ -135,11 +135,11 @@ const SolarPanelTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[1.8, 0.2, 1.2]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/spawn/definition.ts b/packages/nodes/src/spawn/definition.ts index fde67442..5233ccc4 100644 --- a/packages/nodes/src/spawn/definition.ts +++ b/packages/nodes/src/spawn/definition.ts @@ -107,7 +107,7 @@ export const spawnDefinition: NodeDefinition = { presentation: { label: 'Spawn Point', description: 'Player or camera origin within a level. One per level.', - icon: { kind: 'url', src: '/icons/spawn-point.png' }, + icon: { kind: 'url', src: '/icons/spawn-point.webp' }, paletteSection: 'structure', paletteOrder: 90, // bottom of structure list — matches legacy palette order }, diff --git a/packages/nodes/src/spawn/panel.tsx b/packages/nodes/src/spawn/panel.tsx index e8a29302..de8e49d0 100644 --- a/packages/nodes/src/spawn/panel.tsx +++ b/packages/nodes/src/spawn/panel.tsx @@ -95,7 +95,7 @@ export default function SpawnPanel() { return ( Math.round(value * 2) / 2 +const snapToGrid = (value: number) => snapScalar(value, useEditor.getState().gridSnapStep) const worldVector = new Vector3() function getExistingSpawnIds() { @@ -31,14 +38,14 @@ function getLevelLocalPosition( if (!levelObject) { return bypassSnap ? [event.localPosition[0], 0, event.localPosition[2]] - : [roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])] + : [snapToGrid(event.localPosition[0]), 0, snapToGrid(event.localPosition[2])] } worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) levelObject.worldToLocal(worldVector) return bypassSnap ? [worldVector.x, 0, worldVector.z] - : [roundToHalf(worldVector.x), 0, roundToHalf(worldVector.z)] + : [snapToGrid(worldVector.x), 0, snapToGrid(worldVector.z)] } /** @@ -58,11 +65,11 @@ const SpawnTool = () => { const onGridMove = (event: GridEvent) => { // Cursor lives in the ToolManager's building-local group. Use - // event.localPosition directly (already building-local) with the - // same half-meter snap the legacy tool uses. + // event.localPosition directly (already building-local), snapped to the + // editor's configured grid step (Shift bypasses). const bypassSnap = event.nativeEvent?.shiftKey === true - const nextX = bypassSnap ? event.localPosition[0] : roundToHalf(event.localPosition[0]) - const nextZ = bypassSnap ? event.localPosition[2] : roundToHalf(event.localPosition[2]) + const nextX = bypassSnap ? event.localPosition[0] : snapToGrid(event.localPosition[0]) + const nextZ = bypassSnap ? event.localPosition[2] : snapToGrid(event.localPosition[2]) const position: [number, number, number] = [nextX, 0, nextZ] const previewNode = SpawnNode.parse({ name: 'Spawn Point', diff --git a/packages/nodes/src/stair-segment/definition.ts b/packages/nodes/src/stair-segment/definition.ts index 63333a61..81730be0 100644 --- a/packages/nodes/src/stair-segment/definition.ts +++ b/packages/nodes/src/stair-segment/definition.ts @@ -140,7 +140,7 @@ export const stairSegmentDefinition: NodeDefinition = { presentation: { label: 'Stair Segment', description: 'A single flight of a parent stair.', - icon: { kind: 'url', src: '/icons/stairs.png' }, + icon: { kind: 'url', src: '/icons/stairs.webp' }, paletteSection: 'structure', paletteOrder: 111, }, diff --git a/packages/nodes/src/stair-segment/panel.tsx b/packages/nodes/src/stair-segment/panel.tsx index 79267551..af6c7101 100644 --- a/packages/nodes/src/stair-segment/panel.tsx +++ b/packages/nodes/src/stair-segment/panel.tsx @@ -121,7 +121,7 @@ export default function StairSegmentPanel() { return ( = { label: 'Stair', description: 'A stair composed of one or more flights with configurable treads, risers, railings.', - icon: { kind: 'url', src: '/icons/stairs.png' }, + icon: { kind: 'url', src: '/icons/stairs.webp' }, paletteSection: 'structure', paletteOrder: 110, }, diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 1b9f2b92..13d6e6f2 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -226,7 +226,7 @@ export default function StairPanel() { return ( = { presentation: { label: 'Turbine Vent', description: 'Wind-driven spinning whirlybird exhaust vent for a roof slope.', - icon: { kind: 'url', src: '/icons/roof.png' }, + icon: { kind: 'url', src: '/icons/roof.webp' }, paletteSection: 'structure', paletteOrder: 121, }, diff --git a/packages/nodes/src/turbine-vent/panel.tsx b/packages/nodes/src/turbine-vent/panel.tsx index 57898e92..9588668a 100644 --- a/packages/nodes/src/turbine-vent/panel.tsx +++ b/packages/nodes/src/turbine-vent/panel.tsx @@ -161,7 +161,7 @@ export default function TurbineVentPanel() { return ( { +const TurbineVentPreview = ({ node, invalid }: { node: TurbineVentNode; invalid?: boolean }) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const geometry = useMemo( () => buildTurbineVentGeometry(node), [node.style, node.diameter, node.height, node.neckHeight, node.vaneCount, node.baseOverhang], @@ -21,17 +23,17 @@ const TurbineVentPreview = ({ node }: { node: TurbineVentNode }) => { const material = useMemo( () => new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - emissive: 0x6c_a3_ff, + color: invalid ? INVALID_GHOST_COLOR : 0xff_ff_ff, + emissive: invalid ? INVALID_GHOST_COLOR : 0x6c_a3_ff, emissiveIntensity: 0.18, roughness: 0.6, metalness: 0.2, transparent: true, - opacity: 0.35, + opacity: invalid ? 0.4 : 0.35, depthWrite: false, side: THREE.DoubleSide, }), - [], + [invalid], ) const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry]) diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index 2e768544..0d1bb494 100644 --- a/packages/nodes/src/turbine-vent/renderer.tsx +++ b/packages/nodes/src/turbine-vent/renderer.tsx @@ -65,10 +65,12 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => : undefined, ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const baseGeometry = useMemo( () => buildTurbineVentBase(node), [node.diameter, node.height, node.neckHeight, node.baseOverhang], ) + // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const headGeometry = useMemo( () => buildTurbineVentHead(node), [node.style, node.diameter, node.height, node.neckHeight, node.vaneCount], diff --git a/packages/nodes/src/turbine-vent/tool.tsx b/packages/nodes/src/turbine-vent/tool.tsx index 8ba0b37d..9634e5e6 100644 --- a/packages/nodes/src/turbine-vent/tool.tsx +++ b/packages/nodes/src/turbine-vent/tool.tsx @@ -122,11 +122,11 @@ const TurbineVentTool = () => { <> } onInvalidTarget={() => { setPreviewPos(null) setPreviewSurfaceQuat(null) }} - size={[0.5, 0.8, 0.5]} /> {activeBuildingId && previewPos && previewSurfaceQuat && ( diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index d200c436..47356850 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -121,7 +121,7 @@ export const wallDefinition: NodeDefinition = { presentation: { label: 'Wall', description: 'A straight or curved wall segment. Hosts doors, windows, and wall-mounted items.', - icon: { kind: 'url', src: '/icons/wall.png' }, + icon: { kind: 'url', src: '/icons/wall.webp' }, paletteSection: 'structure', paletteOrder: 10, }, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 5750eefb..31f49519 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -137,7 +137,7 @@ export default function WallPanel() { return ( unknown }): number { if (!w.wallId) return Number.POSITIVE_INFINITY @@ -49,6 +53,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor n.width, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newWidth) => { const rotY = initial.rotation[1] const armX = Math.cos(rotY) @@ -96,6 +101,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor n.height, + onDrag: (node) => publishOpeningResizeGuides(node, true), apply: (initial, newHeight) => { // Anchored edge stays in wall-local Y; opposite edge moves. const anchorY = @@ -115,7 +121,26 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'move-cross', + plane: 'node-normal', + portal: 'grandparent', + cursor: 'move', + onActivate: (node, _scene, editor) => editor.engageMoveDrag(node), + placement: { + position: () => [0, 0, MOVE_HANDLE_LIFT], + }, + } +} + const windowHandles: HandleDescriptor[] = [ + windowMoveHandle(), windowWidthHandle('left'), windowWidthHandle('right'), windowHeightHandle('top'), @@ -210,7 +235,7 @@ export const windowDefinition: NodeDefinition = { presentation: { label: 'Window', description: 'A window cut into a wall. Animated open/close for opening windows.', - icon: { kind: 'url', src: '/icons/window.png' }, + icon: { kind: 'url', src: '/icons/window.webp' }, paletteSection: 'structure', paletteOrder: 60, }, diff --git a/packages/nodes/src/window/floor-projection.tsx b/packages/nodes/src/window/floor-projection.tsx new file mode 100644 index 00000000..07115630 --- /dev/null +++ b/packages/nodes/src/window/floor-projection.tsx @@ -0,0 +1,111 @@ +'use client' + +import { EDITOR_LAYER } from '@pascal-app/editor' +import { useEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, LineSegments } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' + +/** + * Floor "shadow" projection for a window during placement / move. + * + * Windows sit elevated above the floor, so over open ground (and on a wall) + * it's hard to read where the window actually is in plan. This draws its + * projection on the floor: a small footprint segment directly below the window + * (its plan extent along the wall) plus a DASHED vertical line dropping from the + * window centre to that footprint — like a shadow tether. Placement aid only; + * never shown on a committed window. + * + * Rendered in WORLD space (the tool positions the ghost in world space too), so + * it's mounted as a sibling of the ghost — NOT inside the ghost's rotated/offset + * group. `centerY` is the window centre's world Y; `floorY` is the level floor. + */ + +const FOOTPRINT_COLOR = 0x38_bd_f8 +const DROP_COLOR = 0x38_bd_f8 + +const footprintMaterial = new LineBasicNodeMaterial({ + color: FOOTPRINT_COLOR, + transparent: true, + opacity: 0.85, + depthWrite: false, +}) +const dropMaterial = new LineBasicNodeMaterial({ + color: DROP_COLOR, + transparent: true, + opacity: 0.6, + depthWrite: false, +}) + +// Dash geometry for the vertical drop: alternating on/off segments so it reads +// as a dashed tether without a dashed-line material (unavailable in three/webgpu). +const DASH = 0.12 +const GAP = 0.08 + +export function WindowFloorProjection({ + centerX, + centerZ, + centerY, + floorY, + width, + rotationY, +}: { + centerX: number + centerZ: number + centerY: number + floorY: number + width: number + rotationY: number +}) { + // Footprint: a short segment of length `width` along the window's wall axis, + // centred under the window on the floor. The window faces `rotationY` about Y + // (its width runs along the wall), so the along-wall direction is + // (cos, -sin) in XZ. + const footprint = useMemo(() => { + const half = width / 2 + const dirX = Math.cos(rotationY) + const dirZ = -Math.sin(rotationY) + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new LineSegments(geometry, footprintMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + line.raycast = () => {} + position.setXYZ(0, centerX - dirX * half, floorY + 0.002, centerZ - dirZ * half) + position.setXYZ(1, centerX + dirX * half, floorY + 0.002, centerZ + dirZ * half) + position.needsUpdate = true + return line + }, [centerX, centerZ, floorY, width, rotationY]) + + // Dashed vertical tether from the window centre down to the footprint. + const drop = useMemo(() => { + const span = Math.max(centerY - floorY, 0) + const segs: number[] = [] + let y = floorY + while (y < floorY + span) { + const top = Math.min(y + DASH, floorY + span) + segs.push(centerX, y, centerZ, centerX, top, centerZ) + y += DASH + GAP + } + const position = new Float32BufferAttribute(new Float32Array(segs), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new LineSegments(geometry, dropMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + line.raycast = () => {} + return line + }, [centerX, centerZ, centerY, floorY]) + + useEffect(() => () => footprint.geometry.dispose(), [footprint]) + useEffect(() => () => drop.geometry.dispose(), [drop]) + + return ( + <> + + + + ) +} diff --git a/packages/nodes/src/window/floorplan-move.ts b/packages/nodes/src/window/floorplan-move.ts index cfa3c187..48d6e1df 100644 --- a/packages/nodes/src/window/floorplan-move.ts +++ b/packages/nodes/src/window/floorplan-move.ts @@ -2,22 +2,22 @@ import { type AnyNodeId, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveTransforms, useScene, type WallNode, + WallNode as WallNodeSchema, type WindowNode, } from '@pascal-app/core' -import { snapToHalf } from '@pascal-app/editor' +import { snapToHalf, triggerSFX, usePlacementPreview } from '@pascal-app/editor' import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' -import { - getRoofHostedOpeningLevelId, - getRoofHostedOpeningPlanPoint, -} from '../shared/roof-opening-host' +import { getOpeningHostLevelId, getRoofHostedOpeningPlanPoint } from '../shared/roof-opening-host' import { findClosestWallInPlan, projectWallLocalPointToPlan, + resolveOpeningPlacement, snapLocalXToNeighbors, } from '../shared/wall-attach-target' -import { clampToWall, hasWallChildOverlap } from './window-math' +import { clampToWall, DEFAULT_WINDOW_SILL_M, hasWallChildOverlap } from './window-math' /** * 2D floor-plan move handler for window. Same shape as door (see @@ -32,15 +32,9 @@ import { clampToWall, hasWallChildOverlap } from './window-math' */ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) => { - const startLevelId = (() => { - // Wall-hosted: the wall's parent is the level. Roof-hosted: walk - // segment → roof → level. - const nodes = useScene.getState().nodes - const roofLevelId = getRoofHostedOpeningLevelId(node, nodes) - if (roofLevelId) return roofLevelId - const wall = nodes[node.parentId as AnyNodeId] - return wall ? (wall.parentId as AnyNodeId | null) : null - })() + // The level that owns the wall-snap candidates — resolves the wall-hosted, + // roof-hosted, and fresh-placement parentings (see `getOpeningHostLevelId`). + const startLevelId = getOpeningHostLevelId(node, useScene.getState().nodes) const originalWall = node.parentId ? (useScene.getState().nodes[node.parentId as AnyNodeId] as WallNode | undefined) : undefined @@ -50,12 +44,21 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod ? projectWallLocalPointToPlan(originalWall, node.position[0]) : (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]), metadata: node.metadata, + // Absolute: query the wall snap with the TRUE cursor (see the matching + // comment in `doorFloorplanMoveTarget`). Relative mode anchored the search + // to the original wall, which let the window snap to a farther wall across + // a thin gap instead of the one under the cursor. + mode: 'absolute', }) // Preserve the source window's local Y — 2D move doesn't have a way // to express vertical motion, so we keep whatever vertical position - // the window had when the move started. - const startLocalY = node.position[1] + // the window had when the move started. A fresh preset/catalog clone is + // created at y=0, which would sit the window's centre on the floor (half + // below ground); default those to a realistic sill so it floats above + // the floor in 2D too. Same rule as the 3D `MoveWindowTool` (`getSillCenterY`). + const startLocalY = + node.position[1] > 0.1 ? node.position[1] : DEFAULT_WINDOW_SILL_M + node.height / 2 // Track the last successful placement so `commit()` can write it // atomically — same deterministic-commit fix as `doorFloorplanMoveTarget`. @@ -69,13 +72,100 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod roofFace: undefined } | null = null + // R flips the window's facing (front ↔ back) mid-placement — see + // `doorFloorplanMoveTarget`. `apply` re-derives the side each move, so the + // flip is a persistent XOR plus a π rotation offset. + let flipped = false + let lastApply: { + planPoint: readonly [number, number] + modifiers: { shiftKey: boolean; altKey: boolean; ctrlKey: boolean; metaKey: boolean } + } | null = null + // See `doorFloorplanMoveTarget`: off-wall the window free-follows the cursor + // as a ghost and isn't committable (it needs a wall). Starts true. + let onWall = true + // Shift force-place (last apply's modifier) — lets `canCommit` allow an + // overlapping placement, matching the 3D move. + let forcePlace = false + + // Move SFX — parity with the 3D `MoveWindowTool` (see `doorFloorplanMoveTarget`): + // ONE soft `sfx:grid-snap` click per grid step, identical free-following or on a + // wall, keyed on the RAW cursor. No separate floor→wall cue (that was the + // "double"). 2D `apply` runs once per pointermove, so the step-key dedup suffices. + const STEP_M = 0.1 + let lastStepKey: string | null = null + const tickGridStep = (...coords: number[]) => { + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key !== lastStepKey) { + lastStepKey = key + triggerSFX('sfx:grid-snap') + } + } + + const freeFollow = (planPoint: readonly [number, number]) => { + onWall = false + lastValid = null + if ((useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: false }) + } + const half = node.width / 2 + 0.5 + const wall = WallNodeSchema.parse({ + start: [planPoint[0] - half, planPoint[1]], + end: [planPoint[0] + half, planPoint[1]], + thickness: 0.1, + }) + // Reflect the R-flip on the floating ghost so it faces the side that will + // be committed (see `doorFloorplanMoveTarget.freeFollow`). + const ghostSide: WindowNode['side'] = flipped + ? node.side === 'front' + ? 'back' + : 'front' + : node.side + const ghost = { + ...node, + side: ghostSide, + parentId: wall.id, + wallId: wall.id, + roofSegmentId: undefined, + roofFace: undefined, + position: [half, startLocalY, 0] as [number, number, number], + rotation: [0, flipped ? Math.PI : 0, 0] as [number, number, number], + visible: true, + } as WindowNode + usePlacementPreview.getState().set(ghost, wall) + } + const session: FloorplanMoveTargetSession = { affectedIds: [node.id as AnyNodeId], + flipSide() { + flipped = !flipped + if (lastApply) this.apply(lastApply) + }, apply({ planPoint, modifiers }) { + lastApply = { planPoint, modifiers } + forcePlace = modifiers.shiftKey === true + // Drop any stale live transform left by the 3D `MoveWindowTool` — see + // `doorFloorplanMoveTarget.apply`. Without this the 2D registry layer + // keeps rendering the window at the 3D tool's last hover (it prefers + // `useLiveTransforms` over the scene node for door/window), so the 2D + // slide — which writes the scene node — wouldn't show. Guarded on + // existence: `clear` allocates a new Map + re-renders. + if (useLiveTransforms.getState().transforms.has(node.id as AnyNodeId)) { + useLiveTransforms.getState().clear(node.id as AnyNodeId) + } const nodes = useScene.getState().nodes const resolvedPlanPoint = resolveCursor(planPoint) const hit = findClosestWallInPlan(resolvedPlanPoint, nodes, startLevelId) - if (!hit) return + if (!hit) { + // Off any wall — free-follow. Click per grid cell over open floor. + tickGridStep(resolvedPlanPoint[0], resolvedPlanPoint[1]) + freeFollow(resolvedPlanPoint) + return + } + onWall = true + usePlacementPreview.getState().clear() + if ((nodes[node.id as AnyNodeId] as WindowNode | undefined)?.visible === false) { + useScene.getState().updateNode(node.id as AnyNodeId, { visible: true }) + } // Figma-style along-wall alignment first (edge-to-edge with other // openings / wall ends), winning over the 0.5m grid snap; falls back @@ -99,10 +189,22 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod node.height, ) + // One click per grid step, keyed on the RAW along-wall cursor (`hit.localX`) + // so the wall slide ticks at the same cadence as the off-wall ghost — same + // SFX, no separate snap cue. + tickGridStep(hit.localX) + + const side: WindowNode['side'] = flipped + ? hit.side === 'front' + ? 'back' + : 'front' + : hit.side + const itemRotation = hit.itemRotation + (flipped ? Math.PI : 0) + lastValid = { position: [clampedX, clampedY, 0], - rotation: [0, hit.itemRotation, 0], - side: hit.side, + rotation: [0, itemRotation, 0], + side, parentId: hit.wall.id, wallId: hit.wall.id, // Re-anchoring to a wall ends any roof-segment hosting; the @@ -119,9 +221,14 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod ]) }, canCommit() { + // Off-wall the window is free-following — not placeable; the overlay + // reverts to the pre-move snapshot. Matches the 3D move. + if (!onWall) return false const live = useScene.getState().nodes[node.id as AnyNodeId] as WindowNode | undefined if (!live || live.type !== 'window') return false - const overlapping = hasWallChildOverlap( + // Block on overlap UNLESS Shift force-places — same `placeable` rule as + // the 3D move + the shared `resolveOpeningPlacement`. + const collides = hasWallChildOverlap( live.parentId as string, live.position[0], live.position[1], @@ -129,7 +236,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget = ({ nod live.height, live.id, ) - return !overlapping + return resolveOpeningPlacement({ collides, forcePlace }).placeable }, commit() { // Own the atomic write so the overlay takes the deterministic diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index eb6076b9..37ac6567 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, collectAlignmentAnchors, emitter, + type GridEvent, isCurvedWall, type RoofEvent, type RoofNode, @@ -13,7 +14,6 @@ import { WindowNode, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, consumePlacementDragRelease, EDITOR_LAYER, @@ -26,16 +26,29 @@ import { useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, + resolveSillSnap, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, resolveRoofWallOpeningTarget, } from '../shared/roof-wall-opening-placement' +import { resolveOpeningPlacement } from '../shared/wall-attach-target' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' -import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' +import { WindowFloorProjection } from './floor-projection' +import WindowPreview from './preview' +import { + clampToWall, + DEFAULT_WINDOW_SILL_M, + hasWallChildOverlap, + wallLocalToWorld, +} from './window-math' const edgeMaterial = new LineBasicNodeMaterial({ color: 0xef_44_44, @@ -59,6 +72,38 @@ const edgeMaterial = new LineBasicNodeMaterial({ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { const cursorGroupRef = useRef(null!) + // The window preview ghost. Shown for the WHOLE move so the user always sees + // a translucent window tinted by placement state — red off-wall or colliding, + // green on a valid wall. The real node stays hidden until commit (the wall + // still cuts its hole from the node data). `null` = not previewing. See the + // matching `WindowPreview` tint and `MoveDoorTool` for the full rationale. + const [ghostPose, setGhostPose] = useState<{ + position: [number, number, number] + rotationY: number + tint: 'valid' | 'invalid' + // Level floor world-Y, for the floor "shadow" projection (drop-line + footprint). + floorY: number + // Live facing side — R-flip changes it and the window geometry depends on it, + // so the ghost must rebuild with the live side (see `MoveDoorTool`). + side: WindowNode['side'] + } | null>(null) + + // Ghost preview node: the moving window with a zeroed transform + the live + // facing side (the ghost is positioned by the `` wrapper; + // `updateWindowMesh` bakes the node's own position/rotation in, so passing the + // live node would double-offset). Rebuilds on an R-flip so the preview matches + // what commit will place. + const ghostSide = ghostPose?.side ?? movingWindowNode.side + const ghostNode = useMemo( + () => ({ + ...movingWindowNode, + side: ghostSide, + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0] as [number, number, number], + }), + [movingWindowNode, ghostSide], + ) + const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -85,6 +130,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: movingWindowNode.roofSegmentId, roofFace: movingWindowNode.roofFace, metadata: movingWindowNode.metadata, + // Free-follow hides the node (visible:false); revert paths restore this. + visible: movingWindowNode.visible, } // In move mode (existing window) mark it transient so its mesh skips the live wall CSG @@ -101,6 +148,38 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode let currentHostId: string | null = movingWindowNode.parentId let committed = false + // Off-wall free-follow: over empty floor the window is parented to the + // level and tracks the cursor like an item. `freeFollowing` marks that + // state; `lastMeshEventTime` defers the floor handler whenever a wall/roof + // mesh event owns the same pointermove — that's the only thing that snaps. + let freeFollowing = false + let lastMeshEventTime = -1 + // Last open-floor cursor point (level-local X/Z), so an R-flip while free- + // following can re-run the ghost at the same spot with the new facing. + let lastFloorPoint: [number, number] | null = null + // Live Shift state (force-place) — lets the preview tint re-evaluate when + // Shift is pressed/released with the pointer stationary (see `MoveDoorTool`). + let shiftHeld = false + // Movement SFX: ONE soft `sfx:grid-snap` click per grid step — identical + // whether free-following over floor or sliding along a wall (the user's + // ask). Always keyed on the RAW cursor (continuous ~0.1m cadence), never the + // snapped along-wall value. Guards: `lastStepKey` (cell change) + + // `lastTickFrame` (one tick per DOM pointermove). No separate snap cue — a + // distinct floor→wall sound was the "double" the user heard. See `MoveDoorTool`. + const STEP_M = 0.1 + let lastStepKey: string | null = null + let lastTickFrame = -1 + const tickGridStep = (frame: number, ...coords: number[]) => { + if (frame === lastTickFrame) return + const key = coords.map((c) => Math.round(c / STEP_M)).join(',') + if (key === lastStepKey) return + lastStepKey = key + lastTickFrame = frame + triggerSFX('sfx:grid-snap') + } + // The window's chosen facing side. R flips it mid-placement (front ↔ back), + // matching the committed-selected R flip. Initialised from the moving node. + let sideOverride: WindowNode['side'] = movingWindowNode.side let dragAnchor: { wallId: string rawX: number @@ -113,7 +192,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: string side: WindowNode['side'] itemRotation: number - cursorRotation: number clampedX: number clampedY: number valid: boolean @@ -141,6 +219,16 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } + + // Sill-center height used while the window isn't on a wall (free-follow and + // proximity). Fresh preset clones are created at position [0,0,0], which + // would bury half the window below the floor; default such windows to a + // small sill so the ghost floats slightly above the ground. An existing + // window keeps its own sill. + const getSillCenterY = () => { + const y = movingWindowNode.position[1] + return y > 0.1 ? y : DEFAULT_WINDOW_SILL_M + movingWindowNode.height / 2 + } const getSlabElevation = (wallEvent: WallEvent) => spatialGridManager.getSlabElevationForWall( wallEvent.node.parentId ?? '', @@ -151,6 +239,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + setGhostPose(null) } // Alignment candidates — anchors of every OTHER alignable object (the @@ -183,9 +273,10 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode // Only interact with walls on the current level if (event.node.parentId !== getLevelId()) return - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + const faceSide = getSideFromNormal(event.normal) + const side = sideOverride ?? faceSide + const rotationOffset = side !== faceSide ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + rotationOffset const rawLocalX = event.localPosition[0] const rawLocalY = event.localPosition[1] @@ -206,15 +297,30 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX) const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY) - const targetLocalY = - event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY) + // Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins + // over the 0.5m grid when within threshold; Shift bypasses both. + const bypassY = event.nativeEvent?.shiftKey === true + const sillSnapped = bypassY + ? null + : resolveSillSnap({ + wall: event.node, + movingId: movingWindowNode.id, + localX: targetLocalX, + localY: targetRawLocalY, + width: movingWindowNode.width, + height: movingWindowNode.height, + nodes: useScene.getState().nodes, + }) + const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY)) const localX = resolveWallSlideAlignment({ wallNode: event.node, rawLocalX: targetLocalX, width: movingWindowNode.width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + // Alt still hard-disables alignment (no guides). Shift = free-place: + // land at the raw cursor but keep showing the along-wall guides. + bypass: event.nativeEvent?.altKey === true, + freePlace: event.nativeEvent?.shiftKey === true, }) const { clampedX, clampedY } = clampToWall( event.node, @@ -238,7 +344,6 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: event.node.id, side, itemRotation, - cursorRotation, clampedX, clampedY, valid, @@ -247,6 +352,14 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const applyPreview = (target: NonNullable) => { + // Same click as the off-wall ghost: one grid-snap tick per grid step, + // keyed on the RAW cursor along-wall position (not the snapped clampedX). + // Per-frame guard collapses duplicate wall events on the same pointermove. + tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.event.localPosition[0]) + // Keep the REAL node hidden and show a tinted ghost in the wall opening — + // green when placeable, red when it collides — matching the free-follow + // ghost so validity reads at a glance (see MoveDoorTool). The node position + // is still written so the wall cuts the hole at the right spot. if (currentHostId !== target.wallId) { useScene.getState().updateNode(movingWindowNode.id, { position: [target.clampedX, target.clampedY, 0], @@ -256,6 +369,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + visible: false, }) markHostDirty(currentHostId) currentHostId = target.wallId @@ -273,25 +387,50 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) markHostDirtyThrottled(target.wallId) - updateCursor( - wallLocalToWorld( + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + const placement = resolveOpeningPlacement({ collides: !target.valid, forcePlace: shiftHeld }) + // Ghost world yaw must equal the committed wall-CHILD's world yaw + // (-wallAngle + itemRotation); `cursorRotation` is π off here. See + // `MoveDoorTool.applyPreview`. + const wallAngle = Math.atan2( + target.wallNode.end[1] - target.wallNode.start[1], + target.wallNode.end[0] - target.wallNode.start[0], + ) + setGhostPose({ + position: wallLocalToWorld( target.wallNode, target.clampedX, target.clampedY, getLevelYOffset(), getSlabElevation(target.event), ), - target.cursorRotation, - target.valid, - ) + rotationY: target.itemRotation - wallAngle, + tint: placement.tint, + floorY: getLevelYOffset() + getSlabElevation(target.event), + side: target.side, + }) + + publishOpeningGuidesForWallEvent({ + wall: target.wallNode, + movingId: movingWindowNode.id, + centerS: target.clampedX, + centerY: target.clampedY, + width: movingWindowNode.width, + height: movingWindowNode.height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevation(target.event), + }) } const onWallEnter = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveMoveTarget(event) if (!target) { onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) @@ -299,6 +438,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onWallMove = (event: WallEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 if (!isValidWallSideFace(event.normal)) { onWallLeave() return @@ -318,21 +458,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode onWallLeave() return } + freeFollowing = false lastTarget = target lastRoofEvent = null applyPreview(target) event.stopPropagation() } - const onWallClick = (event: WallEvent) => { + // Promote the moving window into its committed wall placement. Shared by + // the direct wall-mesh click and the floor proximity click. + const commitToWall = (target: NonNullable) => { if (committed) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return - - const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) - if (!target?.valid) return committed = true let placedId: string @@ -356,6 +492,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode parentId: target.wallId, roofSegmentId: undefined, roofFace: undefined, + // Hidden during free-follow; the committed window must be visible. + visible: true, }) useScene.getState().createNode(node, target.wallId as AnyNodeId) placedId = node.id @@ -371,6 +509,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -382,6 +521,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: target.wallId, roofSegmentId: undefined, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== target.wallId) { @@ -398,31 +538,109 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode hideCursor() useViewer.getState().setSelection({ selectedIds: [placedId] }) exitMoveMode() + } + + const onWallClick = (event: WallEvent) => { + if (committed) return + if (!isValidWallSideFace(event.normal)) return + if (isCurvedWall(event.node)) return + // Only interact with walls on the current level + if (event.node.parentId !== getLevelId()) return + + const target = lastTarget?.wallId === event.node.id ? lastTarget : resolveMoveTarget(event) + // Shift force-places: commit even when the window overlaps another opening. + // The preview keeps its red invalid tint as a warning; Shift just lifts the + // commit block. Read shift from THIS event so it's never stale at commit. + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return + commitToWall(target) event.stopPropagation() } const onWallLeave = () => { + // The cursor left the wall mesh. Don't snap back to the origin/original + // here — the floor proximity handler (onGridMove) takes over on the same + // pointermove: it snaps to a nearby wall or free-follows the cursor, so + // the window never blinks back to the building origin between a wall and + // open floor. Revert is left to free-follow / cancel / commit. hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return // No original to restore for duplicates - // Move mode: restore to original position while off-wall - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) + } + + // Reveal the real window node + drop the ghost. Used by the roof-face path, + // which previews with the real mesh (the ghost-tint flow is wall-specific). + const revealRealNode = () => { + setGhostPose(null) + const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as + | WindowNode + | undefined + if (live && live.visible === false) { + useScene.getState().updateNode(movingWindowNode.id, { visible: true }) } - currentHostId = original.parentId - useScene.getState().updateNode(movingWindowNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, + } + + // Free-follow: over open floor there's no wall to host the window, so hide + // the real (pale, near-invisible-on-grid) node and float a red translucent + // ghost at the cursor — same treatment the raw `WindowTool` build path uses. + const freeFollowAt = (localX: number, localZ: number, frame: number) => { + freeFollowing = true + lastTarget = null + lastRoofEvent = null + // Click per grid cell as the ghost slides over open floor (X+Z) — the + // same `tickGridStep` the on-wall slide uses, so both feel identical. + tickGridStep(frame, localX, localZ) + hideCursor() + useLiveTransforms.getState().clear(movingWindowNode.id) + const levelId = getLevelId() + const sillCenterY = getSillCenterY() + // Keep the R-flip visible while free-following (back = rotated π). + const yaw = sideOverride === 'back' ? Math.PI : 0 + if (currentHostId !== levelId) { + if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + visible: false, + }) + currentHostId = levelId + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + visible: false, + }) + } + // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to + // the sill center (sideOverride carries the R-flip so the ghost matches). + setGhostPose({ + position: [localX, getLevelYOffset() + sillCenterY, localZ], + rotationY: yaw, + tint: 'invalid', + floorY: getLevelYOffset(), + side: sideOverride, }) - if (original.parentId) markHostDirty(original.parentId) + } + + const onGridMove = (event: GridEvent) => { + if (committed) return + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler owns this exact pointermove (shared DOM + // timeStamp): the cursor ray is on a wall/roof, so it snaps. Otherwise + // the cursor is over open floor — free-follow it. No proximity magnet: + // snapping engages only when the cursor ray actually hovers a wall. + if (event.nativeEvent?.timeStamp === lastMeshEventTime) return + const [x, , z] = event.localPosition + lastFloorPoint = [x, z] + freeFollowAt(x, z, event.nativeEvent?.timeStamp ?? -1) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -449,16 +667,22 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onRoofHover = (event: RoofEvent) => { + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofMoveTarget(event) if (!target) { onRoofLeave() return } // Wall-frame drag anchor / live transform don't apply on a roof face. + freeFollowing = false dragAnchor = null lastTarget = null lastRoofEvent = event useLiveTransforms.getState().clear(movingWindowNode.id) + // Opening guides are wall-specific; clear them when over a roof face. + clearOpeningGuides3D() + // On a roof face the real mesh is the preview — drop the ghost + reveal. + revealRealNode() if (currentHostId !== target.segment.id) { useScene.getState().updateNode(movingWindowNode.id, { position: target.position, @@ -468,6 +692,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode wallId: undefined, roofSegmentId: target.segment.id, roofFace: target.face.id, + visible: true, }) markHostDirty(currentHostId) currentHostId = target.segment.id @@ -485,7 +710,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onRoofClick = (event: RoofEvent) => { if (committed) return const target = resolveRoofMoveTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target too (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return committed = true const segmentId = target.segment.id @@ -508,6 +735,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: segmentId, roofFace: target.face.id, parentId: segmentId, + visible: true, }) useScene.getState().createNode(node, segmentId as AnyNodeId) placedId = node.id @@ -521,6 +749,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) useScene.temporal.getState().resume() @@ -533,6 +762,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: segmentId, roofFace: target.face.id, metadata: {}, + visible: true, }) if (original.parentId && original.parentId !== segmentId) { @@ -553,26 +783,13 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode } const onRoofLeave = () => { + // Mirror onWallLeave: don't revert to origin here — onGridMove takes + // over on the same pointermove (snap to a nearby wall or free-follow). hideCursor() useLiveTransforms.getState().clear(movingWindowNode.id) dragAnchor = null lastTarget = null lastRoofEvent = null - if (isNew) return - if (currentHostId && currentHostId !== original.parentId) { - markHostDirty(currentHostId) - } - currentHostId = original.parentId - useScene.getState().updateNode(movingWindowNode.id, { - position: original.position, - rotation: original.rotation, - side: original.side, - parentId: original.parentId, - wallId: original.wallId, - roofSegmentId: original.roofSegmentId, - roofFace: original.roofFace, - }) - if (original.parentId) markHostDirty(original.parentId) } const onCancel = () => { @@ -590,6 +807,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } @@ -600,13 +818,68 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const onPlacementDragPointerUp = (event: PointerEvent) => { if (!consumePlacementDragRelease(event)) return - if (lastTarget) { - onWallClick(lastTarget.event) + // Free-following over open floor can't commit (no wall). A wall hover + // target commits via commitToWall; a roof face via onRoofClick. Shift + // force-places over a colliding wall target (tint stays red as a warning); + // read shift from this pointerup so it's current at commit. + if (lastTarget && !freeFollowing && (lastTarget.valid || event.shiftKey)) { + commitToWall(lastTarget) return } if (lastRoofEvent) onRoofClick(lastRoofEvent) } + // R flips the window's facing side mid-placement (front ↔ back), like the + // committed-selected R flip — usable before commit, whether snapped to a + // wall or free-following. No-op on a roof-segment face (front-only host). + const onKeyDown = (e: KeyboardEvent) => { + if (committed) return + if (e.key !== 'r' && e.key !== 'R') return + const target = e.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + ) { + return + } + // Ignore OS key-repeat so a held R doesn't flip many times per press. + if (e.repeat) return + e.preventDefault() + // ALWAYS toggle the persistent flip intent — never a no-op (the old gate + // dropped R before the first pointermove). Then re-render the current + // preview so the flip shows live and matches commit. See `MoveDoorTool`. + sideOverride = sideOverride === 'front' ? 'back' : 'front' + triggerSFX('sfx:item-rotate') + if (lastTarget) { + const next = resolveMoveTarget(lastTarget.event) + if (next) { + lastTarget = next + applyPreview(next) + } + } else if (lastFloorPoint) { + // Free-following: re-run at the same spot so the floating ghost rebuilds + // with the flipped side. + freeFollowAt(lastFloorPoint[0], lastFloorPoint[1], -1) + } else { + // No preview yet (R before the first pointermove): flip the hidden node + // so the first preview/commit already reflects the chosen side. + useScene.getState().updateNode(movingWindowNode.id, { + side: sideOverride, + rotation: [0, sideOverride === 'back' ? Math.PI : 0, 0], + }) + } + } + + // Shift toggles force-place — re-run the on-wall preview so the tint flips + // green↔red live (pointer stationary). Commit gates still read shift fresh. + const onShiftToggle = (e: KeyboardEvent) => { + if (e.key !== 'Shift') return + const held = e.type === 'keydown' + if (held === shiftHeld) return + shiftHeld = held + if (!committed && lastTarget) applyPreview(lastTarget) + } + emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -615,8 +888,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) + emitter.on('grid:move', onGridMove) emitter.on('tool:cancel', onCancel) window.addEventListener('pointerup', onPlacementDragPointerUp) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keydown', onShiftToggle) + window.addEventListener('keyup', onShiftToggle) return () => { // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) @@ -638,12 +915,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode roofSegmentId: original.roofSegmentId, roofFace: original.roofFace, metadata: original.metadata, + visible: original.visible, }) if (original.parentId) markHostDirty(original.parentId) } + } else if (current && current.visible === false) { + // Safety net: a fresh (isNew) clone isn't marked `isTransient`; if we + // unmount mid-free-follow it would be left hidden. Reveal it so it never + // becomes an invisible orphan (place-preset deletes a true cancel). + useScene.getState().updateNode(movingWindowNode.id, { visible: true }) } useLiveTransforms.getState().clear(movingWindowNode.id) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) @@ -653,8 +937,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) + emitter.off('grid:move', onGridMove) emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keydown', onShiftToggle) + window.removeEventListener('keyup', onShiftToggle) } }, [movingWindowNode, exitMoveMode]) @@ -668,11 +956,38 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode boxGeo.dispose() return geo }, [movingWindowNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {/* Placement ghost shown for the whole move (the real pale node stays + hidden): red off-wall / colliding, green on a valid wall. */} + {ghostPose && ( + + + + )} + {/* Floor "shadow" projection: footprint + dashed drop-line, so an elevated + window's plan position is legible while placing. World-space, so it's a + sibling of the ghost group, not a child. */} + {ghostPose && ( + + )} + ) } diff --git a/packages/nodes/src/window/panel.tsx b/packages/nodes/src/window/panel.tsx index b1bd3b8f..a1abde0f 100644 --- a/packages/nodes/src/window/panel.tsx +++ b/packages/nodes/src/window/panel.tsx @@ -383,7 +383,7 @@ export default function WindowPanel() { return ( { + const mesh = useMemo(() => { + const m = buildWindowPreviewMesh(node) + m.layers.set(EDITOR_LAYER) + return m + }, [ + node.width, + node.height, + node.frameDepth, + node.openingShape, + node.windowType, + node.sill, + node.sillDepth, + node.sillThickness, + ]) + + // Ghost treatment (clone + tint + raycast-off) re-applies if the tint flips; + // its cleanup only disposes the clones it made. + useEffect(() => applyGhost(mesh, { invalid, valid }), [mesh, invalid, valid]) + + // Geometry is freshly built per `mesh` and owned here — dispose it only + // when the mesh itself is replaced/unmounted, never on an `invalid` toggle. + useEffect( + () => () => { + mesh.traverse((obj) => { + const m = obj as { geometry?: { dispose: () => void } } + m.geometry?.dispose() + }) + }, + [mesh], + ) + + return +} + +export default WindowPreview diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 26552ebc..c0b48113 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -10,6 +10,7 @@ import { spatialGridManager, useScene, type WallEvent, + type WallNode, WindowNode, } from '@pascal-app/core' import { @@ -23,9 +24,14 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' +import { + clearOpeningGuides3D, + publishOpeningGuidesForWallEvent, + resolveSillSnap, +} from '../shared/opening-guides-runtime' import { getRoofWallOpeningCursorPose, type RoofWallOpeningTarget, @@ -33,7 +39,14 @@ import { worldToSelectedBuildingLocal, } from '../shared/roof-wall-opening-placement' import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment' -import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' +import { WindowFloorProjection } from './floor-projection' +import WindowPreview from './preview' +import { + clampToWall, + DEFAULT_WINDOW_SILL_M, + hasWallChildOverlap, + wallLocalToWorld, +} from './window-math' // Shared edge material — reuse across renders, just toggle color const edgeMaterial = new LineBasicNodeMaterial({ @@ -45,34 +58,81 @@ const edgeMaterial = new LineBasicNodeMaterial({ const FALLBACK_WIDTH = 1.5 const FALLBACK_HEIGHT = 1.5 -const FALLBACK_SILL_LIFT = 0.45 +// Off-wall ghost lift = the default sill, so the floating preview matches the +// sill the floor-cursor placement commits at. +const FALLBACK_SILL_LIFT = DEFAULT_WINDOW_SILL_M +// Default sill centre for a window snapped from the floor (the floor cursor +// carries no wall-face height): the default sill + half the default height. +const DEFAULT_SILL_CENTER_Y = DEFAULT_WINDOW_SILL_M + FALLBACK_HEIGHT / 2 const roofFallbackPoint = new Vector3() +// What currently owns the cursor frame: a wall/roof mesh hover, or null when +// the cursor is over open floor (the grid handler then free-follows). +type HostKind = 'wall' | 'roof' | null + /** * Window tool — places WindowNodes on walls and on roof-segment wall * faces (the generated base walls under a roof, including coplanar gable * ends — a window can sit in the gable pediment). - * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. + * + * The ghost follows the cursor everywhere (like moving an item): over open + * floor it floats as an invalid (unplaceable) ghost; the moment the cursor ray + * hovers a wall (or roof-segment face) the real draft snaps onto it. Snapping + * engages only on an actual mesh hover — no proximity magnet. */ const WindowTool: React.FC = () => { const draftRef = useRef(null) const cursorGroupRef = useRef(null!) const edgesRef = useRef(null!) + // Off-host floating ghost: the real window geometry follows the cursor + // over the grid (tinted invalid). Mutually exclusive with the on-host draft. + // `floorY` feeds the floor "shadow" projection; `side` carries the R-flip so + // the floating ghost faces the side that will be committed. + const [fallbackPose, setFallbackPose] = useState<{ + position: [number, number, number] + rotationY: number + floorY: number + side: WindowNode['side'] + } | null>(null) + + // Ghost preview node — zeroed transform + the live facing side (rebuilds on R). + const ghostStub = useMemo( + () => + WindowNode.parse({ + position: [0, 0, 0], + rotation: [0, 0, 0], + side: fallbackPose?.side ?? 'front', + }), + [fallbackPose?.side], + ) + useEffect(() => { useScene.temporal.getState().pause() + let hostKind: HostKind = null + // timeStamp of the most recent wall/roof mesh event. A wall/roof hover and + // the grid raycast from the SAME pointermove share the source DOM event's + // timeStamp, so the grid handler can detect "a mesh handler already owns + // this frame" without depending on event order or on a leave firing (node + // events are suppressed during a camera drag, so a sticky boolean would + // strand the draft after an orbit; a per-frame timestamp self-heals). + let lastMeshEventTime = -1 + // R flips the window's facing side mid-placement (front ↔ back); re-applied + // to the last wall hover so the flip shows live before commit. + let sideFlip = false + let lastWallEvent: WallEvent | null = null + // Last open-floor cursor point (level-local X/Z) + floor Y, so an R-flip + // while free-following can re-render the floating ghost with the new facing. + let lastFloorPoint: { pos: [number, number, number]; floorY: number } | null = null + const getLevelId = () => useViewer.getState().selection.levelId const getLevelYOffset = () => { const id = getLevelId() return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 } - const getSlabElevation = (wallEvent: WallEvent) => - spatialGridManager.getSlabElevationForWall( - wallEvent.node.parentId ?? '', - wallEvent.node.start, - wallEvent.node.end, - ) + const getSlabElevationForWall = (wall: WallNode) => + spatialGridManager.getSlabElevationForWall(wall.parentId ?? '', wall.start, wall.end) const markHostDirty = (hostId: string) => { useScene.getState().dirtyNodes.add(hostId as AnyNodeId) @@ -90,6 +150,8 @@ const WindowTool: React.FC = () => { const hideCursor = () => { if (cursorGroupRef.current) cursorGroupRef.current.visible = false useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + setFallbackPose(null) } // Alignment candidates — anchors of every alignable object; refreshed @@ -97,11 +159,15 @@ const WindowTool: React.FC = () => { // (along-wall only; the floor-plane guides don't cover sill height). let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') + // On-host cursor: the green/red wireframe outline tracks a live draft. + // Showing it always clears the off-host floating ghost (they never + // coexist — a draft means the cursor is on a valid host). const updateCursor = ( worldPosition: [number, number, number], cursorRotationY: number, valid: boolean, ) => { + setFallbackPose(null) const group = cursorGroupRef.current if (!group) return group.visible = true @@ -110,276 +176,231 @@ const WindowTool: React.FC = () => { edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44) } - const showFallbackCursor = (event: GridEvent) => { - if (draftRef.current) return - const [x, y, z] = event.localPosition - updateCursor([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) + // Off-host fallback: hide the wireframe outline and float the real window + // geometry (tinted invalid) at the cursor so the armed tool is visible. + const showGhostAt = (position: [number, number, number], floorY: number) => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + lastFloorPoint = { pos: position, floorY } + // `sideFlip` (R) flips the facing — back is a π yaw on the floating ghost. + setFallbackPose({ + position, + rotationY: sideFlip ? Math.PI : 0, + floorY, + side: sideFlip ? 'back' : 'front', + }) useAlignmentGuides.getState().clear() + clearOpeningGuides3D() } const showRoofFallbackCursor = (event: RoofEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) - useAlignmentGuides.getState().clear() + showGhostAt( + [x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], + getLevelYOffset(), + ) } const showWallFallbackCursor = (event: WallEvent) => { const [x, , z] = worldToSelectedBuildingLocal(roofFallbackPoint.set(...event.position)) - updateCursor([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], 0, false) - useAlignmentGuides.getState().clear() - } - - const onWallEnter = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - const levelId = getLevelId() - if (!levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - // Only interact with walls on the current level - if (event.node.parentId !== levelId) { - destroyDraft() - showWallFallbackCursor(event) - return - } - - destroyDraft() - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const width = 1.5 - const height = 1.5 - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) - - const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) - - const node = WindowNode.parse({ - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, - metadata: { isTransient: true }, - }) - - useScene.getState().createNode(node, event.node.id as AnyNodeId) - draftRef.current = node - - const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) - - updateCursor( - wallLocalToWorld( - event.node, - clampedX, - clampedY, - getLevelYOffset(), - getSlabElevation(event), - ), - cursorRotation, - valid, + showGhostAt( + [x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], + getLevelYOffset(), ) - event.stopPropagation() } - const onWallMove = (event: WallEvent) => { - if (!isValidWallSideFace(event.normal)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - if (isCurvedWall(event.node)) { - destroyDraft() - showWallFallbackCursor(event) - return - } - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) { - destroyDraft() - showWallFallbackCursor(event) - return - } + // Sill alignment (snap + guide): a sibling sill/centre/top wins over the + // 0.5m grid when within threshold; Shift bypasses both. `movingId` is the + // draft's id once it exists (so it's excluded from the sibling scan), or '' + // before the draft is created (nothing to exclude yet). + const resolvePlacementY = (args: { + wall: WallNode + movingId: string + localX: number + rawLocalY: number + width: number + height: number + bypassSnap: boolean + }): number => { + if (args.bypassSnap) return args.rawLocalY + const sillY = resolveSillSnap({ + wall: args.wall, + movingId: args.movingId, + localX: args.localX, + localY: args.rawLocalY, + width: args.width, + height: args.height, + nodes: useScene.getState().nodes, + }) + return sillY ?? snapToHalf(args.rawLocalY) + } - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - const width = draftRef.current?.width ?? 1.5 - const height = draftRef.current?.height ?? 1.5 + // Settle a wall target: alignment snap → sill clamp → overlap check. + const resolveWallPlacement = ( + wall: WallNode, + rawLocalX: number, + rawLocalY: number, + width: number, + height: number, + bypass: boolean, + bypassSnap: boolean, + ignoreId?: string, + ) => { + // bypassSnap is set by Shift (see callers). Shift = free-place: land at the + // raw cursor but keep the along-wall guides visible. bypass (Alt) still + // hard-disables alignment. const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], + wallNode: wall, + rawLocalX, width, candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, + bypass: bypass && !bypassSnap, + freePlace: bypassSnap, }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) - - const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height) - - // Draft may be null after a successful placement (the click handler - // deletes it and relies on the wall rebuild → pointer-enter cascade to - // recreate it). Recreate it here on the first subsequent move so the - // preview is ready for the next click without requiring a leave/enter. - if (!draftRef.current) { - const levelId = getLevelId() - if (levelId && event.node.parentId === levelId) { - const node = WindowNode.parse({ - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - wallId: event.node.id, - parentId: event.node.id, - metadata: { isTransient: true }, - }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) - draftRef.current = node - } - } - - if (draftRef.current) { - // Update the scene store on every move so the 2D floor plan - // stays in sync (it re-renders from `node.position`). Only - // forward `parentId` / `wallId` when the wall actually changed - // — otherwise the reparent path churns the host wall's - // `children` array every tick, which re-renders the wall and - // briefly draws its 0-vertex placeholder geometry (WebGPU then - // flags "Vertex buffer slot 0 ... was not set"). - const isSameWall = event.node.id === draftRef.current.parentId - if (isSameWall) { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - }) - markHostDirty(event.node.id) - } else { - useScene.getState().updateNode(draftRef.current.id, { - position: [clampedX, clampedY, 0], - rotation: [0, itemRotation, 0], - side, - parentId: event.node.id, - wallId: event.node.id, - // The draft may arrive from a roof-segment face hover. - roofSegmentId: undefined, - roofFace: undefined, - }) - } - } - - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, + const localY = resolvePlacementY({ + wall, + movingId: ignoreId ?? '', + localX, + rawLocalY, width, height, - draftRef.current?.id, + bypassSnap, + }) + const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height) + const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId) + return { clampedX, clampedY, valid } + } + + // Shared create/update path for the wall draft — used by the direct + // wall-mesh hover and the floor proximity snap. Reuses the existing draft + // (reparenting only on an actual wall change to avoid churning the host's + // children array, which flashes 0-vertex wall geometry in WebGPU). + const applyWallTarget = (args: { + wall: WallNode + rawLocalX: number + rawLocalY: number + side: 'front' | 'back' + itemRotation: number + cursorRotationY: number + bypass: boolean + bypassSnap: boolean + }) => { + const { + wall, + rawLocalX, + rawLocalY, + side, + itemRotation, + cursorRotationY, + bypass, + bypassSnap, + } = args + const width = draftRef.current?.width ?? 1.5 + const height = draftRef.current?.height ?? 1.5 + + if (!draftRef.current) { + const node = WindowNode.parse({ + position: [0, DEFAULT_SILL_CENTER_Y, 0], + rotation: [0, itemRotation, 0], + side, + wallId: wall.id, + parentId: wall.id, + metadata: { isTransient: true }, + }) + useScene.getState().createNode(node, wall.id as AnyNodeId) + draftRef.current = node + } + + const { clampedX, clampedY, valid } = resolveWallPlacement( + wall, + rawLocalX, + rawLocalY, + width, + height, + bypass, + bypassSnap, + draftRef.current.id, ) + if (wall.id === draftRef.current.parentId) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + }) + markHostDirty(wall.id) + } else { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: wall.id, + wallId: wall.id, + // The draft may arrive from a roof-segment face hover. + roofSegmentId: undefined, + roofFace: undefined, + }) + } + updateCursor( wallLocalToWorld( - event.node, + wall, clampedX, clampedY, getLevelYOffset(), - getSlabElevation(event), + getSlabElevationForWall(wall), ), - cursorRotation, + cursorRotationY, valid, ) - event.stopPropagation() + + if (draftRef.current) { + publishOpeningGuidesForWallEvent({ + wall, + movingId: draftRef.current.id, + centerS: clampedX, + centerY: clampedY, + width, + height, + includeVertical: true, + levelYOffset: getLevelYOffset(), + slabElevation: getSlabElevationForWall(wall), + }) + } + return { clampedX, clampedY, valid } } - const onWallClick = (event: WallEvent) => { - if (!draftRef.current) return - if (!isValidWallSideFace(event.normal)) return - if (isCurvedWall(event.node)) return - // Only interact with walls on the current level - if (event.node.parentId !== getLevelId()) return - - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - - const localX = resolveWallSlideAlignment({ - wallNode: event.node, - rawLocalX: event.localPosition[0], - width: draftRef.current.width, - candidates: alignmentCandidates, - bypass: event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true, - bypassSnap: event.nativeEvent?.shiftKey === true, - }) - const localY = - event.nativeEvent?.shiftKey === true - ? event.localPosition[1] - : snapToHalf(event.localPosition[1]) - const { clampedX, clampedY } = clampToWall( - event.node, - localX, - localY, - draftRef.current.width, - draftRef.current.height, - ) - const valid = !hasWallChildOverlap( - event.node.id, - clampedX, - clampedY, - draftRef.current.width, - draftRef.current.height, - draftRef.current.id, - ) - if (!valid) return - + // Promote the draft into a permanent window. Shared by the wall-mesh click + // and the floor proximity click. + const commitWindowAtWall = ( + wall: WallNode, + clampedX: number, + clampedY: number, + side: 'front' | 'back', + itemRotation: number, + ) => { const draft = draftRef.current + if (!draft) return draftRef.current = null + hostKind = null - // Delete transient draft (paused, invisible to undo) useScene.getState().deleteNode(draft.id) - - // Resume → create permanent node (single undoable action) useScene.temporal.getState().resume() const levelId = getLevelId() const state = useScene.getState() const windowCount = Object.values(state.nodes).filter((n) => { if (n.type !== 'window') return false - const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined - return wall?.parentId === levelId + const w = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined + return w?.parentId === levelId }).length - const name = `Window ${windowCount + 1}` const node = WindowNode.parse({ - name, + name: `Window ${windowCount + 1}`, position: [clampedX, clampedY, 0], rotation: [0, itemRotation, 0], side, - wallId: event.node.id, - parentId: event.node.id, + wallId: wall.id, + parentId: wall.id, width: draft.width, height: draft.height, windowType: draft.windowType, @@ -398,19 +419,111 @@ const WindowTool: React.FC = () => { sillThickness: draft.sillThickness, }) - useScene.getState().createNode(node, event.node.id as AnyNodeId) + useScene.getState().createNode(node, wall.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [node.id] }) useScene.temporal.getState().pause() triggerSFX('sfx:structure-build') alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '') useAlignmentGuides.getState().clear() + clearOpeningGuides3D() + } + // ── Direct wall-mesh hover ────────────────────────────────────── + const onWallHover = (event: WallEvent) => { + hostKind = 'wall' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + destroyDraft() + showWallFallbackCursor(event) + return + } + lastWallEvent = event + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const flipOffset = sideFlip ? Math.PI : 0 + const itemRotation = calculateItemRotation(event.normal) + flipOffset + const cursorRotation = + calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + applyWallTarget({ + wall: event.node, + rawLocalX: event.localPosition[0], + rawLocalY: event.localPosition[1], + side, + itemRotation, + cursorRotationY: cursorRotation, + bypass, + bypassSnap, + }) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if ( + !isValidWallSideFace(event.normal) || + isCurvedWall(event.node) || + event.node.parentId !== getLevelId() + ) { + return + } + + const faceSide = getSideFromNormal(event.normal) + const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide + const itemRotation = calculateItemRotation(event.normal) + (sideFlip ? Math.PI : 0) + const bypassSnap = event.nativeEvent?.shiftKey === true + const bypass = event.nativeEvent?.altKey === true || bypassSnap + + const { clampedX, clampedY, valid } = resolveWallPlacement( + event.node, + event.localPosition[0], + event.localPosition[1], + draftRef.current.width, + draftRef.current.height, + bypass, + bypassSnap, + draftRef.current.id, + ) + // Shift force-places over a collision (the draft stays red as a warning). + if (!valid && !bypassSnap) return + + commitWindowAtWall(event.node, clampedX, clampedY, side, itemRotation) event.stopPropagation() } const onWallLeave = () => { + if (hostKind !== 'wall') return + lastWallEvent = null destroyDraft() hideCursor() + hostKind = null + } + + // ── Floor free-follow ─────────────────────────────────────────── + // Over open floor the ghost follows the cursor like a moving item. It does + // NOT snap from proximity — snapping engages only when the cursor ray + // actually hovers a wall (onWallHover) or roof face (onRoofHover). + const onGridFreeFollow = (event: GridEvent) => { + if (useViewer.getState().cameraDragging) return + // A wall/roof mesh handler processed this exact pointermove (R3F + the + // grid raycast share the source DOM event's timeStamp) — it owns the + // frame and has snapped the draft, so skip the floor follow this tick. + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastMeshEventTime) return + // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft + // and free-follow the cursor with the invalid (unplaceable) ghost. + hostKind = null + lastWallEvent = null + const [x, y, z] = event.localPosition + destroyDraft() + showGhostAt([x, y + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z], y) } // ── Roof-segment wall faces ───────────────────────────────────── @@ -437,6 +550,8 @@ const WindowTool: React.FC = () => { } const onRoofHover = (event: RoofEvent) => { + hostKind = 'roof' + lastMeshEventTime = event.nativeEvent?.timeStamp ?? -1 const target = resolveRoofTarget(event) if (!target) { // On the roof but not over a placeable wall face (slope, soffit, @@ -467,6 +582,8 @@ const WindowTool: React.FC = () => { useScene.getState().createNode(node, segment.id as AnyNodeId) draftRef.current = node } + // Opening guides are wall-specific; clear them while over a roof face. + clearOpeningGuides3D() updateRoofCursor(target, event.node as RoofNode) event.stopPropagation() } @@ -474,11 +591,14 @@ const WindowTool: React.FC = () => { const onRoofClick = (event: RoofEvent) => { if (!draftRef.current?.roofSegmentId) return const target = resolveRoofTarget(event) - if (!target?.valid) return + // Shift force-places over a colliding roof-face target (see onWallClick). + if (!target) return + if (!target.valid && event.nativeEvent?.shiftKey !== true) return const { segment, face, position } = target const draft = draftRef.current draftRef.current = null + hostKind = null useScene.getState().deleteNode(draft.id) useScene.temporal.getState().resume() @@ -525,59 +645,109 @@ const WindowTool: React.FC = () => { } const onRoofLeave = () => { - if (!draftRef.current?.roofSegmentId) return + if (hostKind !== 'roof') return destroyDraft() hideCursor() + hostKind = null } const onCancel = () => { destroyDraft() hideCursor() + hostKind = null } - emitter.on('wall:enter', onWallEnter) - emitter.on('wall:move', onWallMove) + // R flips the window's facing side mid-placement (front ↔ back). ALWAYS + // toggles the persistent flip intent — never a no-op (the old `!lastWallEvent` + // guard dropped R off-wall / before the first hover). Then re-renders the + // current preview so the flip shows live and matches commit. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'r' && e.key !== 'R') return + if (e.repeat) return + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return + e.preventDefault() + sideFlip = !sideFlip + triggerSFX('sfx:item-rotate') + if (lastWallEvent) { + onWallHover(lastWallEvent) + } else if (lastFloorPoint) { + showGhostAt(lastFloorPoint.pos, lastFloorPoint.floorY) + } + // else: no preview yet — `sideFlip` is set, so the first hover/follow uses it. + } + + emitter.on('wall:enter', onWallHover) + emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) emitter.on('roof:click', onRoofClick) emitter.on('roof:leave', onRoofLeave) - emitter.on('grid:move', showFallbackCursor) + emitter.on('grid:move', onGridFreeFollow) emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) return () => { destroyDraft() hideCursor() useAlignmentGuides.getState().clear() + clearOpeningGuides3D() useScene.temporal.getState().resume() - emitter.off('wall:enter', onWallEnter) - emitter.off('wall:move', onWallMove) + emitter.off('wall:enter', onWallHover) + emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) emitter.off('roof:click', onRoofClick) emitter.off('roof:leave', onRoofLeave) - emitter.off('grid:move', showFallbackCursor) + emitter.off('grid:move', onGridFreeFollow) emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) } }, []) - // Cursor geometry: window outline rectangle. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: window outline rectangle. Static dims, so build it once and + // dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry + // on every re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( - - - + <> + + + + {fallbackPose && ( + + + + )} + {/* Floor "shadow" projection for the off-host ghost (drop-line + footprint) + so the elevated window's plan position is legible while placing. */} + {fallbackPose && ( + + )} + ) } diff --git a/packages/nodes/src/window/window-math.ts b/packages/nodes/src/window/window-math.ts index 182611a2..eb3547df 100644 --- a/packages/nodes/src/window/window-math.ts +++ b/packages/nodes/src/window/window-math.ts @@ -1,12 +1,13 @@ -import { - type AnyNodeId, - type DoorNode, - getScaledDimensions, - type ItemNode, - useScene, - type WallNode, - type WindowNode, -} from '@pascal-app/core' +import type { WallNode } from '@pascal-app/core' + +/** + * Default sill height (metres from the floor to the BOTTOM of a window) for a + * fresh window that has no wall-face height yet — the off-wall ghost and the + * floor-cursor placement use it so a new window floats slightly above the + * ground rather than sitting on it. The committed Y is the window's CENTRE, so + * callers add `height / 2`. An existing window keeps its own sill. + */ +export const DEFAULT_WINDOW_SILL_M = 0.5 /** * Converts wall-local (X along wall, Y = height above wall base) to world XYZ. @@ -54,64 +55,8 @@ export function clampToWall( } /** - * Directly checks the wall's children for bounding-box overlap with a proposed window. - * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). - * The spatial grid only tracks `item` nodes, so windows must be checked this way. - * Reads the wall's latest children from the store (not the event node) to avoid stale data. + * Wall-child overlap is shared by door + window placement (one source of + * truth in `shared/wall-attach-target.ts`). Re-exported here so existing + * `./window-math` importers don't change. */ -export function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true // Block if wall not found - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = getScaledDimensions(item) - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] // items store bottom Y - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 // windows store center Y - childTop = win.position[1] + win.height / 2 - } else if (child.type === 'door') { - const door = child as DoorNode - childLeft = door.position[0] - door.width / 2 - childRight = door.position[0] + door.width / 2 - childBottom = door.position[1] - door.height / 2 // doors store center Y - childTop = door.position[1] + door.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} +export { hasWallChildOverlap } from '../shared/wall-attach-target' diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts index afbdeac7..7aeb8ec2 100644 --- a/packages/nodes/src/zone/definition.ts +++ b/packages/nodes/src/zone/definition.ts @@ -66,7 +66,7 @@ export const zoneDefinition: NodeDefinition = { presentation: { label: 'Zone', description: 'A polygonal site zone (lawn, water, paving) with a TSL gradient material.', - icon: { kind: 'url', src: '/icons/zone.png' }, + icon: { kind: 'url', src: '/icons/zone.webp' }, paletteSection: 'site', paletteOrder: 20, }, diff --git a/packages/viewer/src/components/renderers/parametric-node-renderer.tsx b/packages/viewer/src/components/renderers/parametric-node-renderer.tsx index 4cd6f23f..65984c23 100644 --- a/packages/viewer/src/components/renderers/parametric-node-renderer.tsx +++ b/packages/viewer/src/components/renderers/parametric-node-renderer.tsx @@ -70,12 +70,16 @@ export const ParametricNodeRenderer = ({ node }: { node: AnyNode }) => { const position = liveTransform?.position ?? overridePosition ?? n.position ?? [0, 0, 0] const rawRotation = overrideRotation ?? n.rotation + const baseRotation: [number, number, number] = + typeof rawRotation === 'number' ? [0, rawRotation, 0] : (rawRotation ?? [0, 0, 0]) + // The live transform carries only the plan-view Y rotation; keep the + // node's own X/Z so 3D-oriented kinds (e.g. a duct-fitting riser at + // X=π/2) don't visually flatten to horizontal mid-drag. Matches the + // move tool's commit, which also replaces only the Y component. const rotation: [number, number, number] = liveTransform?.rotation !== undefined - ? [0, liveTransform.rotation, 0] - : typeof rawRotation === 'number' - ? [0, rawRotation, 0] - : (rawRotation ?? [0, 0, 0]) + ? [baseRotation[0], liveTransform.rotation, baseRotation[2]] + : baseRotation return ( (function Viewer( perf = false, useBvh = true, renderContext = 'editor', + transparent, defaultRender, isolate, sceneReadyKey, @@ -348,6 +350,16 @@ const Viewer = forwardRef(function Viewer( }, [isolate]) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const transparentBackground = useViewer((state) => state.transparentBackground) + useLayoutEffect(() => { + if (transparent === undefined) return + + useViewer.getState().setTransparentBackground(transparent) + return () => { + useViewer.getState().setTransparentBackground(false) + } + }, [transparent]) + const defaultShading = defaultRender?.shading const defaultTextures = defaultRender?.textures const defaultColorPreset = defaultRender?.colorPreset @@ -392,7 +404,9 @@ const Viewer = forwardRef(function Viewer( return ( (function Viewer( if (cached) return cached const promise = (async () => { try { - const renderer = new THREE.WebGPURenderer(props as any) + const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true }) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = getSceneTheme( useViewer.getState().sceneTheme, diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 301ccec4..68b4041b 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -15,6 +15,8 @@ import { oscSine, output, pass, + premultiplyAlpha, + renderOutput, sample, time, uniform, @@ -180,6 +182,8 @@ const PostProcessingPasses = ({ const projectId = useViewer((s) => s.projectId) const shading = useViewer((s) => s.shading) const edges = useViewer((s) => s.edges) + const inkOpacityOverride = useViewer((s) => s.inkOpacity) + const transparentBackground = useViewer((s) => s.transparentBackground) const lastProjectIdRef = useRef(projectId) // Bump this to force a pipeline rebuild (used by retry logic) @@ -272,7 +276,7 @@ const PostProcessingPasses = ({ // Same 1px line thickness for both (soft's thickness is the nice one); // strong reads heavier purely by being fully solid vs soft's lighter 50%. const inkRadius = 1 - const inkOpacity = edges === 'strong' ? 1 : 0.5 + const inkOpacity = inkOpacityOverride ?? (edges === 'strong' ? 1 : 0.5) console.log('[viewer/post-processing] Building pipeline', { version: pipelineVersion, @@ -282,6 +286,7 @@ const PostProcessingPasses = ({ perfDisable, projectId, shading, + transparentBackground, rendererCtor: (renderer as any).constructor?.name, width, height, @@ -419,6 +424,7 @@ const PostProcessingPasses = ({ // Single merged outline node: one shared depth pass for both selected + hovered groups. const outliner = useViewer.getState().outliner let compositeWithOutlines = sceneColor + let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { primaryObjects: outliner.selectedObjects, @@ -446,6 +452,11 @@ const PostProcessingPasses = ({ .mul(hoverStrength) .mul(osc) + const outlineAlpha = outlineNode.primaryVisibleEdge + .max(outlineNode.primaryHiddenEdge) + .max(outlineNode.secondaryVisibleEdge) + .max(outlineNode.secondaryHiddenEdge) + visualAlpha = visualAlpha.max(outlineAlpha) compositeWithOutlines = vec4( add(sceneColor.rgb, selectedOutline.add(hoverOutline)), sceneColor.a, @@ -456,9 +467,22 @@ const PostProcessingPasses = ({ // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) - const finalOutput = vec4(withOverlay, float(1)) + let finalOutput: ReturnType | ReturnType = vec4( + withOverlay, + float(1), + ) + if (transparentBackground) { + const overlayAlpha = overlayColor.a + const alpha = overlayAlpha.add(visualAlpha.mul(overlayAlpha.oneMinus())) + const straightRgb = overlayColor.rgb + .mul(overlayAlpha) + .add(compositeWithOutlines.rgb.mul(visualAlpha).mul(overlayAlpha.oneMinus())) + .div(alpha.max(float(0.00001))) + finalOutput = premultiplyAlpha(renderOutput(vec4(straightRgb, alpha))) + } const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) + renderPipeline.outputColorTransform = !transparentBackground renderPipeline.outputNode = finalOutput renderPipelineRef.current = renderPipeline retryCountRef.current = 0 @@ -496,11 +520,13 @@ const PostProcessingPasses = ({ hoverStrength, hoverVisibleColor, edges, + inkOpacityOverride, pipelineVersion, projectId, renderer, scene, shading, + transparentBackground, size.height, size.width, zoneLayers, @@ -527,7 +553,7 @@ const PostProcessingPasses = ({ if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) { try { if ((renderer as any).setClearAlpha) { - ;(renderer as any).setClearAlpha(1) + ;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1) } const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0 ;(renderer as any).render(scene, camera) diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index a33beab3..98fd5d04 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -36,52 +36,60 @@ export function useNodeEvents(node: NodeByKind, type: emitter.emit(eventKey, payload as never) } - // Suppress node pointer events while an interaction drag is in - // progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging` - // covers host-driven drags (editor handle arrows etc.). Without - // this, the synthesized click on pointerup would reroute selection - // to whatever mesh the cursor lands on at release. - const isInteractionActive = () => { + // Camera drags (orbit / pan / dolly) suppress ALL node pointer events. + // + // `inputDragging` (host-driven drags: handle arrows, press-drag moves) + // additionally suppresses the SELECTION events — without it the click + // synthesized on pointer-release would reroute selection to whatever mesh + // sits under the cursor at release. It must NOT suppress the SPATIAL events + // (`enter` / `move` / `leave`): a surface-following move tool — a door / + // window sliding along a wall — runs WITH `inputDragging` set and depends on + // those events to track the cursor. Consumers that should ignore drag-time + // spatial events gate on `inputDragging` themselves (the editor's hover and + // paint paths, box-select), so emitting them during a drag only reaches the + // active move tool that wants them. + const spatialSuppressed = () => useViewer.getState().cameraDragging + const selectionSuppressed = () => { const s = useViewer.getState() return s.cameraDragging || s.inputDragging } return { onPointerDown: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return if (e.button !== 0) return emit('pointerdown', e) }, onPointerUp: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return if (e.button !== 0) return emit('pointerup', e) // Synthesize a click event on pointer up to be more forgiving than R3F's default onClick // which often fails if the mouse moves even 1 pixel. emit('click', e) }, - onClick: (e: ThreeEvent) => { + onClick: (_e: ThreeEvent) => { // Disable default R3F click since we synthesize it on pointerup // This prevents double-clicks from firing twice. }, onPointerEnter: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('enter', e) }, onPointerLeave: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('leave', e) }, onPointerMove: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (spatialSuppressed()) return emit('move', e) }, onDoubleClick: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('double-click', e) }, onContextMenu: (e: ThreeEvent) => { - if (isInteractionActive()) return + if (selectionSuppressed()) return emit('context-menu', e) }, } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 0802b53a..562fc73e 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -92,7 +92,7 @@ export { createColumnTorusGeometry, } from './systems/column/column-geometry' export { DoorAnimationSystem } from './systems/door/door-animation-system' -export { DoorSystem } from './systems/door/door-system' +export { buildDoorPreviewMesh, DoorSystem } from './systems/door/door-system' export { ElevatorInteractionSystem } from './systems/elevator/elevator-interaction-system' // Fence system follows the wall re-export pattern — composed into the // registry-driven fence definition's `def.system`. Removed in Phase 6 @@ -158,5 +158,5 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials' // removed in Phase 6 when the legacy mount points are deleted. export { WallSystem } from './systems/wall/wall-system' export { WindowAnimationSystem } from './systems/window/window-animation-system' -export { WindowSystem } from './systems/window/window-system' +export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 11cc67e3..a4bf08c9 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -75,6 +75,13 @@ type ViewerState = { showGrid: boolean setShowGrid: (show: boolean) => void + transparentBackground: boolean + setTransparentBackground: (transparent: boolean) => void + + // Embed-controlled ink-edge opacity override (null = use the per-mode default). + inkOpacity: number | null + setInkOpacity: (opacity: number | null) => void + projectId: string | null setProjectId: (id: string | null) => void projectPreferences: Record< @@ -283,6 +290,12 @@ const useViewer = create()( return { showGrid: show, projectPreferences } }), + transparentBackground: false, + setTransparentBackground: (transparent) => set({ transparentBackground: transparent }), + + inkOpacity: null, + setInkOpacity: (opacity) => set({ inkOpacity: opacity }), + projectId: null, setProjectId: (id) => set((state) => { diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 3342f4fd..04763991 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -2606,3 +2606,13 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) { } cutout.visible = false } + +/** + * Build a fresh door mesh for preview/ghost rendering. + * Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware). + */ +export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh { + const mesh = new THREE.Mesh() + updateDoorMesh(node, mesh) + return mesh +} diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 6b689711..b6a40043 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -68,6 +68,14 @@ export const GeometrySystem = () => { // shelf dirties the shelf without altering its boards. const builtGeometryKeyRef = useRef>(new Map()) + // Re-mark every geometry-backed node dirty whenever a viewer appearance + // value changes, so `def.geometry` builders re-run and pick up the new + // shading / texture / preset / theme. These four are deliberate re-run + // TRIGGERS, not values read in the body — the effect re-fires on any + // change. They're primitives (stable by value), so listing them is safe; + // biome flags them as "unnecessary" because the body doesn't reference + // them, but dropping them silently breaks appearance-mode switching. + // biome-ignore lint/correctness/useExhaustiveDependencies: shading/textures/colorPreset/sceneTheme are intentional re-run triggers; removing them stops geometry from rebuilding on appearance change. useEffect(() => { const nodes = useScene.getState().nodes for (const node of Object.values(nodes)) { diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 8867d27b..63877873 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,8 +1,7 @@ -import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { lerp } from 'three/src/math/MathUtils.js' import useViewer from '../../store/use-viewer' -import { getLevelHeight } from './level-utils' const EXPLODED_GAP = 5 @@ -40,7 +39,11 @@ export const LevelSystem = () => { obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel - cumulativeY += getLevelHeight(levelId, nodes) + cumulativeY += getLevelHeight( + levelId, + nodes, + (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, + ) } }, 5) // Using a lower priority so it runs after transforms from other systems have settled return null diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index 1804e374..bf595c6c 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,53 +1,4 @@ -import { - type CeilingNode, - type LevelNode, - sceneRegistry, - useScene, - type WallNode, -} from '@pascal-app/core' - -export const DEFAULT_LEVEL_HEIGHT = 2.5 - -// Cache: levelId → computed height. Invalidated when the nodes reference changes. -// Zustand produces a new `nodes` object on every mutation, so reference equality -// is a zero-cost way to detect stale data without any subscription overhead. -const heightCache = new Map() -let lastNodesRef: object | null = null - -export function getLevelHeight( - levelId: string, - nodes: ReturnType['nodes'], -): number { - if (nodes !== lastNodesRef) { - heightCache.clear() - lastNodesRef = nodes - } - - if (heightCache.has(levelId)) return heightCache.get(levelId)! - - const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined - if (!level) return DEFAULT_LEVEL_HEIGHT - - let maxTop = 0 - - for (const childId of level.children) { - const child = nodes[childId as keyof typeof nodes] - if (!child) continue - if (child.type === 'ceiling') { - const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT - if (ch > maxTop) maxTop = ch - } else if (child.type === 'wall') { - let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0 - if (meshY < 0) meshY = 0 - const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) - if (top > maxTop) maxTop = top - } - } - - const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT - heightCache.set(levelId, height) - return height -} +import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' /** * Instantly snaps all level Objects3D to their true stacked Y positions @@ -90,7 +41,11 @@ export function snapLevelsToTruePositions(): () => void { for (const { levelId, obj } of entries) { obj.position.y = cumulativeY obj.visible = true - cumulativeY += getLevelHeight(levelId, nodes) + cumulativeY += getLevelHeight( + levelId, + nodes, + (wallId) => sceneRegistry.nodes.get(wallId)?.position.y, + ) } return () => { diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 57c93892..30b9a15e 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -3648,3 +3648,13 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) { } cutout.visible = false } + +/** + * Build a fresh window mesh for preview/ghost rendering. + * Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware). + */ +export function buildWindowPreviewMesh(node: WindowNode): THREE.Mesh { + const mesh = new THREE.Mesh() + updateWindowMesh(node, mesh) + return mesh +} diff --git a/wiki/architecture/README.md b/wiki/architecture/README.md index efc42c72..8ad9e1ce 100644 --- a/wiki/architecture/README.md +++ b/wiki/architecture/README.md @@ -12,7 +12,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa | [node-definitions](node-definitions.md) | Three-checkbox composition model for registry-driven kinds (`geometry` / `renderer` / `system`) | | [materials-and-themes](materials-and-themes.md) | Surface colour: surface roles, colour presets, the textures axis, and scene themes (appearance / ground / clay tints) | | [plugin-authoring](plugin-authoring.md) | Public contract for external plugins — `Plugin` shape, `setPluginDiscovery`, lifecycle, what's in and out of v1 | -| [tools](tools.md) | Editor tools structure, manipulation constraints, and Shift bypass defaults | +| [tools](tools.md) | Editor tools structure, 2D↔3D behavioral parity, manipulation constraints, and Shift bypass defaults | | [viewer-isolation](viewer-isolation.md) | Keeping `@pascal-app/viewer` editor-agnostic | | [selection-managers](selection-managers.md) | Two-layer selection (viewer + editor), events, outliner | | [scene-registry](scene-registry.md) | Global node ID → Object3D map and `useRegistry` | diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index d8c80f8b..8ef982ab 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -99,6 +99,18 @@ export function MyTool() { 3. Add the tool identifier to the `useEditor` tool union type. 4. If the tool requires new node types, add schema + renderer + system first. +## 2D ↔ 3D behavioral parity (default expectation) + +The 2D floor-plan view and the 3D view are two presentations of the **same** edit. Whenever a behavior is applicable to both, it must exist in both — the *mechanism* may differ (a 3D raycast hover vs a plan-space nearest-wall query; a real mesh ghost vs an SVG symbol), but the *felt behavior* should match. When you add or change an interaction in one view, port it to the other in the same change, or write down why it genuinely doesn't apply. + +Concretely, door/window placement/move keeps these in lockstep across `{door,window}/move-tool.tsx` (3D) and `{door,window}/floorplan-move.ts` (2D): + +- **Snap target**: nearest wall to the true cursor (shared `findClosestWallInPlan` / wall raycast), free-follow off-wall, commit only on a host. +- **Move SFX**: a soft `sfx:grid-snap` click per grid step while sliding (free-follow plan XZ or on-wall along-X, quantized + deduped so it isn't a machine-gun) and a soft `sfx:item-pick` cue on the floor→wall snap. Both tools carry an identical `tickGridStep` / `tickWallSnap` pair — keep them in sync. +- **R-flip** facing mid-placement, **Shift** to free snap/alignment (guides stay visible) and force-place over collisions, faithful ghost/symbol, deterministic single-undo commit. + +Tells that you've broken parity: a sound/guide/snap that fires in 3D but is silent in 2D (or vice-versa), or a fix landed in one move file but not its sibling. The two move files are deliberately near-mirrors; diff them when in doubt. + ## Move coexistence: 2D `FloorplanRegistryMoveOverlay` + legacy 3D mover While a kind is mid-migration its move can run through two paths at once: the registry-driven 2D `FloorplanRegistryMoveOverlay` (`def.floorplanMoveTarget`) and the legacy 3D mover (e.g. `MoveItemContent`). Both react to `setMovingNode(node)`, both mount, both want to commit. Two pitfalls surfaced and have stable fixes; replicate the patterns when porting another kind to coexist.