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:
Sudhir Yadav
2026-06-16 15:30:39 -04:00
committed by GitHub
parent a0d3d9c701
commit 5551500d98
172 changed files with 17361 additions and 150 deletions
@@ -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<typeof HvacEquipmentNode> = {
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.',
},
}
@@ -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 }
}
@@ -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) —
* `<ParametricNodeRenderer>` 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<typeof localEquipmentPorts>[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
}
@@ -0,0 +1,4 @@
export { hvacEquipmentDefinition } from './definition'
export { buildHvacEquipmentGeometry } from './geometry'
export { getHvacEquipmentPorts } from './ports'
export { HvacEquipmentNode } from './schema'
@@ -0,0 +1,104 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { HvacEquipmentNode } from './schema'
export const hvacEquipmentParametrics: ParametricDescriptor<HvacEquipmentNode> = {
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',
},
],
},
],
}
+122
View File
@@ -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,
}
})
}
@@ -0,0 +1 @@
export { HvacEquipmentNode } from '@pascal-app/core'
+135
View File
@@ -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 (
<LevelOffsetGroup>
<group position={cursor} rotation={[0, yaw, 0]}>
<primitive object={ghost} />
</group>
<Html
center
position={[cursor[0], cursor[1] + previewNode.height + 0.4, cursor[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">
<span className="font-medium text-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> smooth</span>
</div>
</Html>
</LevelOffsetGroup>
)
}
export default HvacEquipmentTool