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,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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user