636 lines
25 KiB
TypeScript
636 lines
25 KiB
TypeScript
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
|
||
}
|