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 { 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<typeof PipeFittingNode> = {
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.',
},
}
@@ -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 }
}
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
export { pipeFittingDefinition } from './definition'
export { buildPipeFittingGeometry } from './geometry'
export { getPipeFittingPorts } from './ports'
export { PipeFittingNode } from './schema'
@@ -0,0 +1,56 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { PipeFittingNode } from './schema'
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
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' },
],
},
],
}
+102
View File
@@ -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,
}
})
}
@@ -0,0 +1 @@
export { PipeFittingNode } from '@pascal-app/core'
@@ -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
+255
View File
@@ -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<Placement | null>(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 (
<LevelOffsetGroup>
{/* 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. */}
<CursorSphere position={placement.position} />
<group position={placement.position} rotation={placement.rotation}>
<primitive object={ghost} />
</group>
{/* Rotation HUD — active axis + key hints, pinned above the ghost. */}
<Html
center
position={[placement.position[0], placement.position[1] + 0.5, placement.position[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
{/* Same pill shell as DimensionPill so the placement HUD matches
the drawing / dragging readouts. */}
<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">Axis {axis.toUpperCase()}</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> axis</span>
</div>
</Html>
{/* Port-snap halo so the user sees the click will mate, not free-place. */}
{placement.snapPort && (
<mesh
layers={EDITOR_LAYER}
position={placement.snapPort.position as [number, number, number]}
>
<sphereGeometry args={[0.18, 24, 16]} />
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
</mesh>
)}
</LevelOffsetGroup>
)
}
export default PipeFittingTool