import type { DownspoutNode } from '@pascal-app/core' import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import type { OutletDims } from '../gutter/profile-geometry' import { computeDownspoutPath, type DownspoutPath, type DownspoutRouting, downspoutPipeDims, effectiveWallJog, } from './routing' /** * Downspout pipe builder. The pipe follows a real downspout's path — * a short DROP out of the collar, an OFFSET ELBOW back to the wall, the * VERTICAL RUN down the wall, and a bottom KICKOUT — plus the hardware * that makes it read real: WALL STRAPS clamping the run to the wall, * an open (hollow) mouth at the kickout, and a SPLASH BLOCK on the * ground under the mouth. * * Mesh frame is centred on the outlet: local Y = 0 is the gutter floor, * −Y is down, −Z is toward the wall (+Z is outward over the eave). The * path lives in the local Y/Z plane; X is the gutter-length axis. * * Cross-section follows the host gutter's profile: round on half-round, * rectangular on k-style / box. Straight legs are solid cylinders / * boxes welded at the corners with a small joint; the kickout leg is a * hollow tube so the open mouth reads through. * * Pure: no React, no scene access. */ const RADIAL_SEGMENTS = 16 const JOINT_SEGMENTS = 12 const FWD = new THREE.Vector3(0, 0, 1) const UP = new THREE.Vector3(0, 1, 0) // Pipe wall thickness for the hollow (open-mouth) kickout leg. const PIPE_WALL = 0.004 // Wall straps — a thin band clamps the run to the wall, set in a margin // from each end (spacing comes from the node). const STRAP_END_MARGIN = 0.3 const STRAP_THICKNESS = 0.022 const STRAP_OVERHANG = 0.014 // Splash block — a tilted slab on the ground under the mouth that // carries water away from the foundation. const SPLASH_WIDTH = 0.22 const SPLASH_LENGTH = 0.34 const SPLASH_THICKNESS = 0.05 const SPLASH_TILT = 0.1 export function buildDownspoutGeometry( node: DownspoutNode, routing?: DownspoutRouting | null, ): THREE.BufferGeometry { const dims = downspoutPipeDims(node, routing) const terminal = node.terminal ?? 'splash' // 'straight' runs the pipe to grade with no kickout leg. const pathData = computeDownspoutPath( node.length, effectiveWallJog(node, routing), terminal !== 'straight', ) // Drop consecutive duplicates (jog == 0 collapses the elbow; no kick // collapses the bottom two) so we never build a zero-length segment. const path: THREE.Vector3[] = [] for (const [x, y, z] of pathData.points) { const p = new THREE.Vector3(x, y, z) const last = path.at(-1) if (!last || last.distanceTo(p) > 1e-4) path.push(p) } const pieces: THREE.BufferGeometry[] = [] const lastLeg = path.length - 2 for (let i = 0; i < path.length - 1; i++) { // The final leg (the kickout mouth) is a hollow tube so you can see // up the open end; the rest stay solid (their outer surface reads // identically, and they're capped by the collar / joints anyway). pieces.push( i === lastLeg ? ringTube(path[i]!, path[i + 1]!, dims) : segmentBetween(path[i]!, path[i + 1]!, dims), ) if (i > 0) pieces.push(jointAt(path[i]!, path[i - 1]!, path[i + 1]!, dims)) } if ((node.strapStyle ?? 'band') !== 'none') { for (const strap of buildStraps(pathData, dims, node.strapSpacing ?? 1.8)) pieces.push(strap) } if (terminal === 'splash') { const splash = buildSplash(pathData) if (splash) pieces.push(splash) } const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!) if (merged !== pieces[0]) { for (const p of pieces) p.dispose() } merged.computeVertexNormals() return merged } /** * Solid segment spanning two points. Round → a cylinder; rect → a box * (2·halfX wide along the gutter length, 2·halfZ deep outward). The * orient-onto-direction rotation is purely about X for our planar path, * so the box's width stays aligned with the gutter length axis. */ function segmentBetween( a: THREE.Vector3, b: THREE.Vector3, dims: OutletDims, ): THREE.BufferGeometry { const dir = new THREE.Vector3().subVectors(b, a) const len = dir.length() const geo = dims.shape === 'round' ? new THREE.CylinderGeometry(dims.halfX, dims.halfX, len, RADIAL_SEGMENTS).toNonIndexed() : new THREE.BoxGeometry(2 * dims.halfX, len, 2 * dims.halfZ).toNonIndexed() // The primitive runs along +Y centred at origin; rotate +Y onto the // segment direction, then drop it on the midpoint. geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize())) geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2) return geo } /** * Hollow tube spanning two points — a ring (round) / rectangular-ring * cross-section extruded along the leg, so both ends are open and the * bore reads through. Used for the kickout mouth. */ function ringTube(a: THREE.Vector3, b: THREE.Vector3, dims: OutletDims): THREE.BufferGeometry { const dir = new THREE.Vector3().subVectors(b, a) const len = dir.length() const shape = new THREE.Shape() const hole = new THREE.Path() if (dims.shape === 'round') { shape.absarc(0, 0, dims.halfX, 0, Math.PI * 2, false) hole.absarc(0, 0, Math.max(0.002, dims.halfX - PIPE_WALL), 0, Math.PI * 2, true) } else { const ox = dims.halfX const oz = dims.halfZ const ix = Math.max(0.002, ox - PIPE_WALL) const iz = Math.max(0.002, oz - PIPE_WALL) shape.moveTo(-ox, -oz) shape.lineTo(ox, -oz) shape.lineTo(ox, oz) shape.lineTo(-ox, oz) shape.closePath() hole.moveTo(-ix, -iz) hole.lineTo(-ix, iz) hole.lineTo(ix, iz) hole.lineTo(ix, -iz) hole.closePath() } shape.holes.push(hole) // ExtrudeGeometry runs the shape (in XY) along +Z from 0 to depth; // orient +Z onto the leg direction, then move the z=0 end to `a`. // ExtrudeGeometry is already non-indexed, matching the merge set. const geo = new THREE.ExtrudeGeometry(shape, { depth: len, bevelEnabled: false, steps: 1, curveSegments: RADIAL_SEGMENTS, }) geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(FWD, dir.normalize())) geo.translate(a.x, a.y, a.z) return geo } /** * Corner joint at `p` between the segments (prev→p) and (p→next). Round * → a sphere; rect → a box aligned to the bend bisector so it bridges * the wedge the two box ends leave open at the outer corner. */ function jointAt( p: THREE.Vector3, prev: THREE.Vector3, next: THREE.Vector3, dims: OutletDims, ): THREE.BufferGeometry { if (dims.shape === 'round') { const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed() geo.translate(p.x, p.y, p.z) return geo } const dirIn = new THREE.Vector3().subVectors(p, prev).normalize() const dirOut = new THREE.Vector3().subVectors(next, p).normalize() const bis = new THREE.Vector3().addVectors(dirIn, dirOut) if (bis.lengthSq() < 1e-8) bis.copy(dirOut) // straight-through; degenerate bis.normalize() const geo = new THREE.BoxGeometry(2 * dims.halfX, 2 * dims.halfZ, 2 * dims.halfZ).toNonIndexed() geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis)) geo.translate(p.x, p.y, p.z) return geo } /** * Thin bands clamping the wall run to the wall, ~`STRAP_SPACING` apart * and set in from each end. Each is a flat box a touch proud of the * pipe so it reads as a strap wrapping the run. */ function buildStraps( path: DownspoutPath, dims: OutletDims, spacing: number, ): THREE.BufferGeometry[] { const top = path.wallRunTopY const bottom = path.wallRunBottomY const z = path.wallRunZ const runLen = top - bottom if (runLen < STRAP_END_MARGIN * 2 + 0.05) return [] const usable = runLen - STRAP_END_MARGIN * 2 const count = Math.max(1, Math.floor(usable / Math.max(0.2, spacing)) + 1) const stride = count > 1 ? usable / (count - 1) : 0 const w = 2 * dims.halfX + 2 * STRAP_OVERHANG const d = 2 * dims.halfZ + 2 * STRAP_OVERHANG const straps: THREE.BufferGeometry[] = [] for (let i = 0; i < count; i++) { const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2 const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed() band.translate(0, y, z) straps.push(band) } return straps } /** * Tilted slab on the ground under the mouth, extending outward (+Z, * away from the wall) so it carries water off from the foundation. */ function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null { const [bx, by, bz] = path.bottom const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed() // Tilt the far (+Z) end down so it slopes away from the wall. slab.rotateX(SPLASH_TILT) slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2) return slab }