636 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { GutterNode } from '@pascal-app/core'
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { type GutterMitres, NO_MITRES } from './corner-mitre'
import {
OUTLET_STUB_LENGTH,
OUTLET_WALL_THICKNESS,
type OutletDims,
type OutletShape,
outletDims,
outletShapeForProfile,
profileFloorMidZ,
} from './profile-geometry'
/**
* Pure builder for the gutter mesh. The gutter is a hollow trough that
* runs along the eave; we build its cross-section as a closed 2D Shape
* in the (Z, Y) plane with the channel cavity carved out as a Path
* hole, then extrude along the gutter's local +X (length direction).
*
* Three profiles share the same outer-outline-minus-cavity recipe; they
* differ only in the OUTLINE shape:
*
* - `k-style`: flat back + flat bottom + ogee (S-curve) fascia.
* Most common modern residential profile.
* - `half-round`: half-cylinder (semicircle cross-section).
* - `box`: square / rectangular u-channel; reads as commercial.
*
* The gutter mounts at the eave line (gutter-local Y=0) and drops
* downward (negative Y) by `size`. +Z is "away from the building" —
* positive Z is the outer face that hangs over the eave.
*
* End caps: when `endCapLeft` / `endCapRight` is true, the matching
* end gets a thin SOLID slice (depth = wall thickness) instead of the
* hollow U-channel. The solid slice's end face closes the trough so
* water can't run out the side. Caps subtract from the user-set
* `length` so the gutter's total span stays constant — capping doesn't
* silently grow the geometry past what the inspector reads.
*
* Corner mitres: when a sibling gutter meets this gutter at a roof
* corner, the corner-mitre detector passes a mitre angle for the
* affected end. The end-face vertices are skewed (back wall held in
* place, front rim extended outward) so two perpendicular gutters'
* front rims meet at the outer eave intersection. A mitred end's cap
* is force-suppressed — capping a corner would wall off the L.
*
* Pure: no React, no scene access, no store mutation.
*/
export function buildGutterGeometry(
node: GutterNode,
mitres: GutterMitres = NO_MITRES,
): THREE.BufferGeometry {
const len = Math.max(0.05, node.length)
const size = Math.max(0.04, node.size)
const t = Math.min(Math.max(0.001, node.thickness), size * 0.4)
const capLeft = (node.endCapLeft ?? true) && mitres.left === 0
const capRight = (node.endCapRight ?? true) && mitres.right === 0
// Reserve cap slices at each capped end. Each cap is `t` thick
// (matches the wall thickness — a real end cap is a stamped plate
// welded onto the gutter). Clamp so a tiny gutter doesn't end up
// all-cap-no-channel.
const reserved = (capLeft ? t : 0) + (capRight ? t : 0)
const channelLen = Math.max(len * 0.1, len - reserved)
const totalCap = len - channelLen
const capLeftLen = capLeft ? (capRight ? totalCap / 2 : totalCap) : 0
const capRightLen = capRight ? (capLeft ? totalCap / 2 : totalCap) : 0
let channelCross: THREE.Shape
let capCross: THREE.Shape
if (node.profile === 'half-round') {
channelCross = buildHalfRoundCross(size, t)
capCross = buildHalfRoundOuterOnly(size)
} else if (node.profile === 'box') {
channelCross = buildBoxCross(size, t)
capCross = buildBoxOuterOnly(size)
} else {
channelCross = buildKStyleCross(size, t)
capCross = buildKStyleOuterOnly(size)
}
// Each extrude below uses the same orient-and-recenter recipe:
// ExtrudeGeometry produces (mesh-X = cross-X, mesh-Y = cross-Y,
// mesh-Z = extrusion axis); we rotateY(-π/2) so the LENGTH lands
// along mesh-+X and the OUTWARD direction lands along mesh-+Z, then
// translate so the piece sits in its slot of the gutter's overall
// [-len/2, +len/2] span. Z_cs = 0 maps to mesh-+X (right end);
// Z_cs = depth maps to mesh--X (left end).
const pieces: THREE.BufferGeometry[] = []
let channel: THREE.BufferGeometry = new THREE.ExtrudeGeometry(channelCross, {
depth: channelLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
// Apply the corner-mitre skew while we're still in the source frame.
// Source axes (pre-rotation): X_cs = outward, Y_cs = vertical,
// Z_cs = length (0 at right end of mesh, `channelLen` at left end).
// After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs.
//
// Corner mitre rule: the back wall (X_cs = 0) stays at the original
// end (mesh-X = ±len/2); the front rim (X_cs = +outward) is SKEWED
// along the gutter's length to reach the eave intersection of the L.
// The mitre is SIGNED — positive (convex / outer corner) extends the
// rim past the end; negative (concave / inner corner) retracts it to
// the inner intersection. `Math.tan` carries the sign, so the same
// formula handles both. In mesh coords:
// right end: Δmesh-X = +mesh-Z · tan(mitreRight)
// left end: Δmesh-X = −mesh-Z · tan(mitreLeft)
// Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs):
// right end (Z_cs = 0): new Z_cs = −X_cs · tan(mitreRight)
// left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft)
if (mitres.right !== 0 || mitres.left !== 0) {
// Drop the cross-section CAP at each mitred end first. Both gutters
// at a corner skew their end faces onto the SAME mitre plane, so the
// thin metal-section caps land coincident and z-fight into the stray
// seam lines visible at the joint (and the ink-edge pass outlines
// them). With the caps gone the open troughs flow into each other and
// the side walls carry the corner — a clean seam. Done before the
// skew so the end planes are still the clean z=0 / z=channelLen.
const stripped = stripEndCaps(channel, channelLen, mitres.right !== 0, mitres.left !== 0)
channel.dispose()
channel = stripped
const tanRight = Math.tan(mitres.right)
const tanLeft = Math.tan(mitres.left)
const eps = 1e-5
const pos = channel.attributes.position!
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i)
const z = pos.getZ(i)
if (mitres.right !== 0 && Math.abs(z) < eps) {
pos.setZ(i, -x * tanRight)
} else if (mitres.left !== 0 && Math.abs(z - channelLen) < eps) {
pos.setZ(i, channelLen + x * tanLeft)
}
}
pos.needsUpdate = true
channel.computeVertexNormals()
}
channel.rotateY(-Math.PI / 2)
// Channel spans [-len/2 + capLeftLen, +len/2 - capRightLen]: shift
// the recentered extrude so its right end butts against the right cap.
channel.translate(len / 2 - capRightLen, 0, 0)
pieces.push(channel)
if (capLeft) {
const leftCap = new THREE.ExtrudeGeometry(capCross, {
depth: capLeftLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
leftCap.rotateY(-Math.PI / 2)
// Left cap spans [-len/2, -len/2 + capLeftLen]: translate by
// -len/2 + capLeftLen so Z_cs=0 (mesh-+X end of the cap slice)
// sits at -len/2 + capLeftLen and Z_cs=depth at -len/2.
leftCap.translate(-len / 2 + capLeftLen, 0, 0)
pieces.push(leftCap)
}
if (capRight) {
const rightCap = new THREE.ExtrudeGeometry(capCross, {
depth: capRightLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
rightCap.rotateY(-Math.PI / 2)
// Right cap spans [+len/2 - capRightLen, +len/2].
rightCap.translate(len / 2, 0, 0)
pieces.push(rightCap)
}
// Hangers: thin metal straps spanning the rim from the back wall to
// the front rim, repeated along the length. Each strap is a small
// box centered on Y=0 (the eave line, where the rim sits) with its
// top ~3mm above the rim — so it reads as a clip resting on the
// gutter rather than buried in it.
if ((node.hangerStyle ?? 'strap') !== 'none') {
for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen, mitres)) {
pieces.push(hanger)
}
}
// Downspout outlets: short drop-tube collars hanging off the gutter
// floor at each outlet's position. Each collar is solid at the outer
// (bore + wall) cross-section; the bore is then drilled through both
// the floor and the collar via CSG, so the result is a real hole in
// the trough floor and a hollow tube descending from it. CSG only
// runs when at least one outlet exists — an empty `outlets` keeps the
// fast merge path.
const placements = resolveOutletPlacements(node, len, size, capLeftLen, capRightLen)
for (const p of placements) {
pieces.push(buildOutletStub(p, size))
// Flared funnel lip at the floor so the drop reads as a real drop
// outlet rather than a bare tube; the same bore drill cuts it open.
pieces.push(buildOutletFunnel(p, size))
}
const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
// Free the intermediate pieces when merge returned a new geometry.
if (merged !== pieces[0]) {
for (const p of pieces) p.dispose()
}
merged.computeVertexNormals()
// CSG drill — punches each bore through the merged geometry. Runs
// last so the floor + collars are already in one mesh; each drill
// cuts both at once, subtracted sequentially.
if (placements.length > 0) {
let workingBrush = new Brush(merged)
prepareBrushForCSG(workingBrush)
for (const p of placements) {
const drill = buildOutletDrill(p, size)
const drillBrush = new Brush(drill)
prepareBrushForCSG(drillBrush)
const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush
prepareBrushForCSG(next)
// Free the previous step's intermediate result (but not `merged`,
// which is disposed once below).
if (workingBrush.geometry !== merged) workingBrush.geometry.dispose()
workingBrush = next
drill.dispose()
}
const cutGeometry = csgGeometry(workingBrush)
merged.dispose()
return cutGeometry
}
return merged
}
// Remove the extrude's cross-section CAP triangles at a mitred end so two
// gutters meeting at a corner don't leave coincident, z-fighting end
// faces (the seam lines at the joint). A cap triangle is one whose three
// vertices ALL lie on an end plane — z≈0 (right end) or z≈channelLen
// (left end). Wall triangles always span the two planes, so this cleanly
// separates caps from walls without depending on ExtrudeGeometry's
// internal vertex order. Input is non-indexed (ExtrudeGeometry), so every
// three positions is one triangle; we copy through the survivors and let
// the caller recompute normals.
function stripEndCaps(
geo: THREE.BufferGeometry,
channelLen: number,
removeRight: boolean,
removeLeft: boolean,
): THREE.BufferGeometry {
const src = geo.index ? geo.toNonIndexed() : geo
const pos = src.attributes.position!
const uv = src.attributes.uv
const eps = 1e-4
const keepPos: number[] = []
const keepUv: number[] | null = uv ? [] : null
const triCount = Math.floor(pos.count / 3)
for (let t = 0; t < triCount; t++) {
const a = t * 3
const za = pos.getZ(a)
const zb = pos.getZ(a + 1)
const zc = pos.getZ(a + 2)
const allRight = Math.abs(za) < eps && Math.abs(zb) < eps && Math.abs(zc) < eps
const allLeft =
Math.abs(za - channelLen) < eps &&
Math.abs(zb - channelLen) < eps &&
Math.abs(zc - channelLen) < eps
if ((removeRight && allRight) || (removeLeft && allLeft)) continue
for (let k = 0; k < 3; k++) {
const idx = a + k
keepPos.push(pos.getX(idx), pos.getY(idx), pos.getZ(idx))
if (keepUv && uv) keepUv.push(uv.getX(idx), uv.getY(idx))
}
}
if (src !== geo) src.dispose()
const out = new THREE.BufferGeometry()
out.setAttribute('position', new THREE.Float32BufferAttribute(keepPos, 3))
if (keepUv) out.setAttribute('uv', new THREE.Float32BufferAttribute(keepUv, 2))
return out
}
// Each cross-section below is authored as a single closed polygon that
// traces the U-channel's MATERIAL — outer wall down → bottom → outer
// wall up → rim across → inner wall down → inner bottom → inner wall
// up → closing rim. The interior of the U (where rainwater sits) is
// empty space, not a hole — so the extrude has an OPEN TOP, which is
// what makes the geometry read as a gutter rather than a sealed box
// with a tunnel through it.
//
// Cross-section authoring frame: X is the gutter's outward direction
// (X=0 against the fascia, X=+w hanging outward); Y is vertical (Y=0
// at the eave line, Y=-size at the bottom of the trough). After the
// rotateY(-π/2) in the parent builder, +X maps to segment-outward (+Z)
// and the extrude axis (length) maps to segment-+X.
function buildKStyleCross(size: number, t: number): THREE.Shape {
const wBot = size * 0.8 // bottom outer width — narrower than the rim
const wTop = size * 0.95
const ogeeY = -size * 0.65 // S-curve inflection on the fascia
const shape = new THREE.Shape()
// Outer trace — top-back → down the back → across the bottom → up
// the ogee fascia.
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(wBot, -size)
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
// Front rim (thin top of the front wall): step inward by `t`.
shape.lineTo(wTop - t, 0)
// Inner trace — back down the ogee, across the inner bottom, up the
// inner back wall. Same bezier control points pushed inward by `t`.
shape.bezierCurveTo(
wTop - size * 0.15 - t,
ogeeY * 0.4,
wBot + size * 0.15 - t,
ogeeY,
wBot - t,
-size + t,
)
shape.lineTo(t, -size + t)
shape.lineTo(t, 0)
// closePath draws the back rim (t, 0) → (0, 0) — the thin top of
// the back wall, sealing the cross-section.
shape.closePath()
return shape
}
// Half-round trough — a semicircular cross-section with a smaller
// concentric semicircle carved out. Single closed trace: outer half
// from (0,0) sweeping down and back up to (2r, 0), front rim across by
// `t`, inner half from (2r-t, 0) sweeping back to (t, 0), back rim
// closes the loop.
function buildHalfRoundCross(size: number, t: number): THREE.Shape {
const r = size // radius == size: half-circle drops `size` below the eave
const ri = r - t // inner radius
const segs = 24
const shape = new THREE.Shape()
shape.moveTo(0, 0)
// Outer semicircle, lower half (angle π → 2π). At i=0 we'd be at
// (0,0) — already there from moveTo — so start at i=1.
for (let i = 1; i <= segs; i++) {
const angle = Math.PI + (Math.PI * i) / segs
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
}
// Front rim — step inward by `t` to start the inner trace.
shape.lineTo(2 * r - t, 0)
// Inner semicircle, traced BACK toward the back wall (angle 2π → π).
for (let i = 1; i <= segs; i++) {
const angle = 2 * Math.PI - (Math.PI * i) / segs
shape.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle))
}
// closePath draws (t, 0) → (0, 0) — back rim.
shape.closePath()
return shape
}
// Square / rectangular box U-channel. Width equals size (deep-and-
// narrow ratio reads as commercial). Traced as outer rect → front rim
// → inner rect (reverse) → back rim.
function buildBoxCross(size: number, t: number): THREE.Shape {
const w = size
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(w, -size)
shape.lineTo(w, 0)
// Front rim.
shape.lineTo(w - t, 0)
// Inner rect, reversed so the polygon doesn't self-intersect.
shape.lineTo(w - t, -size + t)
shape.lineTo(t, -size + t)
shape.lineTo(t, 0)
// closePath draws the back rim (t, 0) → (0, 0).
shape.closePath()
return shape
}
// Solid-outer outlines used for the end-cap slices: same outer
// boundary as the channel cross-sections above but without the inner
// trough carved out, so the extruded slice is a solid plug that closes
// the open end of the trough.
function buildKStyleOuterOnly(size: number): THREE.Shape {
const wBot = size * 0.8
const wTop = size * 0.95
const ogeeY = -size * 0.65
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(wBot, -size)
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
// closePath draws (wTop, 0) → (0, 0) — the cap's rim line across
// the top of the gutter cross-section.
shape.closePath()
return shape
}
function buildHalfRoundOuterOnly(size: number): THREE.Shape {
const r = size
const segs = 24
const shape = new THREE.Shape()
shape.moveTo(0, 0)
for (let i = 1; i <= segs; i++) {
const angle = Math.PI + (Math.PI * i) / segs
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
}
shape.closePath()
return shape
}
function buildBoxOuterOnly(size: number): THREE.Shape {
const w = size
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(w, -size)
shape.lineTo(w, 0)
shape.closePath()
return shape
}
// ─── Hangers ───────────────────────────────────────────────────────
// Strap dimensions — a residential hidden hanger reads as a flat band
// roughly 25mm wide along the gutter, 3mm thick, sitting on the rim.
const HANGER_BAR_LEN = 0.025
const HANGER_BAR_THICKNESS = 0.003
// Extra spread past the rim's outward extent — so the strap looks like
// it "wraps over" both edges rather than ending flush.
const HANGER_OVERHANG = 0.005
// Distance from each gutter end where a strap is allowed to sit; keeps
// straps from clashing with end caps and from looking pinned to the
// very edge.
const HANGER_END_MARGIN = 0.05
/** Outward Z extent of each profile, used to size the strap. */
function profileRimWidth(profile: GutterNode['profile'], size: number): number {
if (profile === 'half-round') return 2 * size
if (profile === 'box') return size
return size * 0.95 // k-style wTop
}
function buildHangers(
node: GutterNode,
len: number,
size: number,
capLeftLen: number,
capRightLen: number,
mitres: GutterMitres,
): THREE.BufferGeometry[] {
const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6)
const profile = node.profile ?? 'k-style'
const rimWidth = profileRimWidth(profile, size)
const strapDepth = rimWidth + HANGER_OVERHANG * 2
// At an INNER (concave) corner the two gutters' troughs fold into the
// same notch, so a strap sitting near that end overlaps the neighbour
// gutter's end strap — the two perpendicular straps read as an X across
// the corner. Pull the run's bound back by a full strap depth at a
// concave end so the nearest strap clears the joint. OUTER (convex)
// corners diverge outward and never overlap, so they keep the tight
// margin (`mitre > 0` and non-mitred ends → no extra inset).
const concaveInset = (mitre: number) => (mitre < 0 ? strapDepth : 0)
// Inset by margin AND any cap so straps don't punch into the cap slab,
// plus the concave-corner clearance above.
const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + concaveInset(mitres.left)
const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN - concaveInset(mitres.right)
const usable = rightBound - leftBound
if (usable <= 0) return []
// Span the usable run with straps at `spacing` between centers, plus
// one at each end. Symmetric layout for any length, including very
// short gutters where two straps land at the bounds.
const count = Math.max(1, Math.floor(usable / spacing) + 1)
const stride = count > 1 ? usable / (count - 1) : 0
const pieces: THREE.BufferGeometry[] = []
for (let i = 0; i < count; i++) {
const x = count > 1 ? leftBound + i * stride : (leftBound + rightBound) / 2
// BoxGeometry is indexed; the channel + cap ExtrudeGeometries are
// not. `mergeGeometries` rejects mixed-index sets, so flatten the
// box to non-indexed before pushing.
const bar = new THREE.BoxGeometry(
HANGER_BAR_LEN,
HANGER_BAR_THICKNESS,
strapDepth,
).toNonIndexed()
// Center the bar at X = position, Y just above the rim line, Z
// straddling 0 so the strap covers the full back-to-front span.
bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2)
pieces.push(bar)
}
return pieces
}
// ─── Outlet ────────────────────────────────────────────────────────
// Radial subdivisions — 24 reads as smooth at typical outlet
// diameters; lower and the 3″ tube starts looking faceted from below.
const OUTLET_RADIAL_SEGMENTS = 24
// Drill overshoot past the floor and past the stub bottom — keeps the
// CSG cut planes from coinciding with merged-geometry surfaces
// (coplanar cuts produce degenerate output in three-bvh-csg).
const OUTLET_DRILL_OVERSHOOT = 0.01
// Funnel lip at the drop outlet — flares this much wider than the collar
// over this height, just below the trough floor. The bore drill cuts it
// open along with the collar.
const OUTLET_FLARE_SCALE = 1.6
const OUTLET_FLARE_HEIGHT = 0.02
type OutletPlacement = {
x: number
z: number
shape: OutletShape
/** Bore cross-section (the drilled hole). */
inner: OutletDims
/** Collar cross-section (bore + wall) — the solid stub body. */
outer: OutletDims
}
/**
* One placement per `node.outlets` entry that fits between the caps. The
* shape follows the gutter profile (`outletShapeForProfile`): round
* leader on half-round, rectangular on k-style / box. Each outlet's
* `offset` (signed from center) is clamped inside the trough-floor span
* using the OUTER along-length half-extent so the collar can't poke into
* a cap. Outlets that can't fit at all are dropped.
*/
function resolveOutletPlacements(
node: GutterNode,
len: number,
size: number,
capLeftLen: number,
capRightLen: number,
): OutletPlacement[] {
const outlets = node.outlets ?? []
if (outlets.length === 0) return []
const shape = outletShapeForProfile(node.profile)
const z = profileFloorMidZ(node.profile ?? 'k-style', size)
const placements: OutletPlacement[] = []
for (const outlet of outlets) {
const inner = outletDims(shape, outlet.diameter ?? 0.07)
const outer: OutletDims = {
shape,
halfX: inner.halfX + OUTLET_WALL_THICKNESS,
halfZ: inner.halfZ + OUTLET_WALL_THICKNESS,
}
const minX = -len / 2 + capLeftLen + outer.halfX
const maxX = len / 2 - capRightLen - outer.halfX
if (maxX <= minX) continue
const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0))
placements.push({ x, z, shape, inner, outer })
}
return placements
}
/** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */
function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry {
if (dims.shape === 'round') {
return new THREE.CylinderGeometry(
dims.halfX,
dims.halfX,
h,
OUTLET_RADIAL_SEGMENTS,
).toNonIndexed()
}
return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed()
}
/**
* Solid collar at the OUTER cross-section; the CSG drill hollows out the
* bore and leaves a tube wall. Top sits flush with the gutter floor
* (Y = −size). Flattened to match the ExtrudeGeometries.
*/
function buildOutletStub(p: OutletPlacement, size: number): THREE.BufferGeometry {
const stub = outletSolid(p.outer, OUTLET_STUB_LENGTH)
stub.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z)
return stub
}
/**
* Flared funnel lip just below the trough floor — wide at the floor,
* tapering to the collar below (round → a cone; rect → a stepped wider
* lip). Sits in the bore drill's span so it gets cut open too.
*/
function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeometry {
const centerY = -size - OUTLET_FLARE_HEIGHT / 2
let funnel: THREE.BufferGeometry
if (p.shape === 'round') {
funnel = new THREE.CylinderGeometry(
p.outer.halfX * OUTLET_FLARE_SCALE,
p.outer.halfX,
OUTLET_FLARE_HEIGHT,
OUTLET_RADIAL_SEGMENTS,
).toNonIndexed()
} else {
funnel = new THREE.BoxGeometry(
2 * p.outer.halfX * OUTLET_FLARE_SCALE,
OUTLET_FLARE_HEIGHT,
2 * p.outer.halfZ * OUTLET_FLARE_SCALE,
).toNonIndexed()
}
funnel.translate(p.x, centerY, p.z)
return funnel
}
/**
* Bore drill — spans from slightly above the trough floor to slightly
* below the collar's bottom; the overshoots keep CSG cut planes from
* sitting coplanar with merged-geometry faces.
*/
function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometry {
const top = -size + OUTLET_DRILL_OVERSHOOT
const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT
const height = top - bottom
const centerY = (top + bottom) / 2
const drill = outletSolid(p.inner, height)
drill.translate(p.x, centerY, p.z)
return drill
}