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,70 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildPipeTrapFloorplan } from './floorplan'
|
||||
import { buildPipeTrapGeometry } from './geometry'
|
||||
import { pipeTrapParametrics } from './parametrics'
|
||||
import { getPipeTrapPorts } from './ports'
|
||||
import { PipeTrapNode } from './schema'
|
||||
|
||||
/**
|
||||
* DWV P-trap — the water-seal fitting on the waste line. Placed by its
|
||||
* own click tool; the pipe tool then draws the trap arm off the outlet.
|
||||
* Modeled explicitly so the IPC 909.1 trap-arm rule has a node to
|
||||
* validate.
|
||||
*/
|
||||
export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
|
||||
kind: 'pipe-trap',
|
||||
schemaVersion: 1,
|
||||
schema: PipeTrapNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
diameter: 1.5,
|
||||
pipeMaterial: 'pvc',
|
||||
armLengthM: 0,
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, portSnap: { systems: ['waste'] } },
|
||||
rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: pipeTrapParametrics,
|
||||
|
||||
geometry: buildPipeTrapGeometry,
|
||||
geometryKey: (n) => JSON.stringify([n.diameter, n.pipeMaterial, n.armLengthM]),
|
||||
|
||||
ports: getPipeTrapPorts,
|
||||
|
||||
floorplan: buildPipeTrapFloorplan,
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Place trap' },
|
||||
{ key: 'R / T', label: 'Rotate ±45°' },
|
||||
{ key: 'Shift', label: 'Smooth (no grid snap)' },
|
||||
{ key: 'Esc', label: 'Exit' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Trap',
|
||||
description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.',
|
||||
icon: { kind: 'iconify', name: 'lucide:spline' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 98,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A DWV P-trap with inlet (up) and outlet (trap arm) ports. Position is level-local meters; rotation is yaw radians. armLengthM is the trap-arm developed length checked against IPC 909.1.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||
import { getPipeTrapPorts } from './ports'
|
||||
import type { PipeTrapNode } from './schema'
|
||||
|
||||
const PIPE_STROKE = '#57534e'
|
||||
|
||||
/**
|
||||
* Floor-plan symbol — the conventional trap glyph: a short stub at the
|
||||
* inlet (the fixture drop, drawn as a dot since it's vertical) and a
|
||||
* solid line for the trap arm out to the outlet. Reads as the P-trap's
|
||||
* arm in plan.
|
||||
*/
|
||||
export function buildPipeTrapFloorplan(
|
||||
node: PipeTrapNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const ports = getPipeTrapPorts(node)
|
||||
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : PIPE_STROKE
|
||||
|
||||
const inletXZ: FloorplanPoint = [inlet.position[0], inlet.position[2]]
|
||||
const outletXZ: FloorplanPoint = [outlet.position[0], outlet.position[2]]
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [inletXZ, outletXZ],
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 2.5 : 1.8,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
opacity: 0.9,
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: inletXZ[0],
|
||||
cy: inletXZ[1],
|
||||
r: 0.04,
|
||||
fill: stroke,
|
||||
opacity: 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
if (showSelectedChrome) {
|
||||
children.push({ kind: 'move-handle', point: [node.position[0], node.position[2]] })
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Group, Mesh, TorusGeometry, Vector3 } from 'three'
|
||||
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { createPipeMaterial } from '../pipe-segment/geometry'
|
||||
import type { PipeTrapNode } from './schema'
|
||||
|
||||
const BEND_SEGMENTS = 24
|
||||
|
||||
/** Inlet drop and arm reach in pipe radii — keeps the trap proportional
|
||||
* to its size without per-size tuning. */
|
||||
const INLET_DROP_RADII = 2.6
|
||||
const ARM_REACH_RADII = 3.2
|
||||
|
||||
/**
|
||||
* P-trap geometry in the LOCAL frame (origin at the trap weir, the low
|
||||
* point of the U). Inlet stub rises +Y to the fixture tailpiece; a
|
||||
* half-torus U-bend turns the flow; the trap arm runs +X toward the
|
||||
* vented waste line. `<ParametricNodeRenderer>` applies position + yaw.
|
||||
*/
|
||||
export function buildPipeTrapGeometry(node: PipeTrapNode): Group {
|
||||
const group = new Group()
|
||||
const material = createPipeMaterial({ pipeMaterial: node.pipeMaterial, system: 'waste' })
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const bendR = radius * 1.6
|
||||
|
||||
// U-bend: half torus in the XY plane, opening upward. Sits so its two
|
||||
// tops are at y = bendR (the inlet riser and the arm rise).
|
||||
const bend = new Mesh(new TorusGeometry(bendR, radius, 12, BEND_SEGMENTS, Math.PI), material)
|
||||
bend.rotation.z = Math.PI // open side up
|
||||
bend.position.set(bendR, bendR, 0)
|
||||
bend.name = 'pipe-trap-bend'
|
||||
group.add(bend)
|
||||
|
||||
// Inlet riser: from the left top of the U straight up to the fixture.
|
||||
const inletDrop = radius * INLET_DROP_RADII
|
||||
const inletTop = new Vector3(0, bendR + inletDrop, 0)
|
||||
const inletStub = buildSection(
|
||||
new Vector3(0, bendR, 0),
|
||||
inletTop,
|
||||
radius,
|
||||
material,
|
||||
'pipe-trap-inlet',
|
||||
)
|
||||
if (inletStub) group.add(inletStub)
|
||||
|
||||
// Trap arm: from the right top of the U horizontally along +X.
|
||||
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
|
||||
const armStart = new Vector3(bendR * 2, bendR, 0)
|
||||
const armEnd = new Vector3(bendR * 2 + armReach, bendR, 0)
|
||||
const arm = buildSection(armStart, armEnd, radius, material, 'pipe-trap-arm')
|
||||
if (arm) group.add(arm)
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
/** Local-frame port positions (before position/yaw): inlet at the top
|
||||
* of the riser facing +Y, outlet at the end of the arm facing +X. */
|
||||
export function localTrapPorts(node: PipeTrapNode): {
|
||||
inlet: Vector3
|
||||
outlet: Vector3
|
||||
} {
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const bendR = radius * 1.6
|
||||
const inletDrop = radius * INLET_DROP_RADII
|
||||
const armReach = Math.max(radius * ARM_REACH_RADII, node.armLengthM)
|
||||
return {
|
||||
inlet: new Vector3(0, bendR + inletDrop, 0),
|
||||
outlet: new Vector3(bendR * 2 + armReach, bendR, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { pipeTrapDefinition } from './definition'
|
||||
export { buildPipeTrapGeometry } from './geometry'
|
||||
export { getPipeTrapPorts } from './ports'
|
||||
export { PipeTrapNode } from './schema'
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { PipeTrapNode } from './schema'
|
||||
|
||||
export const pipeTrapParametrics: ParametricDescriptor<PipeTrapNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Trap',
|
||||
fields: [
|
||||
{ key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 4, step: 0.25 },
|
||||
{ key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] },
|
||||
{ key: 'armLengthM', kind: 'number', unit: 'm', min: 0, max: 4, step: 0.05 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Placement',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { NodePort } from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import { localTrapPorts } from './geometry'
|
||||
import type { PipeTrapNode } from './schema'
|
||||
|
||||
/**
|
||||
* `def.ports` — the trap's inlet (up, to the fixture) and outlet (the
|
||||
* trap arm, toward the vented waste line), transformed by position +
|
||||
* yaw into level-local space. Both carry the trap diameter and the
|
||||
* 'waste' system tag so the pipe tool and system graph treat them like
|
||||
* any other DWV joint.
|
||||
*/
|
||||
export function getPipeTrapPorts(node: PipeTrapNode): NodePort[] {
|
||||
const { inlet, outlet } = localTrapPorts(node)
|
||||
const yaw = node.rotation
|
||||
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
|
||||
const place = (local: Vector3, dir: Vector3): NodePort => {
|
||||
const position = local
|
||||
.clone()
|
||||
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
|
||||
.add(offset)
|
||||
const direction = dir
|
||||
.clone()
|
||||
.applyAxisAngle(new Vector3(0, 1, 0), yaw)
|
||||
.normalize()
|
||||
return {
|
||||
id: local === inlet ? 'inlet' : 'outlet',
|
||||
position: [position.x, position.y, position.z] as const,
|
||||
direction: [direction.x, direction.y, direction.z] as const,
|
||||
diameter: node.diameter,
|
||||
system: 'waste',
|
||||
}
|
||||
}
|
||||
return [place(inlet, new Vector3(0, 1, 0)), place(outlet, new Vector3(1, 0, 0))]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PipeTrapNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import { pipeTrapDefinition } from './definition'
|
||||
import { buildPipeTrapGeometry } from './geometry'
|
||||
|
||||
const PREVIEW_OPACITY = 0.55
|
||||
const ROTATE_STEP_RAD = Math.PI / 4
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-place tool for P-traps. The ghost follows the cursor on the
|
||||
* floor. **R / T** rotate the arm ±45°, **Shift** disables grid snap.
|
||||
* The pipe tool then draws the trap arm off the outlet toward the vent.
|
||||
*/
|
||||
const PipeTrapTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const [cursor, setCursor] = useState<[number, number, number] | null>(null)
|
||||
const [yaw, setYaw] = useState(0)
|
||||
const [diameter] = useState(1.5)
|
||||
const yawRef = useRef(0)
|
||||
const diameterRef = useRef(diameter)
|
||||
diameterRef.current = diameter
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
PipeTrapNode.parse({
|
||||
...pipeTrapDefinition.defaults(),
|
||||
diameter,
|
||||
}),
|
||||
[diameter],
|
||||
)
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeTrapGeometry(previewNode)
|
||||
group.traverse((child) => {
|
||||
const mesh = child as { material?: { transparent: boolean; opacity: number } }
|
||||
if (mesh.material) {
|
||||
mesh.material.transparent = true
|
||||
mesh.material.opacity = PREVIEW_OPACITY
|
||||
}
|
||||
})
|
||||
return group
|
||||
}, [previewNode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
const resolve = (event: GridEvent) => {
|
||||
const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep
|
||||
return {
|
||||
position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
diameter: diameterRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
setCursor(resolve(event).position)
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
const r = resolve(event)
|
||||
const trap = PipeTrapNode.parse({
|
||||
...pipeTrapDefinition.defaults(),
|
||||
diameter: r.diameter,
|
||||
position: r.position,
|
||||
rotation: yawRef.current,
|
||||
})
|
||||
useScene.getState().createNode(trap, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [trap.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
const key = e.key
|
||||
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
|
||||
yawRef.current += steps * ROTATE_STEP_RAD
|
||||
setYaw(yawRef.current)
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
}
|
||||
}, [activeLevelId])
|
||||
|
||||
if (!activeLevelId || !cursor) return null
|
||||
|
||||
return (
|
||||
<LevelOffsetGroup>
|
||||
<group position={cursor} rotation={[0, yaw, 0]}>
|
||||
<primitive object={ghost} />
|
||||
</group>
|
||||
<Html
|
||||
center
|
||||
position={[cursor[0], cursor[1] + 0.5, 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">{diameter}" Trap</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">R/T rotate</span>
|
||||
</div>
|
||||
</Html>
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipeTrapTool
|
||||
Reference in New Issue
Block a user