feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
@@ -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<typeof DuctTerminalNode> = {
|
||||
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.',
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 —
|
||||
* `<ParametricNodeRenderer>` 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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ductTerminalDefinition } from './definition'
|
||||
export { buildDuctTerminalGeometry } from './geometry'
|
||||
export { getDuctTerminalPorts } from './ports'
|
||||
export { DuctTerminalNode } from './schema'
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { DuctTerminalNode } from './schema'
|
||||
|
||||
export const ductTerminalParametrics: ParametricDescriptor<DuctTerminalNode> = {
|
||||
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' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DuctTerminalNode } from '@pascal-app/core'
|
||||
@@ -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<Mount>('floor')
|
||||
const [placement, setPlacement] = useState<Placement | null>(null)
|
||||
|
||||
const mountRef = useRef<Mount>('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<Array<[number, number]>>
|
||||
}
|
||||
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 (
|
||||
<LevelOffsetGroup>
|
||||
{/* 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. */}
|
||||
<CursorSphere position={previewPosition} />
|
||||
<group position={previewPosition} rotation={[0, placement.yaw, 0]}>
|
||||
<primitive object={ghost} />
|
||||
</group>
|
||||
<Html
|
||||
center
|
||||
position={[previewPosition[0], previewPosition[1] + 0.45, previewPosition[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
{placement.snapped && (
|
||||
<>
|
||||
<span className="font-medium text-primary">Snapped to duct</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="font-medium text-foreground">Mount {mountLabel}</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">M surface</span>
|
||||
{effectiveMount !== 'wall' && (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">R/T rotate</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Html>
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default DuctTerminalTool
|
||||
Reference in New Issue
Block a user