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,68 @@
import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
/**
* Shared "drag a path point" floor-plan affordance for polyline
* distribution kinds (duct-segment / pipe-segment / lineset). It is the
* 2D counterpart of their 3D `affordanceTools.selection` handles: one
* draggable handle per path vertex, moved freely on the plan (XZ) with
* grid snap (Shift bypasses). The vertex's Y (elevation / slope) is held
* fixed — plan editing never changes height.
*
* Wired via `def.floorplanAffordances['move-path-point']`; the floor-plan
* builders emit `endpoint-handle` primitives carrying `{ pointIndex }` so
* the dispatcher routes pointer-downs here.
*/
export type PathPointPayload = { pointIndex: number }
type PathShape = { path: ReadonlyArray<readonly [number, number, number]> }
export function createPathPointMoveAffordance<N extends PathShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
const inert: FloorplanAffordanceSession = {
affectedIds: [],
apply() {},
canCommit() {
return false
},
}
return {
start({ node, payload }): FloorplanAffordanceSession {
const { pointIndex } = payload as PathPointPayload
const initialPath = node.path.map((p) => [...p] as [number, number, number])
const target = initialPath[pointIndex]
if (!target) return { ...inert, affectedIds: [node.id] }
// Hold the dragged vertex's elevation — the plan move only shifts XZ.
const y = target[1]
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
// Plan coords map x→world X, y→world Z.
const raw: WallPlanPoint = [planPoint[0], planPoint[1]]
const [sx, sz] = modifiers.shiftKey ? raw : snapPointToGrid(raw)
const nextPath = initialPath.map((p, i) =>
i === pointIndex ? ([sx, y, sz] as [number, number, number]) : p,
)
useScene
.getState()
.updateNodes([{ id: node.id, data: { path: nextPath } as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
return (
!!final &&
(final as unknown as { type: string }).type === kind &&
final.path.length >= 2
)
},
}
},
}
}