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,134 @@
import type { NodeDefinition } from '@pascal-app/core'
import { rotateFittingNode } from '../shared/fitting-rotation'
import { buildDuctFittingFloorplan } from './floorplan'
import { buildDuctFittingGeometry } from './geometry'
import { ductFittingParametrics } from './parametrics'
import { getDuctFittingPorts } from './ports'
import { DuctFittingNode } from './schema'
/**
* Phase 2 of the HVAC node system — duct fittings (elbow / tee / reducer)
* and the first kind to expose typed ports (`def.ports`).
*
* Composition: `def.geometry` only, same as duct-segment. Ports are the
* architectural payload: placement tools snap onto them, and a later
* slice walks them to build the supply/return system graph.
*/
export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
kind: 'duct-fitting',
schemaVersion: 1,
schema: DuctFittingNode,
category: 'utility',
distributionRole: 'fitting',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
fittingType: 'elbow',
shape: 'round',
width: 14,
height: 8,
shape2: 'round',
width2: 14,
height2: 8,
angle: 90,
branchAngle: 90,
diameter: 6,
diameter2: 6,
ductMaterial: 'sheet-metal',
system: 'supply',
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
// `cursorAttached`: a fitting is a small connector — an offset-
// preserving drag reads as the mesh trailing the mouse, so pin its
// origin to the cursor instead.
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
duplicable: true,
deletable: true,
},
parametrics: ductFittingParametrics,
geometry: buildDuctFittingGeometry,
geometryKey: (n) =>
JSON.stringify([
n.fittingType,
// The mitered elbow + flange profiles swap width/height roles based
// on where world-up sits in the local frame, so orientation is a
// geometry input.
n.rotation,
n.shape,
n.width,
n.height,
n.shape2,
n.width2,
n.height2,
n.angle,
n.branchAngle,
n.diameter,
n.diameter2,
n.ductMaterial,
n.system,
]),
ports: getDuctFittingPorts,
floorplan: buildDuctFittingFloorplan,
// R/T rotate a selected fitting ±45° around the shared active axis.
// The default editor rotate only knows Y; fittings need X/Z for
// risers, so this overrides it. Alt-cycling of the axis + the axis
// badge live in `./selection.tsx`.
keyboardActions: {
r: {
appliesTo: (node) => node.type === 'duct-fitting',
run: (node) => rotateFittingNode(node, 1),
},
t: {
appliesTo: (node) => node.type === 'duct-fitting',
run: (node) => rotateFittingNode(node, -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'),
// Ghost-preview duplicate / move. Duplicate is pure drag-to-place: a
// translucent copy of the fitting (built from its real geometry, at its
// own rotation, so an elbow / riser stays properly aligned) follows the
// cursor and only lands on the commit click. Takes priority over
// `capabilities.movable` in the MoveTool dispatcher.
move: () => import('./move-tool'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Click', label: 'Place fitting' },
{ key: 'Hover a duct 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: 'Duct Fitting',
description: 'Elbow, tee, reducer, or square-to-round transition connecting duct runs.',
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
paletteSection: 'structure',
paletteOrder: 91,
},
mcp: {
description:
'A duct fitting (elbow, tee, reducer, or square-to-round transition) with typed connection ports. Position is level-local meters; rotation is an XYZ euler in radians.',
},
}
@@ -0,0 +1,69 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
const SUPPLY_COLOR = '#d4825a'
const RETURN_COLOR = '#5a8ad4'
const BODY_COLOR = '#9ca3af'
/**
* Floor-plan symbol for a duct fitting: one stub line per port from the
* junction center out to the collar (drawn at each collar's real
* diameter), plus a junction circle. Ports are computed in level-local
* 3D and projected to plan, so a rotated or riser-turned fitting shows
* its true plan footprint; a vertical port collapses onto the junction
* circle, which is exactly how it should read from above.
*/
export function buildDuctFittingFloorplan(
node: DuctFittingNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const [cx, , cz] = node.position
const ports = getDuctFittingPorts(node)
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
const accent = node.system === 'supply' ? SUPPLY_COLOR : RETURN_COLOR
const bodyStroke = showSelectedChrome && palette ? palette.selectedStroke : BODY_COLOR
const children: FloorplanGeometry[] = []
for (const port of ports) {
const px = port.position[0]
const pz = port.position[2]
// Vertical port — projects onto the junction itself; skip the stub.
if (Math.hypot(px - cx, pz - cz) < 1e-4) continue
children.push({
kind: 'line',
x1: cx,
y1: cz,
x2: px,
y2: pz,
stroke: bodyStroke,
strokeWidth: port.diameter * INCHES_TO_METERS,
strokeLinecap: 'round',
opacity: showSelectedChrome ? 0.95 : 0.8,
})
}
children.push({
kind: 'circle',
cx,
cy: cz,
r: (node.diameter * INCHES_TO_METERS) / 2 + 0.015,
fill: bodyStroke,
stroke: accent,
strokeWidth: 1.5,
vectorEffect: 'non-scaling-stroke',
opacity: 0.95,
})
if (showSelectedChrome) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
}
return { kind: 'group', children }
}
+463
View File
@@ -0,0 +1,463 @@
import {
BufferGeometry,
CylinderGeometry,
DoubleSide,
Euler,
Float32BufferAttribute,
Group,
Mesh,
type MeshStandardMaterial,
SphereGeometry,
TorusGeometry,
Vector3,
} from 'three'
import {
buildOvalSection,
buildRectSection,
buildSection,
createDuctMaterial,
INCHES_TO_METERS,
} from '../duct-segment/geometry'
import { localFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
const RADIAL_SEGMENTS = 24
const UP = new Vector3(0, 1, 0)
/**
* Mitered rectangular elbow as ONE closed solid — the way sheet-metal
* square elbows are actually folded. The rect profile sweeps from the
* inlet face to the outlet face through a single miter ring lying on
* the corner's bisector plane (the classic 2D miter-join offset:
* join(u) = (wA + wB) · u / (1 + wA·wB)), so the two legs meet in a
* crisp seam instead of interpenetrating boxes.
*
* Local frame: legs in the XZ plane (ports convention) so the fold hinge
* is always local Y. `sweepM` is the profile dimension carried through the
* bend (in the XZ bend plane); `cheekM` is the dimension that stays
* constant along the hinge. Which physical dimension (width vs height)
* plays each role depends on the elbow's world orientation and is decided
* by the caller — a floor turn folds about vertical (cheek = height),
* a wall riser folds about horizontal (cheek = width).
*
* Non-indexed triangles → flat face normals for the folded-metal look;
* the closed solid renders double-sided so winding never makes a face
* vanish.
*/
/**
* Stadium (flat-oval) outline in profile (u, v) coordinates: u-extent
* `uM`, v-extent `vM`, semicircular caps of the smaller dimension. The
* caps land on whichever axis is longer, so a riser-rotated profile
* (swapped roles) stays a valid stadium.
*/
function stadiumOutline(uM: number, vM: number, samplesPerCap = 10): Array<[number, number]> {
const pts: Array<[number, number]> = []
const r = Math.min(uM, vM) / 2
const s = (Math.max(uM, vM) - Math.min(uM, vM)) / 2
const cap = (cu: number, cv: number, startA: number) => {
for (let i = 0; i <= samplesPerCap; i++) {
const a = startA + (Math.PI * i) / samplesPerCap
pts.push([cu + r * Math.cos(a), cv + r * Math.sin(a)])
}
}
if (uM >= vM) {
cap(s, 0, -Math.PI / 2)
cap(-s, 0, Math.PI / 2)
} else {
cap(0, s, 0)
cap(0, -s, Math.PI)
}
return pts
}
function buildMiteredElbow(
inletPos: Vector3,
outletPos: Vector3,
sweepM: number,
cheekM: number,
profileShape: 'rect' | 'oval',
material: MeshStandardMaterial,
): Mesh {
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
const travelOut = outletPos.clone().normalize() // junction → outlet
const wA = new Vector3().crossVectors(UP, travelIn).normalize()
const wB = new Vector3().crossVectors(UP, travelOut).normalize()
// Elbow turns are ≤ 90°, so wA·wB ≥ 0 and the join never degenerates.
const miterScale = 1 / (1 + wA.dot(wB))
const wJoin = new Vector3().addVectors(wA, wB)
const hw = sweepM / 2
const hh = cheekM / 2
const corners: Array<[number, number]> =
profileShape === 'oval'
? stadiumOutline(sweepM, cheekM)
: [
[hw, hh],
[-hw, hh],
[-hw, -hh],
[hw, -hh],
]
const n = corners.length
const ring = (center: Vector3, uAxis: Vector3, scale = 1): Vector3[] =>
corners.map(([u, v]) =>
center
.clone()
.addScaledVector(uAxis, u * scale)
.addScaledVector(UP, v),
)
const inletRing = ring(inletPos, wA)
const miterRing = ring(new Vector3(0, 0, 0), wJoin, miterScale)
const outletRing = ring(outletPos, wB)
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
const quad = (a: Vector3, b: Vector3, c: Vector3, d: Vector3) => {
tri(a, b, c)
tri(a, c, d)
}
const skin = (from: Vector3[], to: Vector3[]) => {
for (let k = 0; k < n; k++) {
const k2 = (k + 1) % n
quad(from[k]!, to[k]!, to[k2]!, from[k2]!)
}
}
skin(inletRing, miterRing)
skin(miterRing, outletRing)
// End caps — triangle fans so any convex profile closes.
for (let k = 1; k < n - 1; k++) {
tri(inletRing[0]!, inletRing[k]!, inletRing[k + 1]!)
tri(outletRing[k + 1]!, outletRing[k]!, outletRing[0]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = `fitting-elbow-${profileShape}`
return mesh
}
/**
* Square-to-round loft between a rect ring at `xRect` and a round ring
* at `xRound`, both centered on the local X axis (the straight-through
* run). Profiles are sampled at matching polar angles — the rect point
* is the ray's intersection with the rectangle boundary — so the skin
* twists nowhere. Non-indexed triangles + computed normals give the
* faceted gore look of a real shop-made square-to-round.
*/
function buildRectToRoundLoft(
xRect: number,
xRound: number,
widthM: number,
heightM: number,
radius: number,
material: MeshStandardMaterial,
): Mesh {
const hw = widthM / 2
const hh = heightM / 2
const rectRing: Vector3[] = []
const roundRing: Vector3[] = []
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const theta = (2 * Math.PI * i) / RADIAL_SEGMENTS
const cz = Math.cos(theta)
const sy = Math.sin(theta)
// Scale the unit ray until it hits the rectangle boundary. Width
// spans local Z and height local Y — the same axes buildRectSection
// gives a +X run.
const t = 1 / Math.max(Math.abs(cz) / hw, Math.abs(sy) / hh)
rectRing.push(new Vector3(xRect, t * sy, t * cz))
roundRing.push(new Vector3(xRound, radius * sy, radius * cz))
}
const positions: number[] = []
const tri = (a: Vector3, b: Vector3, c: Vector3) =>
positions.push(a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z)
for (let i = 0; i < RADIAL_SEGMENTS; i++) {
const j = (i + 1) % RADIAL_SEGMENTS
tri(rectRing[i]!, roundRing[i]!, roundRing[j]!)
tri(rectRing[i]!, roundRing[j]!, rectRing[j]!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
const solidMaterial = material.clone()
solidMaterial.side = DoubleSide
const mesh = new Mesh(geometry, solidMaterial)
mesh.name = 'fitting-transition-loft'
return mesh
}
/**
* Pure geometry builder for a duct fitting, in the fitting's LOCAL frame —
* `<ParametricNodeRenderer>` applies `node.position` / `node.rotation`.
*
* Strategy: one cylinder stub per port from the junction center outward
* (reusing the segment builder's `buildSection`), a sphere at the
* junction, and a slightly-oversized crimp collar ring at each port
* opening so fittings read as sheet-metal junctions rather than bare
* tube ends.
*
* The reducer is special-cased: instead of equal stubs + sphere it draws
* a short inlet stub, a tapered cone, and a short outlet stub inline.
*
* Non-round shapes (elbow / tee): run legs carry the fitting's
* width × height profile — rect prisms or flat-oval stadiums — matching
* the trunk they join; a tee's branch leg carries its own `shape2`
* profile (width2 × height2, or round at `diameter2`). The profile's
* height rides local +Y — for the horizontal-plane orientations trunks
* are drawn in, that's world-vertical.
*/
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
const group = new Group()
const material = createDuctMaterial(node)
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
const ports = localFittingPorts(node)
const widthM = node.width * INCHES_TO_METERS
const heightM = node.height * INCHES_TO_METERS
// The elbow folds about its local Y. Width spans the XZ bend plane and
// height rides the hinge ONLY when local Y is world-vertical (a floor
// turn). For a riser the node is rotated so local Y lands horizontal —
// then it's width that runs along the hinge, so the roles swap. Pick by
// where world-up sits in the fitting's local frame.
const hingeWorld = UP.clone().applyEuler(
new Euler(node.rotation[0], node.rotation[1], node.rotation[2]),
)
const hingeIsVertical = Math.abs(hingeWorld.y) >= Math.SQRT1_2
if (node.fittingType === 'reducer') {
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
radiusMain,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
const cone = new Mesh(
new CylinderGeometry(radiusOut, radiusMain, taperHalf * 2, RADIAL_SEGMENTS, 1, false),
material,
)
cone.name = 'fitting-taper'
cone.quaternion.setFromUnitVectors(UP, new Vector3(1, 0, 0))
group.add(cone)
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.fittingType === 'transition') {
// Square-to-round: rect stub on the inlet, lofted gore body through
// the junction, round stub on the outlet. Same inline layout as the
// reducer, with the taper replaced by the loft.
const radiusOut = (node.diameter2 * INCHES_TO_METERS) / 2
const inlet = ports[0]!
const outlet = ports[1]!
const taperHalf = Math.abs(inlet.position.x) / 3
const stubA = buildRectSection(
inlet.position,
new Vector3(-taperHalf, 0, 0),
widthM,
heightM,
material,
'fitting-stub-inlet',
)
if (stubA) group.add(stubA)
group.add(buildRectToRoundLoft(-taperHalf, taperHalf, widthM, heightM, radiusOut, material))
const stubB = buildSection(
new Vector3(taperHalf, 0, 0),
outlet.position,
radiusOut,
material,
'fitting-stub-outlet',
)
if (stubB) group.add(stubB)
} else if (node.shape !== 'round' && node.fittingType === 'elbow') {
// One mitered solid — no stubs, no junction blob. Oval profiles
// sweep the same way; the ring is a stadium instead of 4 corners.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
group.add(
buildMiteredElbow(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
node.shape,
material,
),
)
} else if (node.shape !== 'round' && node.fittingType === 'tee') {
// Straight rect / oval run inlet→outlet (one prism — nothing to
// miter) plus a branch leg tapping its side. The branch carries its
// own profile: rect or oval at width2 × height2, round at diameter2.
//
// Same orientation swap as the elbow: the run prism and branch stub
// are built on the `rectSectionAxes` basis, whose height rides local
// +Y. That's world-vertical only when the tee's local Y stays vertical
// (a flat tap off a horizontal trunk). When the tee is rotated so
// local Y lands horizontal, width and height roles swap so the
// physical height keeps reading as the vertical face — without this a
// tee drawn along the perpendicular axis looks squished.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
'fitting-stub-branch',
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
'fitting-stub-branch',
)
if (stub) group.add(stub)
} else if (node.shape !== 'round' && node.fittingType === 'cross') {
// Straight rect / oval run inlet→outlet plus two opposed branch legs
// (±Z) carrying the branch profile — both halves of the run that
// passed through, same size at `width2 × height2` / `diameter2`. Same
// orientation swap as the tee / elbow so the cross stays upright when
// rotated so its local Y lands horizontal.
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
const buildRunSection = node.shape === 'oval' ? buildOvalSection : buildRectSection
const run = buildRunSection(
inlet.position,
outlet.position,
hingeIsVertical ? widthM : heightM,
hingeIsVertical ? heightM : widthM,
material,
'fitting-run',
)
if (run) group.add(run)
const buildBranchSection = node.shape2 === 'oval' ? buildOvalSection : buildRectSection
for (const id of ['branch', 'branch2'] as const) {
const branch = ports.find((p) => p.id === id)!
const stub =
node.shape2 !== 'round'
? buildBranchSection(
new Vector3(0, 0, 0),
branch.position,
hingeIsVertical ? width2M : height2M,
hingeIsVertical ? height2M : width2M,
material,
`fitting-stub-${id}`,
)
: buildSection(
new Vector3(0, 0, 0),
branch.position,
(branch.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${id}`,
)
if (stub) group.add(stub)
}
} else {
for (const port of ports) {
const stub = buildSection(
new Vector3(0, 0, 0),
port.position,
(port.diameter * INCHES_TO_METERS) / 2,
material,
`fitting-stub-${port.id}`,
)
if (stub) group.add(stub)
}
const junction = new Mesh(new SphereGeometry(radiusMain * 1.02, RADIAL_SEGMENTS, 12), material)
junction.name = 'fitting-junction'
group.add(junction)
}
// Joint trim at each opening. Round legs get a crimp-collar torus just
// proud of the stub; rect legs get a drive-cleat flange — the thin
// raised rim (TDC/S-cleat) real sheet-metal trunk joints wear where a
// section meets a fitting. The plate is centered on the collar plane so
// the rim reads as the seam between fitting and duct. Run legs
// (inlet/outlet) are rect when `shape` is rect; a rect tee's branch is
// rect when `shape2` is rect. Reducers ignore shape.
// Which profile a leg's opening carries: a transition's inlet is its
// rect end regardless of `shape`; reducers are always round; otherwise
// the run legs follow `shape` and a tee's branch follows `shape2`
// (only meaningful when the run itself is non-round).
const legShape = (portId: string): 'round' | 'rect' | 'oval' => {
if (node.fittingType === 'transition') return portId === 'inlet' ? 'rect' : 'round'
if (node.fittingType === 'reducer' || node.shape === 'round') return 'round'
return portId === 'branch' || portId === 'branch2' ? node.shape2 : node.shape
}
// The flange's profile must match the leg it caps: the branch carries
// its own width2 × height2; elbow legs swap width/height roles when the
// fold hinge lies horizontal (riser elbows) — same choice as the
// mitered solid above.
const rectLegProfile = (portId: string): [number, number] => {
if (portId === 'branch' || portId === 'branch2') {
const width2M = node.width2 * INCHES_TO_METERS
const height2M = node.height2 * INCHES_TO_METERS
return hingeIsVertical ? [width2M, height2M] : [height2M, width2M]
}
if (!hingeIsVertical) return [heightM, widthM]
return [widthM, heightM]
}
const FLANGE_LIP_M = 0.02
const FLANGE_THICK_M = 0.012
for (const port of ports) {
const profile = legShape(port.id)
if (profile !== 'round') {
const [w, h] = rectLegProfile(port.id)
const start = port.position.clone().addScaledVector(port.direction, -FLANGE_THICK_M / 2)
const end = port.position.clone().addScaledVector(port.direction, FLANGE_THICK_M / 2)
const buildFlange = profile === 'oval' ? buildOvalSection : buildRectSection
const flange = buildFlange(
start,
end,
w + FLANGE_LIP_M * 2,
h + FLANGE_LIP_M * 2,
material,
`fitting-flange-${port.id}`,
)
if (flange) group.add(flange)
continue
}
const radius = (port.diameter * INCHES_TO_METERS) / 2
const collar = new Mesh(new TorusGeometry(radius, radius * 0.12, 8, RADIAL_SEGMENTS), material)
collar.name = `fitting-collar-${port.id}`
collar.position.copy(port.position)
collar.quaternion.setFromUnitVectors(new Vector3(0, 0, 1), port.direction)
group.add(collar)
}
return group
}
+4
View File
@@ -0,0 +1,4 @@
export { ductFittingDefinition } from './definition'
export { buildDuctFittingGeometry } from './geometry'
export { getDuctFittingPorts } from './ports'
export { DuctFittingNode } from './schema'
@@ -0,0 +1,286 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
DuctFittingNode,
emitter,
type GridEvent,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
DragBoundingBox,
EDITOR_LAYER,
markToolCancelConsumed,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
import { buildDuctFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
const GHOST_COLOR = '#818cf8'
const GHOST_OPACITY = 0.5
/** Snap a coordinate to the editor's live grid step. */
function snapToGridStep(value: number): number {
const step = useEditor.getState().gridSnapStep
if (step <= 0) return value
return Math.round(value / step) * step
}
/** World-space size + centre offset of `box` after the fitting's euler
* rotation — the footprint box that wraps the oriented geometry. */
function rotatedBounds(box: Box3, rotation: Vec3): { size: Vec3; offset: Vec3 } {
const euler = new Euler(rotation[0], rotation[1], rotation[2])
const min = box.min
const max = box.max
const corners: Vec3[] = [
[min.x, min.y, min.z],
[max.x, min.y, min.z],
[min.x, max.y, min.z],
[min.x, min.y, max.z],
[max.x, max.y, min.z],
[max.x, min.y, max.z],
[min.x, max.y, max.z],
[max.x, max.y, max.z],
]
const lo: Vec3 = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
const hi: Vec3 = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY]
const v = new Vector3()
for (const c of corners) {
v.set(c[0], c[1], c[2]).applyEuler(euler)
lo[0] = Math.min(lo[0], v.x)
lo[1] = Math.min(lo[1], v.y)
lo[2] = Math.min(lo[2], v.z)
hi[0] = Math.max(hi[0], v.x)
hi[1] = Math.max(hi[1], v.y)
hi[2] = Math.max(hi[2], v.z)
}
return {
size: [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]],
offset: [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2],
}
}
/**
* Ghost-preview duplicate / move tool for duct fittings (elbow / tee /
* reducer / transition).
*
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
* inserted into the scene until the commit click. A translucent copy of the
* fitting (built from its real geometry, at its own `rotation`, so an elbow
* / riser stays properly aligned) rides the cursor inside a footprint
* bounding box — the same affordance other items get — and Figma-style
* alignment guides snap the box edges to nearby geometry. The commit click
* calls `createNode`; Esc discards.
*
* **Move** (existing fitting): the real node is hidden while the ghost + box
* track the cursor; commit writes the new `position` and reveals it.
*
* Wired via `def.affordanceTools.move`.
*/
export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
const fitting = node as DuctFittingNode
const originalPosition = (fitting.position ?? [0, 0, 0]) as Vec3
const rotation = (fitting.rotation ?? [0, 0, 0]) as Vec3
const isNew =
typeof node.metadata === 'object' &&
node.metadata !== null &&
!Array.isArray(node.metadata) &&
(node.metadata as Record<string, unknown>).isNew === true
const [cursorPos, setCursorPos] = useState<Vec3>(originalPosition)
// Translucent stand-in built from the fitting's real geometry. Rotation is
// a geometry input (it decides the elbow's profile roles), so the ghost
// matches what lands. Rebuilt only if the source changes.
const ghost = useMemo(() => {
const group = buildDuctFittingGeometry(fitting)
group.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.material = new MeshBasicMaterial({
color: GHOST_COLOR,
transparent: true,
opacity: GHOST_OPACITY,
depthTest: false,
})
mesh.renderOrder = 999
}
obj.layers.set(EDITOR_LAYER)
})
return group
}, [fitting])
// Footprint box that wraps the oriented geometry (size + centre offset),
// measured once from the ghost.
const bounds = useMemo(() => {
const box = new Box3().setFromObject(ghost)
if (box.isEmpty()) return { size: [0.3, 0.3, 0.3] as Vec3, offset: [0, 0, 0] as Vec3 }
return rotatedBounds(box, rotation)
}, [ghost, rotation])
useEffect(() => {
return () => {
ghost.traverse((obj) => {
const mesh = obj as Mesh
if ((mesh as { isMesh?: boolean }).isMesh) {
mesh.geometry?.dispose?.()
const mat = mesh.material as Material | Material[]
if (Array.isArray(mat)) for (const m of mat) m.dispose?.()
else mat?.dispose?.()
}
})
}
}, [ghost])
useEffect(() => {
const nodeId = node.id as AnyNodeId
const [hx, , hz] = [bounds.size[0] / 2, 0, bounds.size[2] / 2]
const [ox, , oz] = bounds.offset
useScene.temporal.getState().pause()
let committed = false
let hasMoved = false
const activatedAt = Date.now()
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
useScene.getState().nodes,
nodeId,
useViewer.getState().selection.levelId ?? node.parentId,
)
// Moving an existing fitting: hide its 3D MESH imperatively (NOT the
// store `visible` flag — the 2D floor plan skips `visible:false` nodes,
// so a store hide makes it vanish in 2D / split view). The ghost stands
// in until commit; the real mesh is restored on cancel / unmount.
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
const setMeshHidden = (hidden: boolean) => {
const obj = sceneRegistry.nodes.get(nodeId)
if (obj) obj.visible = !hidden
}
if (existedAtStart) setMeshHidden(true)
let lastPos: Vec3 = originalPosition
const onMove = (event: GridEvent) => {
const bypass = event.nativeEvent?.shiftKey === true
const snap = bypass ? (v: number) => v : snapToGridStep
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
// Alignment: snap the footprint box edges onto nearby geometry and
// publish guides (Alt / Shift bypass).
if (!bypass) {
const proposed: Aabb2D = {
minX: x + ox - hx,
maxX: x + ox + hx,
minZ: z + oz - hz,
maxZ: z + oz + hz,
}
const { dx, dz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
x += dx
z += dz
useAlignmentGuides.getState().set(guides)
} else {
useAlignmentGuides.getState().clear()
}
const next: Vec3 = [x, originalPosition[1], z]
if (next[0] !== lastPos[0] || next[2] !== lastPos[2]) triggerSFX('sfx:grid-snap')
lastPos = next
hasMoved = true
setCursorPos(next)
}
const commit = (event: GridEvent) => {
if (committed) return
if (Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
if (!hasMoved) {
event.nativeEvent?.stopPropagation?.()
return
}
committed = true
useScene.temporal.getState().resume()
let selectId = nodeId
if (isNew && !useScene.getState().nodes[nodeId]) {
const created = DuctFittingNode.parse({
...(node as Record<string, unknown>),
position: lastPos,
metadata: stripPlacementMetadataFlags(node.metadata),
visible: true,
})
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
useScene.getState().updateNode(nodeId, { position: lastPos } as Partial<AnyNode>)
useScene.getState().markDirty(nodeId)
}
useScene.temporal.getState().pause()
setMeshHidden(false)
useAlignmentGuides.getState().clear()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [selectId] })
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
useAlignmentGuides.getState().clear()
useScene.temporal.getState().resume()
markToolCancelConsumed()
useEditor.getState().setMovingNodeOrigin('3d')
useEditor.getState().setMovingNode(null)
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', commit)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
}
}, [bounds, isNew, node, originalPosition])
return (
<group>
<primitive object={ghost} position={cursorPos} rotation={rotation} />
<DragBoundingBox
centerY={bounds.offset[1]}
nodeId={node.id}
position={[cursorPos[0] + bounds.offset[0], cursorPos[1], cursorPos[2] + bounds.offset[2]]}
size={bounds.size}
/>
</group>
)
}
export default MoveDuctFittingTool
@@ -0,0 +1,293 @@
import {
type AnyNode,
type AnyNodeId,
type DuctSegmentNode,
type ParametricDescriptor,
useScene,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import {
ductPortDiameterIn,
equivalentDiameterIn,
ovalEquivalentDiameterIn,
rollToContinueAcrossElbow,
} from '../duct-segment/geometry'
import { getDuctFittingPorts } from './ports'
import type { DuctFittingNode } from './schema'
/** Schema bounds for `diameter` / `diameter2`. */
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
/** A duct endpoint sitting this close to a collar counts as mated. */
const MATE_TOL_M = 0.03
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
/**
* Ducts whose endpoint sits ON one of the fitting's collars, keyed by
* port id. Auto-minted joints place duct ends exactly on the collar, so
* a tight distance check is enough — no connectivity graph yet.
*/
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
const mates = new Map<string, DuctMate>()
const ports = getDuctFittingPorts(fitting)
for (const node of Object.values(useScene.getState().nodes)) {
if (node.type !== 'duct-segment') continue
const duct = node as DuctSegmentNode
for (const endIndex of [0, duct.path.length - 1]) {
const p = duct.path[endIndex]
if (!p) continue
for (const port of ports) {
if (mates.has(port.id)) continue
const dx = p[0] - port.position[0]
const dy = p[1] - port.position[1]
const dz = p[2] - port.position[2]
if (dx * dx + dy * dy + dz * dz <= MATE_TOL_M * MATE_TOL_M) {
mates.set(port.id, { duct, endIndex })
}
}
}
}
return mates
}
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
// Switching the run legs round↔rect flips the whole fitting and sizes
// the new profile off the ducts actually mated to its collars, so the
// fitting lands flush instead of at schema defaults. The tee branch
// follows its own mated duct (or the run shape when nothing is mated);
// `shape2` stays editable afterwards for mixed taps. Rect profiles
// also write their area-equivalent round size back into `diameter` /
// `diameter2`, which drive leg lengths + advertised ports — without
// this the legs keep the stale round size.
derive: (next, patch) => {
const out: Partial<DuctFittingNode> = {}
if ('shape' in patch && next.fittingType !== 'reducer') {
// `next` still carries the pre-edit diameters, so its ports sit
// where the mated ducts end — size off the actual neighbours.
const mates = matedDucts(next)
const run = (mates.get('inlet') ?? mates.get('outlet'))?.duct
if (next.shape !== 'round' && run?.shape === next.shape) {
out.width = run.width
out.height = run.height
} else if (next.shape === 'round' && run && run.shape !== 'rect') {
// Oval runs present their area-equivalent round size.
out.diameter = clampDiameter(ductPortDiameterIn(run))
}
if (next.fittingType === 'tee' || next.fittingType === 'cross') {
// A cross's two branches share one profile — size off whichever
// branch leg has a duct mated (both halves are the same run).
const branchDuct = (mates.get('branch') ?? mates.get('branch2'))?.duct
out.shape2 = branchDuct?.shape ?? next.shape
if (branchDuct && branchDuct.shape !== 'round') {
out.width2 = branchDuct.width
out.height2 = branchDuct.height
} else if (branchDuct) {
out.diameter2 = clampDiameter(ductPortDiameterIn(branchDuct))
}
}
}
// Non-round legs write their area-equivalent round size back into the
// diameters (leg lengths + advertised ports). A transition's inlet is
// always the rect end regardless of `shape`.
const runShape = next.fittingType === 'transition' ? 'rect' : next.shape
if (runShape !== 'round' && next.fittingType !== 'reducer') {
const equivalent = runShape === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter = clampDiameter(equivalent(out.width ?? next.width, out.height ?? next.height))
}
const shape2 = out.shape2 ?? next.shape2
if ((next.fittingType === 'tee' || next.fittingType === 'cross') && shape2 !== 'round') {
const equivalent2 = shape2 === 'oval' ? ovalEquivalentDiameterIn : equivalentDiameterIn
out.diameter2 = clampDiameter(
equivalent2(out.width2 ?? next.width2, out.height2 ?? next.height2),
)
}
return out
},
// Resizing a fitting moves its collars (leg lengths follow the
// diameters) — re-trim each mated duct's endpoint onto the collar's
// new position so metal keeps meeting metal instead of overlapping
// one neighbour and gapping off another.
reconcile: (prev, next) => {
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
const newPorts = new Map(getDuctFittingPorts(next).map((p) => [p.id, p]))
const mates = matedDucts(prev)
for (const [portId, mate] of mates) {
const target = newPorts.get(portId)
if (!target) continue
const end = mate.duct.path[mate.endIndex]
if (!end) continue
const data: Partial<DuctSegmentNode> = {}
const dx = end[0] - target.position[0]
const dy = end[1] - target.position[1]
const dz = end[2] - target.position[2]
if (dx * dx + dy * dy + dz * dz >= 1e-12) {
const path = mate.duct.path.map((p) => [...p] as [number, number, number])
path[mate.endIndex] = [...target.position]
data.path = path
}
// Steep rect / oval runs also re-derive their cross-section roll
// so a riser's profile stays continuous through the fitting (same
// continuity the draw tool computes; runs flipped to rect after
// drawing never got it). Horizontal runs are left alone — their
// roll-0 orientation is canonical and re-deriving it from a
// possibly-stale riser roll would corrupt it.
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
const source = getDuctFittingPorts(next).find(
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
)
if (away && source) {
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
if (newDir.lengthSq() >= 1e-10) {
newDir.normalize()
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
const srcMate = mates.get(source.id)
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
const srcDir = new Vector3(...source.direction)
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
}
}
}
}
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
}
return updates
},
groups: [
{
label: 'Fitting',
fields: [
{
key: 'fittingType',
kind: 'enum',
options: ['elbow', 'tee', 'cross', 'reducer', 'transition'],
display: 'segmented',
},
{
key: 'angle',
kind: 'number',
unit: '°',
min: 15,
max: 90,
step: 15,
visibleIf: (n) => n.fittingType === 'elbow',
},
{
key: 'branchAngle',
kind: 'number',
unit: '°',
min: 45,
max: 135,
step: 15,
visibleIf: (n) => n.fittingType === 'tee',
},
{
key: 'system',
kind: 'enum',
options: ['supply', 'return'],
display: 'segmented',
},
],
},
{
label: 'Connections',
fields: [
{
key: 'shape',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
// Reducers are always round; a transition's ends are fixed
// (rect inlet, round outlet) so there's nothing to pick.
visibleIf: (n) => n.fittingType !== 'reducer' && n.fittingType !== 'transition',
},
{
key: 'diameter',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
// Hidden when the run legs are rect / oval (transition's inlet
// always is) — `diameter` is then derived as the area equivalent.
visibleIf: (n) =>
n.fittingType === 'reducer' || (n.fittingType !== 'transition' && n.shape === 'round'),
},
{
key: 'width',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'height',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
},
{
key: 'shape2',
kind: 'enum',
options: ['round', 'rect', 'oval'],
display: 'segmented',
visibleIf: (n) => n.fittingType === 'tee' || n.fittingType === 'cross',
},
{
key: 'diameter2',
kind: 'number',
unit: 'in',
min: 4,
max: 24,
step: 1,
visibleIf: (n) =>
n.fittingType !== 'elbow' &&
(n.fittingType !== 'tee' || n.shape2 === 'round') &&
(n.fittingType !== 'cross' || n.shape2 === 'round'),
},
{
key: 'width2',
kind: 'number',
unit: 'in',
min: 4,
max: 60,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'height2',
kind: 'number',
unit: 'in',
min: 3,
max: 40,
step: 1,
visibleIf: (n) =>
(n.fittingType === 'tee' || n.fittingType === 'cross') && n.shape2 !== 'round',
},
{
key: 'ductMaterial',
kind: 'enum',
options: ['sheet-metal', 'flex', 'duct-board'],
},
],
},
{
label: 'Placement',
fields: [
{ key: 'position', kind: 'vec3' },
{ key: 'rotation', kind: 'vec3' },
],
},
],
}
+147
View File
@@ -0,0 +1,147 @@
import type { NodePort } from '@pascal-app/core'
import { Euler, Vector3 } from 'three'
import { INCHES_TO_METERS } from '../duct-segment/geometry'
import type { DuctFittingNode } from './schema'
/**
* Collar stub length in meters — how far each port sticks out from the
* fitting's junction center. Scales with the duct so big trunks get
* proportionally longer collars, with a floor so 4" fittings stay
* grabbable.
*/
export function fittingLegLength(diameterInches: number): number {
const radius = (diameterInches * INCHES_TO_METERS) / 2
return Math.max(0.14, radius * 2.5)
}
type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number }
/**
* Ports in the fitting's LOCAL frame (origin at the junction center,
* before `position`/`rotation`). Shared by `def.ports` (which transforms
* them to level-local) and the geometry builder (which draws a stub per
* port).
*
* Conventions documented on the schema: elbow inlet -X / outlet turned
* `angle`° in XZ; tee run along X with the branch at `branchAngle`° off
* the +X outlet axis (90° → +Z square tee, 45° → downstream lateral,
* 135° → upstream lateral); reducer -X → +X.
*/
export function localFittingPorts(node: DuctFittingNode): LocalPort[] {
const main = fittingLegLength(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 [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: outDir.clone().multiplyScalar(main),
direction: outDir,
diameter: node.diameter,
},
]
}
if (node.fittingType === 'tee') {
const branch = fittingLegLength(node.diameter2)
// Branch leans `branchAngle`° off the +X outlet axis in XZ: 90° is a
// square tap (+Z), shallower angles sweep the branch downstream
// toward the outlet so the lateral merges with the run's flow, and
// angles past 90° lean it upstream toward the inlet (cos goes
// negative, swinging the collar to -X).
const phi = (node.branchAngle * Math.PI) / 180
const branchDir = new Vector3(Math.cos(phi), 0, Math.sin(phi))
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter,
},
{
id: 'branch',
position: branchDir.clone().multiplyScalar(branch),
direction: branchDir,
diameter: node.diameter2,
},
]
}
if (node.fittingType === 'cross') {
// Four-way junction: run inlet -X / outlet +X at the run profile,
// two opposed branches square to the run along ±Z at the branch
// profile. Both branches share `diameter2` (one drawn run passes
// straight through, so its two halves are the same size).
const branch = fittingLegLength(node.diameter2)
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter,
},
{
id: 'branch',
position: new Vector3(0, 0, branch),
direction: new Vector3(0, 0, 1),
diameter: node.diameter2,
},
{
id: 'branch2',
position: new Vector3(0, 0, -branch),
direction: new Vector3(0, 0, -1),
diameter: node.diameter2,
},
]
}
// reducer / transition: straight-through, inlet at `diameter` (the
// transition's rect end advertises its area-equivalent round size),
// outlet at `diameter2`.
return [
{
id: 'inlet',
position: new Vector3(-main, 0, 0),
direction: new Vector3(-1, 0, 0),
diameter: node.diameter,
},
{
id: 'outlet',
position: new Vector3(main, 0, 0),
direction: new Vector3(1, 0, 0),
diameter: node.diameter2,
},
]
}
/** `def.ports` — local ports transformed into level-local space. */
export function getDuctFittingPorts(node: DuctFittingNode): 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 localFittingPorts(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 { DuctFittingNode } 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 fittings, mounted by the
* editor's SelectionAffordanceManager (`def.affordanceTools.selection`).
* The R/T rotation itself lives in `def.keyboardActions` (the editor's
* keyboard hook dispatches it); 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 above the selected
* fitting — so this component renders nothing.
*/
const DuctFittingSelectionAffordance = () => {
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 === 'duct-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 DuctFittingSelectionAffordance
+253
View File
@@ -0,0 +1,253 @@
'use client'
import { DuctFittingNode, emitter, type GridEvent, 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,
DUCT_PORT_SYSTEMS,
findNearestPortXZ,
type ScenePort,
} from '../shared/ports'
import { ductFittingDefinition } from './definition'
import { buildDuctFittingGeometry } from './geometry'
import { localFittingPorts } from './ports'
/** Snap radius (meters, XZ) for mating onto an existing 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 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: DuctFittingNode,
gridStep: number,
manualQuat: Quaternion,
): Placement {
const port = findNearestPortXZ(
raw,
collectScenePorts({ systems: DUCT_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 = localFittingPorts(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 duct fittings (elbow / tee / reducer).
*
* A translucent ghost of the fitting follows the cursor. Within snap
* range of any scene port (duct 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 DuctFittingTool = () => {
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(
() => DuctFittingNode.parse({ ...ductFittingDefinition.defaults(), name: 'Duct fitting' }),
[],
)
const ghost = useMemo(() => {
const group = buildDuctFittingGeometry(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 = DuctFittingNode.parse({
...ductFittingDefinition.defaults(),
name: 'Duct 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 `duct-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 DuctFittingTool