diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 904076138..d89b69ca5 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,17 +31,40 @@ 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' + 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' }, @@ -55,14 +79,32 @@ const BUILD_TYPES: BuildType[] = [ { 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' }, + // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). + { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.png' }, { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', 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.png', kind: 'duct-segment' }, + { + id: 'duct-terminal', + label: 'Register', + iconSrc: '/icons/registers.png', + kind: 'duct-terminal', + }, + { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.png', kind: 'hvac-equipment' }, + { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.png', kind: 'lineset' }, + { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.png', kind: 'liquid-line' }, + { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.png', kind: 'pipe-segment' }, +] + /** * 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') @@ -111,10 +153,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 +204,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 +317,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/public/icons/HVAC.png b/apps/editor/public/icons/HVAC.png new file mode 100644 index 000000000..c846c3eea Binary files /dev/null and b/apps/editor/public/icons/HVAC.png differ diff --git a/apps/editor/public/icons/duct-fitting.png b/apps/editor/public/icons/duct-fitting.png new file mode 100644 index 000000000..f95b799ad Binary files /dev/null and b/apps/editor/public/icons/duct-fitting.png differ diff --git a/apps/editor/public/icons/duct.png b/apps/editor/public/icons/duct.png new file mode 100644 index 000000000..b7f95f722 Binary files /dev/null and b/apps/editor/public/icons/duct.png differ diff --git a/apps/editor/public/icons/dwv-pipes.png b/apps/editor/public/icons/dwv-pipes.png new file mode 100644 index 000000000..06bd1381c Binary files /dev/null and b/apps/editor/public/icons/dwv-pipes.png differ diff --git a/apps/editor/public/icons/lineset.png b/apps/editor/public/icons/lineset.png new file mode 100644 index 000000000..755e7899b Binary files /dev/null and b/apps/editor/public/icons/lineset.png differ diff --git a/apps/editor/public/icons/registers.png b/apps/editor/public/icons/registers.png new file mode 100644 index 000000000..faf1ad71c Binary files /dev/null and b/apps/editor/public/icons/registers.png differ diff --git a/apps/ifc-converter/components/IfcConverter.tsx b/apps/ifc-converter/components/IfcConverter.tsx index 6e9c2f302..829a36d5d 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/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 99bc7e831..6374cbb40 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/registry/index.ts b/packages/core/src/registry/index.ts index a0b58e53b..e8055706b 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -56,6 +56,7 @@ export type { Capabilities, CapabilityCtx, CuttableConfig, + DistributionRole, DragAction, EditorCtx, FloorPlacedConfig, @@ -85,6 +86,7 @@ export type { MovableConfig, NodeCategory, NodeDefinition, + NodePort, NodeRegistry, PaintCapability, PaintEffectiveMaterialArgs, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b5139328c..f171295f5 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -168,6 +168,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. @@ -666,12 +700,26 @@ 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 defaults: () => Omit, 'id' | 'type'> migrate?: Record unknown> @@ -829,6 +877,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 /** @@ -915,6 +972,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 = { @@ -1279,6 +1344,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 } @@ -1411,7 +1501,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 608236877..aae58c9c5 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 000000000..277b02f77 --- /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 000000000..21af751f3 --- /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 000000000..5eefff314 --- /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 000000000..a0e00a139 --- /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 000000000..06bc7ff3a --- /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 000000000..5b3f8b386 --- /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 000000000..88e38e9a3 --- /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 000000000..6f7512ec3 --- /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 000000000..ca63f242f --- /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 b866f4e6c..d404367b0 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 cf8bc9743..5032b154a 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 a57aa6eb3..f52f93944 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, @@ -69,6 +73,19 @@ export { 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, @@ -82,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 000000000..36769027a --- /dev/null +++ b/packages/core/src/services/level-height.ts @@ -0,0 +1,43 @@ +import { sceneRegistry } from '../hooks/scene-registry/scene-registry' +import type { CeilingNode, LevelNode, WallNode } from '../schema' +import type { AnyNode, AnyNodeId } from '../schema/types' + +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: Record): 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 AnyNodeId)?.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 +} diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts new file mode 100644 index 000000000..0c722d2c9 --- /dev/null +++ b/packages/core/src/services/port-connectivity.ts @@ -0,0 +1,189 @@ +import { nodeRegistry } from '../registry' +import type { AnyNode, AnyNodeId } from '../schema' + +/** + * Connectivity-aware editing for port-bearing kinds (HVAC ductwork). + * + * 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 }> | undefined { + return nodeRegistry.get(node.type)?.ports?.(node) as + | ReadonlyArray<{ id: string; position: Point }> + | undefined +} + +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 duct-segment (endpoint stretch) and duct-fitting (rigid follow) + * partners 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 = {} + for (const p of movedPorts) startMovedPorts[p.id] = p.position + + 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 + if (other.type !== 'duct-segment' && other.type !== 'duct-fitting') 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) { + matchedId = mp.id + break + } + } + if (!matchedId) continue + + if (other.type === 'duct-segment') { + 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 000000000..56014f29d --- /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 000000000..d5c7a7aef --- /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 000000000..e7640d675 --- /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 000000000..9dc6d90d7 --- /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 000000000..8d62b30ad --- /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 000000000..9d8c7ee1a --- /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 || 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/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index d6adf6ab7..ada0b4a82 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 b7eca84ed..0be1bac8c 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 @@ -429,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, ) @@ -450,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 @@ -527,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] } @@ -540,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( @@ -592,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/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index aa09c2889..6ae525500 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 1bb7229cc..7a7587495 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -5551,15 +5551,7 @@ export function FloorplanPanel({ } as AnyNode usePlacementPreview.getState().set(ghost, wall) }, - [ - DoorNodeSchema, - WallNodeSchema, - WindowNodeSchema, - floorplanOpeningLocalY, - isDoorBuildActive, - movingNode, - movingOpeningType, - ], + [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 @@ -5567,6 +5559,7 @@ export function FloorplanPanel({ 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]) @@ -8813,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, diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c6903080a..ca6dde067 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -24,6 +24,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' @@ -55,6 +56,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' @@ -617,6 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {isFirstPersonMode ? : } + {!noEditing && } {!(isLoading || isFirstPersonMode) && } @@ -1287,6 +1290,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 892f5d011..793f9d2b7 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/riser-diagram-panel.tsx b/packages/editor/src/components/editor/riser-diagram-panel.tsx new file mode 100644 index 000000000..e89122e19 --- /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/systems/selection-affordance-manager.tsx b/packages/editor/src/components/systems/selection-affordance-manager.tsx new file mode 100644 index 000000000..61ad52241 --- /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/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5ba49e38a..4dd39177d 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/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 797d29b79..24dbdf36d 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,18 @@ 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) { + const mated = resolvePortSnap(node, [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 +479,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 +547,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 +584,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 +648,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 +692,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 +730,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 +740,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } }, [ boxDimensions, + cursorAttached, + portSnapConfig, exitMoveMode, isFreshPlacement, node, 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 30f897eec..9238963df 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -25,4 +25,12 @@ export const tools: ToolConfig[] = [ { 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: 'duct-segment', iconSrc: '/icons/duct.png', label: 'Duct' }, + { id: 'duct-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Duct Fitting' }, + { id: 'duct-terminal', iconSrc: '/icons/registers.png', label: 'Register' }, + { id: 'hvac-equipment', iconSrc: '/icons/HVAC.png', label: 'HVAC Unit' }, + { id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.png', label: 'DWV Pipe' }, + { id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Pipe Fitting' }, + { id: 'lineset', iconSrc: '/icons/lineset.png', label: 'Lineset' }, + { id: 'liquid-line', iconSrc: '/icons/lineset.png', 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 ca3c74b71..93c456ec5 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' @@ -989,6 +989,29 @@ function ReferenceFloorControl() { ) } +// ── Riser diagram control ──────────────────────────────────────────────────── + +function RiserControl() { + const isRiserOpen = useEditor((state) => 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/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index e046fbf20..a5076dd78 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/index.tsx b/packages/editor/src/index.tsx index 49141fc23..b9833369a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -12,7 +12,12 @@ 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' +export { + DimensionPill, + type DimensionPillPart, + formatMeasurement, + MeasurementPill, +} from './components/editor/measurement-pill' export { type SnapshotCameraData, ThumbnailGenerator, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 80109273d..544cd07e7 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -106,6 +106,14 @@ export type StructureTool = | 'dormer' | 'gutter' | 'downspout' + | 'duct-segment' + | 'duct-fitting' + | 'duct-terminal' + | 'hvac-equipment' + | 'lineset' + | 'liquid-line' + | 'pipe-segment' + | 'pipe-fitting' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' @@ -291,6 +299,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 @@ -348,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 @@ -808,6 +828,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, @@ -934,6 +961,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) => ({ diff --git a/packages/nodes/src/box-vent/preview.tsx b/packages/nodes/src/box-vent/preview.tsx index 55b4b7638..566e0c5ea 100644 --- a/packages/nodes/src/box-vent/preview.tsx +++ b/packages/nodes/src/box-vent/preview.tsx @@ -17,6 +17,7 @@ import type { BoxVentNode } from './schema' * the cursor ray and starve the placement tool of `roof:move` events. */ 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], diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 98be50e59..26f01e846 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/chimney/preview.tsx b/packages/nodes/src/chimney/preview.tsx index 472ca506c..e50296c05 100644 --- a/packages/nodes/src/chimney/preview.tsx +++ b/packages/nodes/src/chimney/preview.tsx @@ -48,6 +48,7 @@ const ChimneyPreview = ({ 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, effectiveSegment), [ diff --git a/packages/nodes/src/chimney/renderer.tsx b/packages/nodes/src/chimney/renderer.tsx index 16aaa2f65..a52d46d8c 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/cupola/preview.tsx b/packages/nodes/src/cupola/preview.tsx index 5fd1fd7fd..579844241 100644 --- a/packages/nodes/src/cupola/preview.tsx +++ b/packages/nodes/src/cupola/preview.tsx @@ -13,6 +13,7 @@ import type { CupolaNode } from './schema' * so the preview doesn't intercept the cursor ray feeding the tool. */ 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], diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 2352ff5b4..357c69569 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/dormer/csg-geometry.ts b/packages/nodes/src/dormer/csg-geometry.ts index a392d67a6..2f7dd7c02 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/panel-position-section.tsx b/packages/nodes/src/dormer/panel-position-section.tsx index c36e06291..4833b75cf 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 be6d1676f..7eff4733b 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 diff --git a/packages/nodes/src/dormer/preview.tsx b/packages/nodes/src/dormer/preview.tsx index 2aa2554c7..17932cd7e 100644 --- a/packages/nodes/src/dormer/preview.tsx +++ b/packages/nodes/src/dormer/preview.tsx @@ -26,6 +26,7 @@ const invalidGhostMaterial = new THREE.MeshStandardMaterial({ 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], diff --git a/packages/nodes/src/dormer/renderer.tsx b/packages/nodes/src/dormer/renderer.tsx index 36cf6bc23..360215deb 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/window-assembly.tsx b/packages/nodes/src/dormer/window-assembly.tsx index 4c7734cbc..10060e1d7 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) => ( { + // 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/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index cc0cb6e63..8f071ddac 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/duct-fitting/definition.ts b/packages/nodes/src/duct-fitting/definition.ts new file mode 100644 index 000000000..9b1cf316c --- /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.png' }, + 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 000000000..da7034569 --- /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 000000000..3aa4d7a0e --- /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 000000000..b1509f233 --- /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 000000000..f378d6221 --- /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 000000000..62ca78fe2 --- /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 000000000..f5b2cd66f --- /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 000000000..0c3e44b73 --- /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 000000000..8b396b678 --- /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 000000000..a66af5934 --- /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 000000000..2f55db950 --- /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.png' }, + 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 000000000..26b9cd9fe --- /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 000000000..313d43546 --- /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 000000000..4587f5d83 --- /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 000000000..8b81a516d --- /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 000000000..76776b5eb --- /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 000000000..2df455db9 --- /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 000000000..15011eef7 --- /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 000000000..0c8a7404d --- /dev/null +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -0,0 +1,989 @@ +'use client' + +import { + type AnyNode, + DuctSegmentNode, + emitter, + type GridEvent, + getLevelHeight, + 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) + 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 000000000..b083017f1 --- /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.png' }, + 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 000000000..abadb9151 --- /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 000000000..28475dfab --- /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 000000000..ede0e1c74 --- /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 000000000..88a02c1e5 --- /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 000000000..c53b81f78 --- /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 000000000..f72c87eec --- /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 000000000..15ca0e646 --- /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/eyebrow-vent/preview.tsx b/packages/nodes/src/eyebrow-vent/preview.tsx index 3c6589cf1..d8f8008b4 100644 --- a/packages/nodes/src/eyebrow-vent/preview.tsx +++ b/packages/nodes/src/eyebrow-vent/preview.tsx @@ -13,6 +13,7 @@ import type { EyebrowVentNode } from './schema' * the preview doesn't intercept the cursor ray feeding the tool. */ 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], diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index dfd41f483..eb9bf0933 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/gutter/preview.tsx b/packages/nodes/src/gutter/preview.tsx index 40ddfde03..5c03e204b 100644 --- a/packages/nodes/src/gutter/preview.tsx +++ b/packages/nodes/src/gutter/preview.tsx @@ -22,6 +22,7 @@ import type { GutterNode } from './schema' * placed gutter. */ 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), [ diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 76827e528..5a3862d66 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/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts new file mode 100644 index 000000000..6daa77afd --- /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.png' }, + 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 000000000..e15b85045 --- /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 000000000..72fb61225 --- /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 000000000..0f176b179 --- /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 000000000..569fe8293 --- /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 000000000..9bcfb21c2 --- /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 000000000..330212169 --- /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 000000000..54ee0beea --- /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 6c67f8398..ee5a13b32 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/lineset/connect.test.ts b/packages/nodes/src/lineset/connect.test.ts new file mode 100644 index 000000000..5fe7b9d4a --- /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 000000000..5a88a06fd --- /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 000000000..e08370025 --- /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.png' }, + 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 000000000..7de546cc1 --- /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 000000000..96360835f --- /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 000000000..5689ef854 --- /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 000000000..a025b5348 --- /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 000000000..d55eb7843 --- /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 000000000..f987bfd33 --- /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 000000000..e348d0c17 --- /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 000000000..d7a057a58 --- /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 000000000..74c7afab0 --- /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 000000000..97cbc80a4 --- /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.png' }, + 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 000000000..13b03943a --- /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 000000000..0a99afab4 --- /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 000000000..cd63be633 --- /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 000000000..3e5c81413 --- /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 000000000..04c87098a --- /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 000000000..c19403752 --- /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 000000000..f9b7fbd27 --- /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 000000000..ed23f9d2f --- /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 000000000..745f1f145 --- /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 000000000..3c533cda8 --- /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.png' }, + 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 000000000..1db7f3e46 --- /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 000000000..b7db2552c --- /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 000000000..6943690ca --- /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 000000000..9d9377509 --- /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 000000000..158e4896e --- /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 000000000..ab5ae3b5d --- /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 000000000..20e9a924d --- /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 000000000..eeb502bf5 --- /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 000000000..6ac636c64 --- /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.png' }, + 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 000000000..1451019df --- /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 000000000..c4fd28316 --- /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 000000000..87fd91224 --- /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 000000000..cfa93e392 --- /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 000000000..797c2e507 --- /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 000000000..b195086e2 --- /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 000000000..81f9e1f00 --- /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 000000000..077359125 --- /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 000000000..331bf8e11 --- /dev/null +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -0,0 +1,70 @@ +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', + + 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 000000000..9a7a81268 --- /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 000000000..56f788210 --- /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 000000000..94928a2e9 --- /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 000000000..502377cb6 --- /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 000000000..ce7a954e1 --- /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 000000000..d99c585b4 --- /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 000000000..5de8795f0 --- /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/preview.tsx b/packages/nodes/src/ridge-vent/preview.tsx index 3935dcfd2..9fc5bf423 100644 --- a/packages/nodes/src/ridge-vent/preview.tsx +++ b/packages/nodes/src/ridge-vent/preview.tsx @@ -7,6 +7,7 @@ import { buildRidgeVentGeometry } from './geometry' import type { RidgeVentNode } from './schema' 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], diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 738865959..15386ed92 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/roof-segment/renderer.tsx b/packages/nodes/src/roof-segment/renderer.tsx index 8c1b50492..b584e1dfa 100644 --- a/packages/nodes/src/roof-segment/renderer.tsx +++ b/packages/nodes/src/roof-segment/renderer.tsx @@ -52,6 +52,7 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { // 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/shared/auto-fitting.test.ts b/packages/nodes/src/shared/auto-fitting.test.ts new file mode 100644 index 000000000..81447fcd8 --- /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 000000000..6d14c3513 --- /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 000000000..516650a34 --- /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 000000000..67a328ae7 --- /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 000000000..db1d549f5 --- /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/level-offset-group.tsx b/packages/nodes/src/shared/level-offset-group.tsx new file mode 100644 index 000000000..3228e6b68 --- /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/path-offset.ts b/packages/nodes/src/shared/path-offset.ts new file mode 100644 index 000000000..1c8946f41 --- /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 000000000..74dbc5b6b --- /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 000000000..f03057c9a --- /dev/null +++ b/packages/nodes/src/shared/pipe-auto-fitting.test.ts @@ -0,0 +1,121 @@ +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 → wye, branch leaning 45° downstream', () => { + 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() + expect(plan!.fitting.fittingType).toBe('wye') + + 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 at 45° between the run axis and the drawn direction — + // i.e. leaning downstream, the code-correct wye entry. + const axis = [6 / Math.hypot(6, 0.125), -0.125 / Math.hypot(6, 0.125), 0] + expect(dot(branch.direction, axis)).toBeCloseTo(Math.SQRT1_2, 3) + 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/ports.ts b/packages/nodes/src/shared/ports.ts new file mode 100644 index 000000000..4200c0286 --- /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/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index 4111cd779..f4038470f 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/solar-panel/preview.tsx b/packages/nodes/src/solar-panel/preview.tsx index 15b705a8f..f5c97ec93 100644 --- a/packages/nodes/src/solar-panel/preview.tsx +++ b/packages/nodes/src/solar-panel/preview.tsx @@ -29,6 +29,7 @@ const invalidGhostMaterial = new THREE.MeshStandardMaterial({ 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), [ diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 663a5608f..997c29b00 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/turbine-vent/preview.tsx b/packages/nodes/src/turbine-vent/preview.tsx index 4ebc4bee4..3c9b950d7 100644 --- a/packages/nodes/src/turbine-vent/preview.tsx +++ b/packages/nodes/src/turbine-vent/preview.tsx @@ -14,6 +14,7 @@ import type { TurbineVentNode } from './schema' * doesn't intercept the cursor ray feeding the placement tool. */ 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], diff --git a/packages/nodes/src/turbine-vent/renderer.tsx b/packages/nodes/src/turbine-vent/renderer.tsx index 2e7685440..0d1bb494c 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/viewer/src/components/renderers/parametric-node-renderer.tsx b/packages/viewer/src/components/renderers/parametric-node-renderer.tsx index 4cd6f23fc..65984c236 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 ( { // 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 8867d27b4..67483c100 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 diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index 1804e3747..aa9b2a201 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