{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "air-cooled-exchanger",
  "type": "registry:component",
  "title": "Air-cooled exchanger (fin-fan)",
  "description": "API 661 air-cooled heat exchanger: finned-tube bundles with plug-type or bolted cover-plate box headers, galvanised plenums and fan rings, forced- or induced-draft fans with V-belt drives, vertical TEFC motors, belt guards and vibration switches, optional top louvres. Column structure with bracing, header walkways with handrails and caged ladders, inlet and outlet manifolds across the bays. 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/air-cooled-exchanger.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/air-cooled-exchanger.tsx",
      "content": "// Air-cooled heat exchanger (fin-fan), forced or induced draft, plug-type box headers, belt-driven fans, column structure and header walkways. API 661 / ISO 13706.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, mat4, uvScale, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { flange, grating, handrail, ladder, liftingLug, member, motor, motorSize, nozzle, pipe, tagPlate, weldBead, weldCap } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { Rotors, fanImpeller, type RotorSpec } from '../lib/rotors'\nimport { ACCESS, flange as flangeDims, nozzleStandout, pipeOD, type Rating } from '../lib/standards'\nimport { fanGuardMaterial, louvreMaterial, meshGuardMaterial } from '../lib/textures'\nimport { clamp, jointBolting, openNozzle } from './_thermal'\n\nexport interface AirCooledExchangerProps {\n  bays?: number\n  /** fans per bay, along the tubes */\n  fansPerBay?: 1 | 2 | 3\n  /** finned tube length, m */\n  tubeLength?: number\n  /** bundle width per bay, m */\n  bayWidth?: number\n  /** tube rows */\n  rows?: number\n  draft?: 'forced' | 'induced'\n  /** top-of-steel under the bundles, m */\n  height?: number\n  passes?: 1 | 2\n  louvres?: boolean\n  header?: 'plug' | 'cover-plate'\n  inletNps?: number\n  outletNps?: number\n  rating?: Rating\n  /** motor power per fan, kW */\n  kw?: number\n  running?: boolean\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  bays: 2,\n  fansPerBay: 2 as 1 | 2 | 3,\n  tubeLength: 9.1,\n  bayWidth: 3.0,\n  rows: 6,\n  draft: 'forced' as 'forced' | 'induced',\n  height: 4.5,\n  passes: 2 as 1 | 2,\n  louvres: true,\n  header: 'plug' as 'plug' | 'cover-plate',\n  inletNps: 10,\n  outletNps: 8,\n  rating: 300 as Rating,\n  kw: 30,\n  running: true,\n  tag: 'E-200',\n  paint: 'equipmentPaint' as MatKey,\n}\n\ntype P = typeof defaults\n\nconst NPS = [2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24]\nconst down = (n: number) => NPS[Math.max(0, NPS.findIndex((v) => v >= n) - 1)]\nconst GASKET = 0.0045\nconst PITCH = 0.0635 // typical 2.5\" transverse tube pitch\nconst ROW = 0.055 // typical row pitch\n\nfunction dims(p: P) {\n  const bays = Math.max(1, Math.round(p.bays))\n  const L = p.tubeLength\n  const W = p.bayWidth\n  const Wt = bays * W\n  const n = p.fansPerBay\n  const forced = p.draft === 'forced'\n  const hb = p.rows * ROW + 0.06\n  const hs = hb + 0.1 // side frame depth\n  const hw = 0.32 // header box, along the tubes\n  const hh = hb + 0.14\n  const yB = p.height\n  const yHc = yB + hs / 2\n  // API 661: fans cover ≥ 40 % of the bundle face; ≤ 4.3 m typical\n  const D = clamp(Math.ceil(Math.sqrt((1.6 * L * W) / (Math.PI * n)) / 0.15) * 0.15, 1.2, Math.min(W - 0.25, L / n - 0.3, 4.3))\n  const plen = Math.max(0.9, 0.35 * D)\n  const ringH = clamp(0.25 * D, 0.35, 0.6)\n  const yF = forced ? yB - plen : yB + hs + plen // fan deck plate\n  const yFan = forced ? yF - ringH / 2 : yF + ringH * 0.45\n  const yP = forced ? yFan - 0.85 : yB - 0.18 // drive plate top\n  const fans = Array.from({ length: n }, (_, j) => -L / 2 + ((j + 0.5) * L) / n)\n  const bayZ = Array.from({ length: bays }, (_, i) => -Wt / 2 + (i + 0.5) * W)\n  const xh = L / 2 + hw / 2 // header centre (front header at -xh)\n  const branch = bays > 1\n  const inB = branch ? down(p.inletNps) : p.inletNps\n  const outB = branch ? down(p.outletNps) : p.outletNps\n  const so = (nps: number) => nozzleStandout(nps, p.rating)\n  const yHt = yHc + hh / 2\n  const yHb = yHc - hh / 2\n  const xOut = p.passes === 2 ? -xh : xh\n  const yMi = yHt + so(inB) + GASKET + 0.35 + pipeOD(p.inletNps) / 2 // inlet manifold axis\n  const yMo = yHb - so(outB) - GASKET - 0.35 - pipeOD(p.outletNps) / 2\n  const zPort = Wt / 2 + 0.7\n  return { bays, L, W, Wt, n, forced, hb, hs, hw, hh, yB, yHc, D, plen, ringH, yF, yFan, yP, fans, bayZ, xh, branch, inB, outB, so, yHt, yHb, xOut, yMi, yMo, zPort }\n}\n\ntype Dims = ReturnType<typeof dims>\n\nexport function ports(props: AirCooledExchangerProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  if (!d.branch)\n    return [\n      port('inlet', 'Inlet header', [-d.xh, d.yHt + d.so(d.inB), d.bayZ[0]], [0, 1, 0], d.inB, p.rating),\n      port('outlet', 'Outlet header', [d.xOut, d.yHb - d.so(d.outB), d.bayZ[0]], [0, -1, 0], d.outB, p.rating),\n    ]\n  return [\n    port('inlet', 'Inlet manifold', [-d.xh, d.yMi, d.zPort], [0, 0, 1], p.inletNps, p.rating),\n    port('outlet', 'Outlet manifold', [d.xOut, d.yMo, d.zPort], [0, 0, 1], p.outletNps, p.rating),\n  ]\n}\n\nexport function AirCooledExchanger(props: AirCooledExchangerProps & GroupProps) {\n  const { bays, fansPerBay, tubeLength, bayWidth, rows, draft, height, passes, louvres, header, inletNps, outletNps, rating, kw, running, tag, paint, ...group } = { ...defaults, ...props }\n  const p = { bays, fansPerBay, tubeLength, bayWidth, rows, draft, height, passes, louvres, header, inletNps, outletNps, rating, kw, running, tag, paint } as P\n  const deps = Object.entries(p).filter(([key]) => key !== 'running').map(([, v]) => v)\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const { built, rotors } = useMemo(() => build(p), deps)\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 AirCooledExchanger\n\nfunction build(p: P) {\n  const k = new Kit()\n  const d = dims(p)\n  const rotors: RotorSpec[] = []\n  structure(k, d)\n  for (const zb of d.bayZ) bundle(k, p, d, zb)\n  plenum(k, d)\n  const m = motorSize(p.kw, 4)\n  const Rl = clamp(d.D * 0.11, 0.18, 0.42) // fan sheave\n  const Rs = Rl * 0.32\n  const c = Rl + Math.max(m.R + 0.06, Rs) + 0.2\n  const yS = d.yP - 0.13\n  const fanRpm = clamp(Math.round(3600 / d.D / 10) * 10, 200, 450) // typical tip speed ≈ 55 m/s\n  const fanB = fanBuilt(d, Rl, yS)\n  const sheaveK = new Kit()\n  sheaveK.cyl('castIron', Rs, 0.09, [0, 0, 0], 'y', 20)\n  for (const dy of [-0.02, 0.02]) sheaveK.torus('rubberBlack', Rs + 0.004, 0.008, [0, dy, 0], 'y', 6, 20)\n  const sheaveB = sheaveK.build()\n  for (const zb of d.bayZ)\n    for (const fx of d.fans) {\n      drive(k, d, fx, zb, Rl, Rs, c, m)\n      rotors.push({ built: fanB, axis: 'y', pos: [fx, d.yFan, zb], rpm: fanRpm })\n      rotors.push({ built: sheaveB, axis: 'y', pos: [fx + c, yS, zb], rpm: (fanRpm * Rl) / Rs })\n    }\n  piping(k, p, d)\n  access(k, d)\n  return { built: k.build(), rotors }\n}\n\n// Fan impeller with its shaft and driven sheave, about +Y at the fan plane.\nfunction fanBuilt(d: Dims, Rl: number, yS: number) {\n  const k = new Kit()\n  const R = d.D / 2\n  fanImpeller(k, 'aluminium', R, { axis: 'y', blades: d.D > 3 ? 6 : 4, chord: R * 0.26, pitch: 0.24, hub: R * 0.13, hubLen: 0.18, thick: 0.012, hubMat: 'castIron' })\n  const drop = d.yFan - yS\n  k.cyl('flangeSteel', 0.035, drop, [0, -drop / 2, 0], 'y', 12)\n  k.cyl('castIron', Rl, 0.1, [0, -drop, 0], 'y', 32)\n  for (const dy of [-0.025, 0.025]) k.torus('rubberBlack', Rl + 0.004, 0.009, [0, -drop + dy, 0], 'y', 6, 32)\n  for (let i = 0; i < 5; i++) {\n    const a = (i / 5) * Math.PI * 2\n    k.box('castIron', [Rl * 0.8, 0.03, 0.03], [Math.cos(a) * Rl * 0.5, -drop - 0.03, Math.sin(a) * Rl * 0.5], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a))\n  }\n  return k.build()\n}\n\nfunction structure(k: Kit, d: Dims) {\n  const xc = d.L / 2 - 0.2\n  const zs = Array.from({ length: d.bays + 1 }, (_, j) => -d.Wt / 2 + j * d.W)\n  const yT = d.yB - 0.1\n  for (const z of zs) {\n    member(k, [-xc - 0.1, yT, z], [xc + 0.1, yT, z], 'HEB 200', 'galvanised', [0, 1, 0])\n    for (const x of [-xc, xc]) {\n      member(k, [x, 0.025, z], [x, d.yB - 0.2, z], 'HEB 200', 'galvanised', [1, 0, 0])\n      k.box('galvanised', [0.4, 0.025, 0.4], [x, 0.0125, z])\n      for (const sx of [-1, 1])\n        for (const sz of [-1, 1]) {\n          k.rod('nut', [x + sx * 0.15, 0, z + sz * 0.15], [x + sx * 0.15, 0.08, z + sz * 0.15], 0.012, 8)\n          k.hex('nut', 0.04, 0.02, [x + sx * 0.15, 0.035, z + sz * 0.15])\n        }\n      // knee braces\n      for (const s of [-1, 1]) if ((s < 0 && z > zs[0]) || (s > 0 && z < zs[zs.length - 1])) member(k, [x, yT - 0.9, z], [x, yT - 0.1, z + s * 0.8], 'L70x70x7', 'galvanised')\n      member(k, [x, yT - 0.9, z], [x - Math.sign(x) * 0.8, yT - 0.1, z], 'L70x70x7', 'galvanised')\n    }\n  }\n  for (const x of [-xc, xc]) member(k, [x, yT, zs[0]], [x, yT, zs[zs.length - 1]], 'HEB 200', 'galvanised', [0, 1, 0])\n  // X-bracing on the outer faces, horizontal girts at drive level\n  const yG = d.forced ? d.yP - 0.09 : 1.6\n  for (const z of [zs[0], zs[zs.length - 1]]) {\n    member(k, [-xc, 0.35, z], [xc, yG - 0.1, z], 'L80x80x8', 'galvanised')\n    member(k, [xc, 0.35, z], [-xc, yG - 0.1, z], 'L80x80x8', 'galvanised')\n  }\n  for (const z of zs) member(k, [-xc, yG, z], [xc, yG, z], 'UPN 160', 'galvanised', [0, 1, 0])\n}\n\nfunction bundle(k: Kit, p: P, d: Dims, zb: number) {\n  const { L, W, hs, hw, hh, yB, yHc } = d\n  const P = p.paint\n  const zl = W / 2 - 0.05\n  // side frames (channel) and finned-tube bundle\n  for (const s of [-1, 1]) {\n    k.box('galvanised', [L, hs, 0.008], [0, yB + hs / 2, zb + s * zl])\n    for (const y of [yB + 0.005, yB + hs - 0.005]) k.box('galvanised', [L, 0.01, 0.08], [0, y, zb + s * (zl - 0.036)])\n  }\n  k.box('equipmentPaintDark', [L, hs - 0.07, 2 * zl - 0.02], [0, yB + hs / 2, zb])\n  const nt = Math.floor((W - 0.2) / PITCH)\n  for (const y of [yB + hs - 0.032, yB + 0.032])\n    for (let i = 0; i < nt; i++) {\n      const z = zb - ((nt - 1) / 2) * PITCH + i * PITCH\n      k.cyl('aluminium', 0.0285, L, [0, y, z], 'x', 8)\n    }\n  for (let x = -L / 2 + 1.5; x < L / 2 - 0.5; x += 1.5)\n    for (const y of [yB + hs - 0.003, yB + 0.003]) k.box('galvanised', [0.06, 0.006, 2 * zl], [x, y, zb])\n  // box headers: plug or cover-plate type\n  for (const s of [-1, 1]) {\n    const x = s * d.xh\n    k.rbox(P, [hw, hh, W - 0.06], 0.01, [x, yHc, zb])\n    k.box(P, [0.035, hh + 0.04, W - 0.03], [s * (L / 2 + hw - 0.0175), yHc, zb])\n    k.box(P, [0.03, hh + 0.04, W - 0.03], [s * (L / 2 + 0.015), yHc, zb])\n    if (p.header === 'cover-plate') {\n      // bolted removable cover plate on a perimeter flange (low-pressure, fouling service)\n      const xo = s * (L / 2 + hw)\n      const ch = hh + 0.16\n      const cw = W - 0.03\n      k.box(P, [0.04, ch, cw], [xo + s * 0.02, yHc, zb])\n      k.box('gasket', [0.006, ch - 0.02, cw - 0.02], [xo + s * 0.003, yHc, zb])\n      const bolt = (y: number, z: number) => {\n        k.cyl('boltXylan', 0.008, 0.07, [xo + s * 0.035, y, z], 'x', 6)\n        k.hex('nut', 0.03, 0.016, [xo + s * 0.048, y, z], 'x')\n      }\n      for (let z = zb - cw / 2 + 0.05; z <= zb + cw / 2 - 0.04; z += 0.11) for (const sy of [-1, 1]) bolt(yHc + sy * (ch / 2 - 0.035), z)\n      for (let y = yHc - ch / 2 + 0.12; y < yHc + ch / 2 - 0.08; y += 0.11) for (const sz of [-1, 1]) bolt(y, zb + sz * (cw / 2 - 0.035))\n      for (const sz of [-1, 1]) liftingLug(k, [xo + s * 0.02, yHc + ch / 2, zb + sz * cw * 0.3], 'z', 0.6, P)\n    } else {\n      // plug-type: a threaded shoulder plug opposite every tube\n      const xf = s * (L / 2 + hw + 0.008)\n      for (let r = 0; r < p.rows; r++) {\n        const y = yHc - ((p.rows - 1) / 2) * ROW + r * ROW\n        for (let i = 0; i < nt - (r % 2); i++) {\n          const z = zb - ((nt - 1) / 2) * PITCH + i * PITCH + (r % 2) * PITCH * 0.5\n          k.hex('flangeSteel', 0.034, 0.016, [xf, y, z], 'x')\n        }\n      }\n    }\n    for (const sz of [-1, 1]) liftingLug(k, [x, yHc + hh / 2, zb + sz * (W / 2 - 0.3)], 'x', 0.8, P)\n  }\n  // louvres on top (forced draft)\n  if (p.louvres && d.forced) {\n    const yl = yB + hs + 0.12\n    const segs = Math.max(1, Math.round(L / 4.6))\n    const sl = L / segs\n    for (let i = 0; i < segs; i++) {\n      const xm = -L / 2 + (i + 0.5) * sl\n      const g = new THREE.PlaneGeometry(sl - 0.1, 2 * zl - 0.1)\n      g.rotateX(-Math.PI / 2)\n      uvScale(g, 1, (2 * zl) / 0.15)\n      k.add(louvreMaterial('#b4b9bc'), g, mat4([xm, yl, zb]))\n      for (const s of [-1, 1]) {\n        k.box('galvanised', [sl - 0.04, 0.15, 0.05], [xm, yl, zb + s * (zl - 0.025)])\n        k.box('galvanised', [0.05, 0.15, 2 * zl], [xm + s * (sl / 2 - 0.045), yl, zb])\n      }\n      k.box('galvanised', [0.06, 0.12, 2 * zl], [xm - sl / 2 + 0.045, yl - 0.13, zb])\n    }\n    // louvre actuator on the +Z frame\n    const za = zb + zl + 0.06\n    k.rbox('actuatorBlue', [0.35, 0.12, 0.1], 0.02, [-L / 2 + 0.6, yl, za])\n    k.rod('stainless', [-L / 2 + 0.78, yl, za], [-L / 2 + 1.2, yl, za], 0.012, 8)\n  }\n  // nameplate on the outer side frame\n  if (zb === d.bayZ[d.bayZ.length - 1]) tagPlate(k, p.tag, [-L / 4, yB + hs / 2, zb + zl + 0.006], 'z', 0.35)\n}\n\nfunction plenum(k: Kit, d: Dims) {\n  const { L, W, yB, yF, hs } = d\n  const yTop = d.forced ? yB - 0.2 : yF\n  const yBot = d.forced ? yF : yB + hs\n  const h = yTop - yBot\n  const Rr = d.D / 2 + 0.025\n  for (const zb of d.bayZ) {\n    const zl = W / 2 - 0.05\n    const yc = (yTop + yBot) / 2\n    for (const s of [-1, 1]) {\n      k.box('galvanised', [L - 0.1, h, 0.005], [0, yc, zb + s * zl])\n      k.box('galvanised', [0.005, h, 2 * zl], [s * (L / 2 - 0.05), yc, zb])\n    }\n    for (let x = -L / 2 + 1; x < L / 2 - 0.3; x += 1) for (const s of [-1, 1]) k.box('galvanised', [0.05, h, 0.03], [x, yc, zb + s * (zl + 0.015)])\n    // fan deck plate with fan openings\n    const sh = new THREE.Shape()\n    sh.moveTo(-L / 2 + 0.05, -zl).lineTo(L / 2 - 0.05, -zl).lineTo(L / 2 - 0.05, zl).lineTo(-L / 2 + 0.05, zl).closePath()\n    for (const fx of d.fans) {\n      const hole = new THREE.Path()\n      hole.absarc(fx, 0, Rr, 0, Math.PI * 2, true)\n      sh.holes.push(hole)\n    }\n    k.extrude('galvanised', sh, 0.006, new THREE.Matrix4().makeRotationX(Math.PI / 2).setPosition(0, yF + 0.003, zb), 48)\n    for (const fx of d.fans) fanRing(k, d, fx, zb, Rr)\n  }\n}\n\nfunction fanRing(k: Kit, d: Dims, fx: number, zb: number, Rr: number) {\n  const t = 0.005\n  const y0 = d.forced ? d.yF - d.ringH : d.yF\n  const y1 = y0 + d.ringH\n  k.lathe('galvanised', [[Rr + t, y0], [Rr + t, y1], [Rr, y1], [Rr, y0], [Rr + t, y0]], [fx, 0, zb], 'y', 64)\n  // inlet bell and stiffening flange\n  k.torus('galvanised', Rr + 0.06, 0.06, [fx, y0, zb], 'y', 10, 64, Math.PI * 2)\n  k.torus('galvanised', Rr + t + 0.01, 0.012, [fx, (y0 + y1) / 2, zb], 'y', 6, 64)\n  // guard on the open side\n  const yG = d.forced ? y0 - 0.07 : y1 + 0.06\n  k.disc(fanGuardMaterial(), Rr + 0.04, [fx, yG, zb], 'y', 48)\n  k.torus('galvanised', Rr + 0.04, 0.01, [fx, yG, zb], 'y', 6, 48)\n  for (let i = 0; i < 6; i++) {\n    const a = (i / 6) * Math.PI * 2\n    const r = Rr + 0.04\n    k.rod('galvanised', [fx + Math.cos(a) * r, yG, zb + Math.sin(a) * r], [fx + Math.cos(a) * (Rr + 0.03), d.forced ? y0 + 0.02 : y1 - 0.02, zb + Math.sin(a) * (Rr + 0.03)], 0.01, 6)\n  }\n}\n\n// Bearing housing, belt drive and vertical TEFC motor under (forced) or below the bundle (induced).\nfunction drive(k: Kit, d: Dims, fx: number, zb: number, Rl: number, Rs: number, c: number, m: ReturnType<typeof motorSize>) {\n  const yP = d.yP\n  const yS = yP - 0.13\n  const zl = d.W / 2\n  const xa = fx - Rl - 0.1\n  const xb = fx + c + m.H + 0.08\n  // cross beams spanning the bay and the drive plate on them\n  const yCB = d.forced ? yP - 0.01 - 0.08 : yP + 0.08\n  for (const x of [xa, xb]) member(k, [x, yCB, zb - zl], [x, yCB, zb + zl], 'UPN 160', 'galvanised', [0, 1, 0])\n  k.box('galvanised', [xb - xa + 0.12, 0.02, Math.max(2 * Rl, 2 * m.R) + 0.25], [(xa + xb) / 2, yP - 0.01, zb])\n  // fan shaft bearing housing\n  const yTopH = d.forced ? d.yFan - 0.14 : yP + 0.6\n  k.cyl('castIron', 0.075, yTopH - yP, [fx, (yTopH + yP) / 2, zb], 'y', 20)\n  for (const y of [yP + 0.06, yTopH - 0.06]) k.cyl('castIron', 0.11, 0.08, [fx, y, zb], 'y', 20)\n  k.cyl('brass', 0.008, 0.05, [fx + 0.12, yTopH - 0.06, zb], 'x', 8)\n  // vibration switch on the drive frame (trips the fan on imbalance)\n  k.rbox('actuatorBlue', [0.1, 0.09, 0.07], 0.01, [xa + 0.02, yCB + (d.forced ? 0.13 : -0.13), zb + 0.25])\n  // belt and guard\n  const belt = beltShape(Rl + 0.012, Rs + 0.012, c, 0.012)\n  k.extrude('rubberBlack', belt, 0.05, new THREE.Matrix4().makeRotationX(-Math.PI / 2).setPosition(fx, yS - 0.025, zb), 24)\n  const gw = 2 * Rl + 0.12\n  const gl = c + Rl + Rs + 0.16\n  const gx = fx - Rl - 0.06 + gl / 2\n  const gg = new THREE.BoxGeometry(gl, 0.17, gw)\n  uvScale(gg, gl / 0.25, 1)\n  k.add(meshGuardMaterial(), gg, mat4([gx, yS, zb]))\n  for (const s of [-1, 1]) {\n    k.box('safetyYellow', [gl, 0.02, 0.02], [gx, yS + s * 0.085, zb - gw / 2])\n    k.box('safetyYellow', [gl, 0.02, 0.02], [gx, yS + s * 0.085, zb + gw / 2])\n  }\n  // motor hung from the plate on a side bracket, shaft up\n  const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2)\n  k.with(mat4([fx + c, yS - 0.1, zb], q), () => motor(k, { R: m.R, L: m.L, H: m.H, tbox: 'side', mat: 'motorPaint' }))\n  k.box('galvanised', [0.02, m.L * 0.9 + 0.12, m.R * 2.2], [fx + c + m.H + 0.01, yS - 0.1 - m.L * 0.45 + 0.06, zb])\n  member(k, [fx + c + m.H + 0.06, yP - 0.02, zb], [fx + c + m.H + 0.06, yS - 0.1 - m.L * 0.8, zb], 'L60x60x6', 'galvanised')\n}\n\nfunction beltShape(R1: number, R2: number, c: number, t: number) {\n  const hull = (a: number, b: number, rev: boolean) => {\n    const phi = Math.acos(clamp((a - b) / c, -1, 1))\n    const pts: THREE.Vector2[] = []\n    for (let i = 0; i <= 20; i++) {\n      const u = phi + (i / 20) * (2 * Math.PI - 2 * phi)\n      pts.push(new THREE.Vector2(Math.cos(u) * a, Math.sin(u) * a))\n    }\n    for (let i = 0; i <= 10; i++) {\n      const u = -phi + (i / 10) * 2 * phi\n      pts.push(new THREE.Vector2(c + Math.cos(u) * b, Math.sin(u) * b))\n    }\n    return rev ? pts.reverse() : pts\n  }\n  const sh = new THREE.Shape(hull(R1, R2, false))\n  sh.holes.push(new THREE.Path(hull(R1 - t, R2 - t, true)))\n  return sh\n}\n\n// Header nozzles and, for several bays, inlet/outlet manifolds with flanged ends on +Z.\nfunction piping(k: Kit, p: P, d: Dims) {\n  const P = p.paint\n  for (const [i, zb] of d.bayZ.entries()) {\n    const inFace = openNozzle(k, { base: [-d.xh, d.yHt, zb], dir: [0, 1, 0], nps: d.inB, rating: p.rating, standout: d.so(d.inB), mat: P, studs: !d.branch })\n    const outFace = openNozzle(k, { base: [d.xOut, d.yHb, zb], dir: [0, -1, 0], nps: d.outB, rating: p.rating, standout: d.so(d.outB), mat: P, studs: !d.branch })\n    // 1-pass: blinded vent on the outlet header top, drain on the inlet header bottom\n    if (p.passes === 1) {\n      nozzle(k, { base: [d.xh, d.yHt, zb + 0.4], dir: [0, 1, 0], nps: 1.5, rating: p.rating, standout: 0.18, mat: P, blind: true })\n      nozzle(k, { base: [-d.xh, d.yHb, zb + 0.4], dir: [0, -1, 0], nps: 1.5, rating: p.rating, standout: 0.18, mat: P, blind: true })\n    } else {\n      nozzle(k, { base: [-d.xh, d.yHt, zb + 0.45], dir: [0, 1, 0], nps: 1.5, rating: p.rating, standout: 0.18, mat: P, blind: true })\n      nozzle(k, { base: [d.xh, d.yHb, zb + 0.45], dir: [0, -1, 0], nps: 1.5, rating: p.rating, standout: 0.18, mat: P, blind: true })\n    }\n    if (!d.branch) continue\n    for (const [face, nps, main, yM, dir] of [\n      [inFace, d.inB, p.inletNps, d.yMi, 1],\n      [outFace, d.outB, p.outletNps, d.yMo, -1],\n    ] as const) {\n      const f2: V3 = [face[0], face[1] + dir * GASKET, face[2]]\n      const Lf = flange(k, nps, p.rating, f2, [0, -dir, 0])\n      jointBolting(k, nps, p.rating, [face[0], face[1] + (dir * GASKET) / 2, face[2]], [0, 1, 0])\n      pipe(k, [f2[0], f2[1] + dir * Lf, f2[2]], [f2[0], yM - (dir * pipeOD(main)) / 2 + dir * 0.01, f2[2]], nps, 'pipePaint')\n      weldBead(k, nps, [f2[0], f2[1] + dir * Lf, f2[2]], [0, 1, 0])\n      if (i === 0) {\n        const x = face[0]\n        const z0 = d.bayZ[0] - 0.45\n        pipe(k, [x, yM, z0], [x, yM, d.zPort - flangeLen(main, p.rating)], main)\n        weldCap(k, main, [x, yM, z0], [0, 0, -1], 'pipePaint')\n        k.disc('pipeBore', pipeOD(main) / 2 - 0.01, [x, yM, z0 + 0.002], 'z', 32) // cap seen from inside the bore\n        flange(k, main, p.rating, [x, yM, d.zPort], [0, 0, 1], true)\n        // manifold supports from the header\n        for (const zb2 of d.bayZ) {\n          const zs = zb2 - 0.6\n          const ys = dir > 0 ? d.yHt : d.yHb\n          k.box('galvanised', [0.08, Math.abs(yM - ys) - pipeOD(main) / 2, 0.08], [x, (yM - (dir * pipeOD(main)) / 2 + ys) / 2, zs])\n        }\n      }\n    }\n  }\n}\n\nconst flangeLen = (nps: number, rating: Rating) => {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\n// Header walkways at both ends with handrails, cantilevered from the columns, and a caged ladder to each.\nfunction access(k: Kit, d: Dims) {\n  const yw = d.yHc - 1.15\n  const w = 0.9\n  const zs = Array.from({ length: d.bays + 1 }, (_, j) => -d.Wt / 2 + j * d.W)\n  const zA = -d.Wt / 2 - 0.1\n  const zB = d.Wt / 2 + 1.1\n  for (const s of [-1, 1]) {\n    const xi = s * (d.L / 2 + d.hw + 0.12)\n    const xo = xi + s * w\n    const xm = (xi + xo) / 2\n    grating(k, [xm, yw, (zA + zB) / 2], w, zB - zA)\n    for (const x of [xi, xo]) member(k, [x, yw - 0.1, zA], [x, yw - 0.1, zB], 'UPN 140', 'galvanised', [0, 1, 0])\n    const xc = s * (d.L / 2 - 0.2)\n    for (const z of zs) {\n      member(k, [xc, yw - 0.1, z], [xo, yw - 0.1, z], 'UPN 140', 'galvanised', [0, 1, 0])\n      member(k, [xc, yw - 0.9, z], [xi + s * 0.3, yw - 0.18, z], 'L70x70x7', 'galvanised')\n    }\n    member(k, [xc, yw - 0.1, zB - 0.05], [xo, yw - 0.1, zB - 0.05], 'UPN 140', 'galvanised', [0, 1, 0])\n    for (const z of [zB - 0.2]) member(k, [xi, 0.0, z], [xi, yw - 0.18, z], 'L80x80x8', 'galvanised')\n    handrail(k, [[xi, yw, zA], [xo, yw, zA], [xo, yw, zB], [xm + (s * ACCESS.ladder.width) / 2 + s * 0.12, yw, zB]])\n    k.box('safetyYellow', [0.006, ACCESS.handrail.toePlate, zB - zA], [xi, yw + 0.05, (zA + zB) / 2])\n    ladder(k, [xm - s * 0.05, 0, zB + 0.2], yw, [0, 0, 1])\n  }\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": "AirCooledExchanger",
    "anchor": "base",
    "standards": [
      "API 661 / ISO 13706",
      "ASME B16.5",
      "ISO 14122",
      "IEC 60072"
    ]
  }
}