486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
import type { GeometryContext } from '@pascal-app/core'
|
||
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
|
||
import {
|
||
BufferGeometry,
|
||
CylinderGeometry,
|
||
DoubleSide,
|
||
Euler,
|
||
Float32BufferAttribute,
|
||
Group,
|
||
type Material,
|
||
Mesh,
|
||
SphereGeometry,
|
||
TorusGeometry,
|
||
Vector3,
|
||
} from 'three'
|
||
import {
|
||
buildOvalSection,
|
||
buildRectSection,
|
||
buildSection,
|
||
createDuctMaterial,
|
||
INCHES_TO_METERS,
|
||
} from '../duct-segment/geometry'
|
||
import { DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||
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: Material,
|
||
): 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: Material,
|
||
): 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,
|
||
ctx?: GeometryContext,
|
||
shading: RenderShading = 'rendered',
|
||
textures = true,
|
||
colorPreset: ColorPreset = 'clay',
|
||
sceneTheme?: string,
|
||
): Group {
|
||
const group = new Group()
|
||
const material = createDuctMaterial(
|
||
node,
|
||
ctx?.materials,
|
||
shading,
|
||
textures,
|
||
colorPreset,
|
||
sceneTheme,
|
||
)
|
||
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)
|
||
}
|
||
|
||
group.traverse((object) => {
|
||
const mesh = object as Mesh
|
||
if (mesh.isMesh) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||
})
|
||
|
||
return group
|
||
}
|