{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cooling-tower",
  "type": "registry:component",
  "title": "Cooling tower (induced draft, counterflow)",
  "description": "Onshore mechanical-draft counterflow cooling tower in a row of cells: concrete cold-water basin with outlet sump, FRP-clad casing on a column frame, louvred air inlets on the long sides, fan deck with handrails, velocity-recovery fan stacks, axial fans on right-angle gearboxes driven through a drive shaft by motors outside the stacks, vibration switches and oil-level lines, hot-water riser and distribution header with a valve per cell, make-up and overflow, and caged ladders with a rest platform. Fans spin while running.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "thermal/cooling-tower.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/cooling-tower.tsx",
      "content": "// Mechanical induced-draft counterflow cooling tower (cell type): concrete basin, clad casing with louvred air inlets, fan deck, fan stacks, right-angle gear drives with motors outside the stacks, hot-water risers and access. CTI STD-201 / ATC-105 practice.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, uvScale, mat4, type V3 } from '../lib/kit'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { paint } from '../lib/materials'\nimport { flange, grating, handrail, ladder, member, motor, motorSize, pipeRun, tagPlate, weldBead } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { Rotors, fanImpeller, type RotorSpec } from '../lib/rotors'\nimport { flange as flangeDims, pipeOD, type Rating } from '../lib/standards'\nimport { louvreMaterial } from '../lib/textures'\nimport { clamp } from './_thermal'\n\nexport interface CoolingTowerProps {\n  /** cells in a row along X */\n  cells?: number\n  /** plan size of one cell (square), m */\n  cellSize?: number\n  /** fan deck above grade, m */\n  deckHeight?: number\n  /** air inlet height above the basin curb, m */\n  inletHeight?: number\n  /** motor power per fan, kW */\n  kw?: number\n  inletNps?: number\n  outletNps?: number\n  rating?: Rating\n  running?: boolean\n  tag?: string\n}\n\nexport const defaults = {\n  cells: 2,\n  cellSize: 9,\n  deckHeight: 10,\n  inletHeight: 3.2,\n  kw: 110,\n  inletNps: 20,\n  outletNps: 24,\n  rating: 150 as Rating,\n  running: true,\n  tag: 'CT-100',\n}\n\ntype P = typeof defaults\n\nconst CURB = 0.6 // basin wall above grade\n\nfunction dims(p: P) {\n  const n = Math.max(1, Math.round(p.cells))\n  const S = p.cellSize\n  const Lx = n * S\n  const Wz = S\n  const yD = Math.max(p.deckHeight, p.inletHeight + CURB + 3)\n  const D = clamp(Math.round(S * 0.7 * 2) / 2, 3, 12) // typical fan to cell ratio, leaves deck room for the drive\n  const Hs = clamp(0.3 * D, 1.8, 4) // velocity-recovery fan stack\n  const cellX = Array.from({ length: n }, (_, i) => -Lx / 2 + (i + 0.5) * S)\n  const yDist = yD - 1.3 // spray distribution header level\n  const xR = -Lx / 2 - 0.9 // hot water riser, outside the -X end wall\n  const fi = flangeDims(p.inletNps, p.rating)\n  const fo = flangeDims(p.outletNps, p.rating)\n  const yIn = 1.2\n  const inFace: V3 = [xR - 1.6, yIn, 0]\n  const outFace: V3 = [Lx / 2 + 0.3 + 1.2 + 0.5, pipeOD(p.outletNps) / 2 + 0.25, 0] // sump wall + sump + spool\n  return { n, S, Lx, Wz, yD, D, Hs, cellX, yDist, xR, fi, fo, yIn, inFace, outFace }\n}\n\nexport function ports(props: CoolingTowerProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  return [\n    port('inlet', 'Hot water inlet', d.inFace, [-1, 0, 0], p.inletNps, p.rating),\n    port('outlet', 'Cold water outlet', d.outFace, [1, 0, 0], p.outletNps, p.rating),\n  ]\n}\n\nexport function CoolingTower(props: CoolingTowerProps & GroupProps) {\n  const { cells, cellSize, deckHeight, inletHeight, kw, inletNps, outletNps, rating, running, tag, ...group } = { ...defaults, ...props }\n  const p = { cells, cellSize, deckHeight, inletHeight, kw, inletNps, outletNps, rating, running, tag } as P\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const { built, rotors } = useMemo(() => build(p), [cells, cellSize, deckHeight, inletHeight, kw, inletNps, outletNps, rating, tag])\n  useEffect(() => () => [built, ...new Set(rotors.map((r) => r.built))].forEach((b) => b.geometry.dispose()), [built, rotors])\n  return (\n    <group {...group}>\n      <KitMesh data={built} />\n      <Rotors rotors={rotors} running={running} />\n    </group>\n  )\n}\n\nexport default CoolingTower\n\nfunction build(p: P) {\n  const k = new Kit()\n  const d = dims(p)\n  const { Lx, Wz, yD, D, Hs } = d\n  const frp = paint('#c8cbc2', { metalness: 0.05, roughness: 0.7, clearcoat: 0, weather: 1.2 }) // FRP cladding\n  const steel = 'galvanised' as const\n  const yI = CURB + p.inletHeight\n  // concrete cold-water basin with curb, outlet sump on +X\n  const t = 0.3\n  for (const s of [-1, 1]) {\n    k.box('concrete', [Lx + 2 * t, CURB, t], [0, CURB / 2, s * (Wz / 2 + t / 2)])\n    k.box('concrete', [t, CURB, Wz], [s * (Lx / 2 + t / 2), CURB / 2, 0])\n  }\n  k.box('liquidWater', [Lx, 0.02, Wz], [0, CURB - 0.15, 0])\n  k.box('concrete', [1.2, 1.0, 2.0], [Lx / 2 + t + 0.6, 0.5, 0])\n  // columns, girts and diagonal bracing in the open air-inlet zone\n  const colX = Array.from({ length: d.n * 2 + 1 }, (_, i) => -Lx / 2 + (i * d.S) / 2)\n  for (const x of colX)\n    for (const s of [-1, 1]) {\n      member(k, [x, CURB, s * (Wz / 2 - 0.1)], [x, yD - 0.2, s * (Wz / 2 - 0.1)], 'SHS 150x150x8', frp)\n      k.box(steel, [0.3, 0.02, 0.3], [x, CURB + 0.01, s * (Wz / 2 - 0.1)])\n    }\n  for (let i = 0; i < colX.length - 1; i++)\n    for (const s of [-1, 1]) {\n      member(k, [colX[i], yI, s * (Wz / 2 - 0.1)], [colX[i + 1], yI, s * (Wz / 2 - 0.1)], 'SHS 100x100x6.3', frp)\n      if (i % 2 === 0) member(k, [colX[i], CURB + 0.2, s * (Wz / 2 - 0.1)], [colX[i + 1], yI - 0.1, s * (Wz / 2 - 0.1)], 'SHS 100x100x6.3', frp)\n    }\n  // air-inlet louvres on the long sides, clad casing above, solid end walls\n  for (const s of [-1, 1]) {\n    const g = new THREE.PlaneGeometry(Lx - 0.2, p.inletHeight - 0.2)\n    uvScale(g, 1, (p.inletHeight - 0.2) / 0.18)\n    k.add(louvreMaterial('#b8bcb4'), g, mat4([0, CURB + p.inletHeight / 2, s * (Wz / 2 - 0.02)], s > 0 ? undefined : new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI)))\n    k.box(frp, [Lx, yD - yI, 0.04], [0, (yI + yD) / 2, s * (Wz / 2)])\n    for (let x = -Lx / 2 + 0.6; x < Lx / 2; x += 0.6) k.box(frp, [0.05, yD - yI, 0.03], [x, (yI + yD) / 2, s * (Wz / 2 + 0.03)]) // panel ribs\n    k.box(frp, [0.04, yD - CURB, Wz], [s * (Lx / 2), (CURB + yD) / 2, 0])\n  }\n  // fan deck, perimeter handrail\n  k.box('deckPlate', [Lx + 0.1, 0.12, Wz + 0.1], [0, yD - 0.06, 0])\n  const e = 0.05\n  handrail(k, [[-Lx / 2 + e, yD, -Wz / 2 + e], [Lx / 2 - e, yD, -Wz / 2 + e], [Lx / 2 - e, yD, Wz / 2 - e], [-Lx / 2 + 1.0, yD, Wz / 2 - e]])\n  handrail(k, [[-Lx / 2 + e, yD, Wz / 2 - e - 0.8], [-Lx / 2 + e, yD, -Wz / 2 + e]])\n  // fan stacks, fans, gear drives and motors\n  const R = D / 2\n  const rpm = clamp(Math.round(4800 / D / 10) * 10, 90, 300) // typical tip speed ≈ 60 m/s\n  const fk = new Kit()\n  fanImpeller(fk, paint('#6d7478', { metalness: 0.5, roughness: 0.45 }), R - 0.05, { axis: 'y', blades: D > 7 ? 8 : 6, chord: R * 0.18, pitch: 0.22, hub: R * 0.12, hubLen: 0.35, thick: 0.03, hubMat: 'castIron' })\n  const fanB = fk.build()\n  const rotors: RotorSpec[] = []\n  const m = motorSize(p.kw, 4)\n  for (const xc of d.cellX) {\n    k.lathe(frp, [[R + 0.35, yD], [R + 0.08, yD + 0.35], [R + 0.03, yD + 0.9], [R + 0.03 + Hs * 0.12, yD + Hs], [R + 0.09 + Hs * 0.12, yD + Hs], [R + 0.09 + Hs * 0.12, yD + Hs - 0.06], [R + 0.08, yD + 0.9], [R + 0.13, yD + 0.35], [R + 0.4, yD]], [xc, 0, 0], 'y', 64)\n    k.lathe('vesselInner', [[R + 0.35, yD], [R + 0.08, yD + 0.35], [R + 0.03, yD + 0.9], [R + 0.03 + Hs * 0.12, yD + Hs]], [xc, 0, 0], 'y', 64)\n    for (let i = 0; i < 12; i++) {\n      const a = (i / 12) * Math.PI * 2\n      k.box(frp, [0.04, Hs - 0.3, 0.12], [xc + Math.cos(a) * (R + 0.13 + Hs * 0.05), yD + Hs / 2 + 0.1, Math.sin(a) * (R + 0.13 + Hs * 0.05)], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a))\n    }\n    // mechanical support beam (torque tube) under the fan, gearbox at the centre\n    const yF = yD + 0.55\n    k.box(steel, [D + 0.6, 0.35, 0.3], [xc, yD - 0.35, 0])\n    k.rbox('castIron', [0.8, 0.6, 0.6], 0.05, [xc, yD + 0.1, 0])\n    k.cyl('castIron', 0.14, yF - yD - 0.3, [xc, (yF + yD + 0.4) / 2, 0], 'y', 16)\n    k.box('actuatorBlue', [0.1, 0.08, 0.07], [xc + 0.45, yD + 0.15, 0.2]) // vibration switch\n    rotors.push({ built: fanB, axis: 'y', pos: [xc, yF, 0], rpm })\n    // motor outside the stack on the deck (+Z side), drive shaft through the stack wall to the gearbox\n    const zf = R + 0.5\n    const yS = yD + m.H + 0.15\n    k.with(mat4([xc, yS, zf], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2)), () => {\n      // local +X (shaft) points to -Z, towards the gearbox\n      k.with(mat4([0, 0, 0]), () => motor(k, { R: m.R, L: m.L, H: m.H, tbox: 'side', mat: 'motorPaint' }))\n    })\n    k.box(steel, [m.R * 2.4, 0.15, m.L + 0.2], [xc, yD + 0.075, zf + m.L / 2])\n    k.cyl('stainless', 0.06, zf - 0.45, [xc, yS, (zf + 0.45) / 2 - 0.1], 'z', 12)\n    for (const zz of [zf - 0.25, 0.4]) k.cyl('flangeSteel', 0.12, 0.08, [xc, yS, zz], 'z', 16)\n    k.box('safetyYellow', [0.35, 0.35, 0.5], [xc, yS, zf - 0.25])\n    // oil level gauge line from the gearbox out to the stack wall\n    k.tube('stainless', [[xc - 0.3, yD + 0.05, 0.3], [xc - 0.3, yD + 0.05, R * 0.7], [xc - 0.3, yD + 0.9, R + 0.35]], 0.012, 6, 0.1)\n    k.cyl('glass', 0.03, 0.18, [xc - 0.3, yD + 1.0, R + 0.35], 'y', 10)\n  }\n  // hot-water riser on the -X end, distribution header along +Z face, one branch and valve per cell\n  const nps = p.inletNps\n  const Lfi = d.fi.hubLength + d.fi.raisedFaceHeight\n  flange(k, nps, p.rating, d.inFace, [-1, 0, 0], true)\n  const zH = Wz / 2 + 0.9\n  // the header turns into the last cell through an LR elbow; earlier cells take welded branches\n  const xLast = d.cellX[d.n - 1]\n  pipeRun(k, [[d.inFace[0] + Lfi, d.yIn, 0], [d.xR, d.yIn, 0], [d.xR, d.yDist, 0], [d.xR, d.yDist, zH], [xLast, d.yDist, zH], [xLast, d.yDist, Wz / 2 - 0.05]], nps, { mat: 'pipePaint' })\n  const bnps0 = nps >= 12 ? Math.round(nps * 0.6) : nps\n  for (const [i, xc] of d.cellX.entries()) {\n    const last = i === d.n - 1\n    const bnps = last ? nps : bnps0\n    if (!last) {\n      pipeRun(k, [[xc, d.yDist, zH - pipeOD(nps) / 2 + 0.01], [xc, d.yDist, Wz / 2 - 0.05]], bnps, { mat: 'pipePaint' })\n      weldBead(k, bnps, [xc, d.yDist, zH - pipeOD(nps) / 2 + 0.005], [0, 0, -1])\n    }\n    k.cyl('valveBody', pipeOD(bnps) / 2 + 0.06, 0.12, [xc, d.yDist, Wz / 2 + 0.45], 'z', 24)\n    k.rod('valveBody', [xc, d.yDist, Wz / 2 + 0.45], [xc, d.yDist + pipeOD(bnps) / 2 + 0.35, Wz / 2 + 0.45], 0.03, 8)\n    k.handwheel('handwheel', 0.35, [xc, d.yDist + pipeOD(bnps) / 2 + 0.38, Wz / 2 + 0.45], 'y')\n  }\n  for (let x = d.xR; x < d.cellX[d.n - 1]; x += 3) k.box(steel, [0.15, 0.15, 0.9], [Math.max(x, -Lx / 2 + 0.1), d.yDist - pipeOD(nps) / 2 - 0.075, Wz / 2 + 0.45])\n  k.box(steel, [0.3, d.yIn - pipeOD(nps) / 2, 0.3], [d.xR, (d.yIn - pipeOD(nps) / 2) / 2, 0])\n  // cold-water outlet from the sump, make-up and overflow\n  const Lfo = d.fo.hubLength + d.fo.raisedFaceHeight\n  flange(k, p.outletNps, p.rating, d.outFace, [1, 0, 0], true)\n  k.pipe('pipePaint', [Lx / 2 + t + 1.2, d.outFace[1], 0], [d.outFace[0] - Lfo, d.outFace[1], 0], pipeOD(p.outletNps), 0.01, 32)\n  k.disc('pipeBore', pipeOD(p.outletNps) / 2 - 0.011, [Lx / 2 + t + 1.202, d.outFace[1], 0], 'x', 32) // dark sump opening\n  k.pipe('pipePaint', [Lx / 2 + t, 0.45, -Wz * 0.3], [Lx / 2 + t + 0.6, 0.45, -Wz * 0.3], pipeOD(4), 0.006, 20)\n  k.tube('pipePaint', [[Lx / 2 - 0.5, CURB + 0.6, -Wz * 0.35], [Lx / 2 - 0.5, CURB + 0.1, -Wz * 0.35]], pipeOD(3) / 2, 12)\n  // access: caged ladders with an intermediate rest platform on the +X end\n  const yMid = Math.min(yD / 2 + 0.5, 6)\n  const xl = Lx / 2 + 0.5\n  grating(k, [xl + 0.45, yMid, -Wz / 2 + 1.5], 0.9, 1.6)\n  handrail(k, [[xl, yMid, -Wz / 2 + 0.7], [xl + 0.9, yMid, -Wz / 2 + 0.7], [xl + 0.9, yMid, -Wz / 2 + 2.3], [xl, yMid, -Wz / 2 + 2.3]])\n  member(k, [xl + 0.9, CURB, -Wz / 2 + 0.75], [xl + 0.9, yMid - 0.03, -Wz / 2 + 0.75], 'SHS 100x100x6.3', steel)\n  member(k, [xl + 0.9, CURB, -Wz / 2 + 2.25], [xl + 0.9, yMid - 0.03, -Wz / 2 + 2.25], 'SHS 100x100x6.3', steel)\n  for (const z of [-Wz / 2 + 0.75, -Wz / 2 + 2.25]) member(k, [Lx / 2 + 0.02, yMid - 0.08, z], [xl + 0.9, yMid - 0.08, z], 'UPN 100', steel, [0, 1, 0])\n  ladder(k, [xl + 0.5, CURB, -Wz / 2 + 2.6], yMid, [0, 0, 1])\n  ladder(k, [xl + 0.45, yMid, -Wz / 2 + 0.4], yD, [0, 0, -1])\n  k.box('deckPlate', [1.2, 0.08, 1.2], [Lx / 2 + 0.6, yD - 0.04, -Wz / 2 + 0.4])\n  tagPlate(k, p.tag, [Lx / 2 + 0.03, yI + 1.5, 0], 'x', 0.6)\n  return { built: k.build(), rotors }\n}\n"
    },
    {
      "path": "thermal/_thermal.ts",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/_thermal.ts",
      "content": "// Shared geometry for thermal equipment: body (girth) flanges, formed heads, eccentric cones, nozzle-to-nozzle bolting.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { axisVec } from '../lib/kit'\nimport { gasket, nozzle, type NozzleOpts } from '../lib/parts'\nimport { flange as flangeDims, pipeOD, pipeWall, type Rating } from '../lib/standards'\n\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nexport interface Girth {\n  Rf: number\n  tf: number\n  hubL: number\n  hubT: number\n  Rbc: number\n  nb: number\n  db: number\n}\n\n/** typical: custom body flange for a shell of outside radius Ro (not B16.5/B16.47; sized like TEMA girth flanges). */\nexport function girthDims(Ro: number): Girth {\n  const D = 2 * Ro\n  const db = D < 0.6 ? 0.024 : D < 1.2 ? 0.03 : 0.036\n  const Rbc = Ro + 0.04 + 1.6 * db\n  const Rf = Rbc + 1.4 * db + 0.012\n  const tf = clamp(0.05 + 0.06 * D, 0.06, 0.16)\n  const nb = Math.max(12, 4 * Math.round((2 * Math.PI * Rbc) / 0.11 / 4))\n  return { Rf, tf, hubL: clamp(0.06 + 0.04 * D, 0.07, 0.14), hubT: clamp(0.012 + 0.012 * D, 0.016, 0.04), Rbc, nb, db }\n}\n\n/** Girth flange about the X axis at y/z = 0: raised face at `x`, hub running towards `dir`. */\nexport function girthFlange(k: Kit, mat: MatKey, x: number, Ro: number, G: Girth, dir: 1 | -1, seg = 64) {\n  const Ri = Ro * 0.97\n  const { Rf, tf, hubL, hubT } = G\n  if (dir > 0)\n    k.lathe(mat, [[Ri, x], [Rf, x], [Rf, x + tf], [Ro + hubT, x + tf], [Ro, x + tf + hubL]], [0, 0, 0], 'x', seg)\n  else k.lathe(mat, [[Ro, x - tf - hubL], [Ro + hubT, x - tf], [Rf, x - tf], [Rf, x], [Ri, x]], [0, 0, 0], 'x', seg)\n}\n\n/** Through-studs with a nut at each end, between flange back faces xa < xb, about the X axis. */\nexport function girthStuds(k: Kit, xa: number, xb: number, G: Girth, R = G.Rbc, n = G.nb, d = G.db, mat: MatKey = 'boltXylan') {\n  const nh = d * 0.95\n  const len = xb - xa + 2 * nh + 1.2 * d\n  k.boltCircle(mat, 'nut', n, R, [xa - nh - 0.6 * d, 0, 0], 'x', d, len, [0.6 * d + nh / 2, len - 0.6 * d - nh / 2], Math.PI / n)\n}\n\n/** 2:1 ellipsoidal (or any depth) head about X: tangent line at x0, crown towards dir. */\nexport function headProfile(R: number, depth: number, x0: number, dir: 1 | -1, n = 18): [number, number][] {\n  const pts: [number, number][] = []\n  for (let i = 0; i <= n; i++) {\n    const t = (i / n) * (Math.PI / 2)\n    pts.push([R * Math.cos(t), x0 + dir * depth * Math.sin(t)])\n  }\n  return dir > 0 ? pts : pts.reverse()\n}\n\nexport function head(k: Kit, mat: MatKey, R: number, depth: number, x0: number, dir: 1 | -1, seg = 64) {\n  k.lathe(mat, headProfile(R, depth, x0, dir), [0, 0, 0], 'x', seg)\n}\n\n/** Shell cylinder about X between x0 < x1. */\nexport function shellX(k: Kit, mat: MatKey, R: number, x0: number, x1: number, seg = 64) {\n  if (x1 - x0 > 1e-4) k.lathe(mat, [[R, x0], [R, x1]], [0, 0, 0], 'x', seg)\n}\n\n/** Circumferential weld seam bead about X. */\nexport function seamX(k: Kit, mat: MatKey, R: number, x: number) {\n  k.torus(mat, R + 0.001, 0.005, [x, 0, 0], 'x', 6, 64)\n}\n\n/** Eccentric cone about X, circle (r0, centre y0) at x0 to (r1, y1) at x1 (flat-bottom kettle reducer). */\nexport function eccentricCone(k: Kit, mat: MatKey, x0: number, r0: number, y0: number, x1: number, r1: number, y1: number, seg = 64) {\n  const pos: number[] = []\n  const idx: number[] = []\n  for (let i = 0; i <= seg; i++) {\n    const a = (i / seg) * Math.PI * 2\n    const c = Math.cos(a)\n    const s = Math.sin(a)\n    pos.push(x0, y0 + r0 * s, r0 * c, x1, y1 + r1 * s, r1 * c)\n  }\n  for (let i = 0; i < seg; i++) {\n    const a = i * 2\n    idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2)\n  }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n/** Gasket + through-studs for two B16.5 flanges meeting face to face at `mid` along `axis`. */\nexport function jointBolting(k: Kit, nps: number, rating: Rating, mid: V3, axis: V3) {\n  const f = flangeDims(nps, rating)\n  const v = axisVec(axis)\n  const g = 0.0045\n  gasket(k, nps, rating, mid, axis, g)\n  const span = 2 * (f.thickness + f.raisedFaceHeight) + g\n  const studL = span + f.boltDia * 3\n  const start = new THREE.Vector3(...mid).addScaledVector(v, -studL / 2).toArray() as V3\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, start, axis, f.boltDia, studL, [f.boltDia * 0.95, studL - f.boltDia * 0.95])\n}\n\n/** Earthing boss: small stainless pad with a bolt, on a surface facing +normal. */\nexport function earthBoss(k: Kit, pos: V3, normal: 'x' | 'z' | '-x' | '-z' | 'y') {\n  k.box('stainless', [0.05, 0.05, 0.05], pos)\n  const v = axisVec(normal)\n  k.hex('nut', 0.022, 0.012, [pos[0] + v.x * 0.03, pos[1] + v.y * 0.03, pos[2] + v.z * 0.03], normal)\n}\n\n/** nozzle() with a dark bore end just above the shell (and pad) surface, so an open nozzle reads hollow instead of showing the painted shell. */\nexport function openNozzle(k: Kit, o: NozzleOpts & { shellR?: number }): V3 {\n  const face = nozzle(k, o)\n  if (!o.blind) {\n    const ri = pipeOD(o.nps) / 2 - pipeWall(o.nps, '80')\n    const sag = o.shellR ? o.shellR - Math.sqrt(Math.max(0, o.shellR ** 2 - ri ** 2)) : 0\n    const d = new THREE.Vector3(...o.dir).normalize()\n    const c = new THREE.Vector3(...o.base).addScaledVector(d, Math.max(sag + 0.004, o.pad ? 0.0095 : 0.004))\n    k.disc('pipeBore', ri * 0.995, c.toArray() as V3, d.toArray() as V3, 28)\n  }\n  return face\n}\n"
    }
  ],
  "categories": [
    "thermal"
  ],
  "meta": {
    "export": "CoolingTower",
    "anchor": "base",
    "standards": [
      "CTI STD-201",
      "CTI ATC-105",
      "ASME B16.5",
      "ISO 14122"
    ]
  }
}