{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "shell-and-tube-exchanger",
  "type": "registry:component",
  "title": "Shell-and-tube exchanger",
  "description": "TEMA shell-and-tube exchanger on fixed and sliding saddles: AES (removable channel, floating head with shell cover), AEU (U-tube), BEM (fixed tubesheet bonnets with expansion bellows) or AKT (kettle reboiler with eccentric cone). Bolted girth flanges with full studding, channel cover davit, shell and channel nozzles, vents, drains and nameplate. Shells can be stacked two high in series with bolted stacking saddles and nozzle-to-nozzle interconnects.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "thermal/shell-and-tube-exchanger.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/shell-and-tube-exchanger.tsx",
      "content": "// Shell-and-tube heat exchanger, TEMA AES / AEU / BEM / AKT (kettle), single or stacked, on saddles. TEMA 10th ed., ASME VIII-1, ASME B16.5.\nimport * as THREE from 'three'\nimport { Kit, mat4, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { gauge, liftingLug, saddle, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { nozzleStandout, pipeOD, shellThickness, type Rating } from '../lib/standards'\nimport { clamp, earthBoss, eccentricCone, girthDims, girthFlange, girthStuds, head, jointBolting, openNozzle, seamX, shellX } from './_thermal'\n\nexport type Tema = 'AES' | 'AEU' | 'BEM' | 'AKT'\n\nexport interface ShellAndTubeExchangerProps {\n  tema?: Tema\n  /** shell inside diameter, m */\n  diameter?: number\n  /** tube length, m */\n  length?: number\n  /** tube passes (AES/AEU/AKT are rounded up to even) */\n  passes?: 1 | 2 | 4 | 6\n  /** shells stacked in series (not for AKT) */\n  stacked?: 1 | 2\n  /** shell bottom above deck, m */\n  elevation?: number\n  /** shell-side design pressure, barg */\n  pressure?: number\n  rating?: Rating\n  shellNps?: number\n  tubeNps?: number\n  insulated?: boolean\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  tema: 'AES' as Tema,\n  diameter: 0.9,\n  length: 6,\n  passes: 2 as 1 | 2 | 4 | 6,\n  stacked: 1 as 1 | 2,\n  elevation: 0.9,\n  pressure: 20,\n  rating: 300 as Rating,\n  shellNps: 8,\n  tubeNps: 6,\n  insulated: false,\n  tag: 'E-100',\n  paint: 'equipmentPaint' as MatKey,\n}\n\ntype P = typeof defaults\n\nconst GASKET = 0.0045\nconst smallNps = (n: number) => (n >= 10 ? 6 : n >= 8 ? 4 : n >= 6 ? 3 : 2)\n\nfunction dims(p: P) {\n  const front = p.tema[0] as 'A' | 'B'\n  const kettle = p.tema[1] === 'K'\n  const rear = p.tema[2] as 'S' | 'U' | 'M' | 'T'\n  const passes = p.tema === 'BEM' ? p.passes : Math.max(2, p.passes + (p.passes % 2))\n  const odd = passes % 2 === 1\n  const stacked = kettle ? 1 : p.stacked\n  const D = p.diameter\n  const wall = shellThickness(p.pressure, D)\n  const Ro = D / 2 + wall\n  const G = girthDims(Ro)\n  const tts = clamp(0.05 + 0.05 * D, 0.06, 0.16) // typical tubesheet\n  const tcov = clamp(0.04 + 0.05 * D, 0.05, 0.14) // typical flat channel cover\n  const hd = D / 4 + wall\n  const fl = G.tf + G.hubL\n  const tOD = pipeOD(p.tubeNps)\n  const sOD = pipeOD(p.shellNps)\n  const so = (nps: number) => nozzleStandout(nps, p.rating, p.insulated)\n\n  // stations along X with the stationary tubesheet channel face at x = 0\n  const xT1 = tts\n  let xFront: number\n  let xn: number\n  if (front === 'A') {\n    const Lc = 2 * fl + Math.max(0.25, tOD * 2.2) + (passes >= 4 ? 0.1 : 0)\n    xn = -Lc / 2\n    xFront = -Lc - tcov\n  } else {\n    const Lb = fl + tOD * 1.5 + 0.12\n    xn = -fl - tOD * 0.75 - 0.06\n    xFront = -Lb - hd\n  }\n  const Rk = kettle ? Math.round(Ro * 1.65 * 20) / 20 : Ro\n  const hdk = kettle ? Rk / 2 : hd\n  let xShellEnd = xT1 + p.length // where the shell barrel stops\n  let xRear: number\n  let xnr = 0\n  let xK0 = 0\n  let xK1 = 0\n  // TEMA S: the shell cover is larger than the shell to clear the floating head; reached through a conical expander\n  const Rc = rear === 'S' ? Ro + clamp(0.1 * D, 0.06, 0.16) : Ro\n  const Gc = girthDims(Rc)\n  const coneL = rear === 'S' ? (Rc - Ro) * 2.5 : 0\n  const hdc = Rc / 2\n  if (rear === 'S') {\n    xShellEnd = xT1 + p.length + 0.12 + coneL\n    xRear = xShellEnd + 0.3 + 0.3 * D + hdc\n  } else if (rear === 'U') {\n    xShellEnd = xT1 + p.length + 0.1 + 0.15 * D\n    xRear = xShellEnd + hd\n  } else if (rear === 'M') {\n    xShellEnd = xT1 + p.length - 2 * tts\n    const Lb = fl + tOD * 1.5 + 0.12\n    xnr = xShellEnd + tts + fl + tOD * 0.75 + 0.06\n    xRear = xShellEnd + tts + Lb + hd\n  } else {\n    xK0 = xT1 + fl + Math.max(0.35, 0.25 * D)\n    xK1 = xK0 + (Rk - Ro) * 2\n    xShellEnd = Math.max(xT1 + p.length + 0.35, xK1 + 1)\n    xRear = xShellEnd + hdk\n  }\n  const off = -(xFront + xRear) / 2\n  // shell nozzles near the front and rear of the barrel\n  const m = fl + sOD * 0.7 + 0.12\n  // saddles: clear of bottom nozzles\n  const ends: [number, number] = kettle ? [xK1, xShellEnd] : [xT1, rear === 'S' ? xShellEnd - Gc.tf - Gc.hubL - coneL : xShellEnd]\n  const span = ends[1] - ends[0]\n  const inset = Math.min(span * 0.3, Math.max(0.2 * span, m + sOD + 0.35))\n  const saddles = [ends[0] + inset, ends[1] - inset]\n  const xs1 = kettle ? saddles[0] + 0.3 * (saddles[1] - saddles[0]) : xT1 + m\n  const xs2 = kettle ? saddles[0] + 0.62 * (saddles[1] - saddles[0]) : (rear === 'S' ? xShellEnd - Gc.hubL - Gc.tf - coneL : xShellEnd) - m\n\n  const yc1 = p.elevation + Ro\n  const ycK = p.elevation + Rk\n  // stacked: shells joined nozzle to nozzle; the gap holds two flanged necks and the stacking saddles\n  const sI = Math.max(so(p.shellNps), so(p.tubeNps), 0.22)\n  const gap = 2 * sI + GASKET\n  const yc2 = yc1 + 2 * Ro + gap\n  return { Rc, Gc, coneL, hdc, front, kettle, rear, passes, odd, stacked, D, wall, Ro, G, tts, tcov, hd, hdk, Rk, fl, xT1, xFront, xRear, xn, xnr, xK0, xK1, xShellEnd, off, xs1, xs2, saddles, yc1, ycK, yc2, sI, gap, so }\n}\n\ntype Dims = ReturnType<typeof dims>\n\nexport function ports(props: ShellAndTubeExchangerProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  const { Ro, off, so } = d\n  const up: V3 = [0, 1, 0]\n  const dn: V3 = [0, -1, 0]\n  if (d.kettle) {\n    const lq = smallNps(p.shellNps)\n    return [\n      port('shell-in', 'Shell inlet (feed)', [d.xs1 + off, d.ycK - d.Rk - so(lq), 0], dn, lq, p.rating),\n      port('shell-out', 'Shell outlet (vapour)', [d.xs2 + off, d.ycK + d.Rk + so(p.shellNps), 0], up, p.shellNps, p.rating),\n      port('tube-in', 'Tube inlet', [d.xn + off, d.yc1 - Ro - so(p.tubeNps), 0], dn, p.tubeNps, p.rating),\n      port('tube-out', 'Tube outlet', [d.xn + off, d.yc1 + Ro + so(p.tubeNps), 0], up, p.tubeNps, p.rating),\n      port('liquid', 'Liquid overflow', [d.xShellEnd - 0.35 + off, d.ycK - d.Rk - so(lq), 0], dn, lq, p.rating),\n    ]\n  }\n  const yTop = d.stacked === 2 ? d.yc2 : d.yc1\n  const xOut = d.odd && d.stacked === 1 ? d.xnr : d.xn\n  return [\n    port('shell-in', 'Shell inlet', [d.xs1 + off, yTop + Ro + so(p.shellNps), 0], up, p.shellNps, p.rating),\n    port('shell-out', 'Shell outlet', [(d.stacked === 2 ? d.xs1 : d.xs2) + off, d.yc1 - Ro - so(p.shellNps), 0], dn, p.shellNps, p.rating),\n    port('tube-in', 'Tube inlet', [d.xn + off, d.yc1 - Ro - so(p.tubeNps), 0], dn, p.tubeNps, p.rating),\n    port('tube-out', 'Tube outlet', [xOut + off, yTop + Ro + so(p.tubeNps), 0], up, p.tubeNps, p.rating),\n  ]\n}\n\nexport function ShellAndTubeExchanger(props: ShellAndTubeExchangerProps & GroupProps) {\n  const { tema, diameter, length, passes, stacked, elevation, pressure, rating, shellNps, tubeNps, insulated, tag, paint, ...group } = { ...defaults, ...props }\n  const p = { tema, diameter, length, passes, stacked, elevation, pressure, rating, shellNps, tubeNps, insulated, tag, paint } as P\n  const data = useKit((k) => build(k, p), Object.values(p))\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default ShellAndTubeExchanger\n\nfunction build(k: Kit, p: P) {\n  const d = dims(p)\n  k.at([d.off, 0, 0], undefined, () => {\n    if (d.stacked === 2) {\n      unit(k, p, d, d.yc1, 'lower')\n      unit(k, p, d, d.yc2, 'upper')\n      stackingSaddles(k, p, d)\n    } else unit(k, p, d, d.yc1, 'single')\n  })\n}\n\n// One shell with its channel and rear end, on the shell axis at height yc.\nfunction unit(k: Kit, p: P, d: Dims, yc: number, role: 'single' | 'lower' | 'upper') {\n  const { Ro, G, fl, tts, xT1 } = d\n  const P = p.paint\n  const S: MatKey = p.insulated ? 'insulation' : P\n  const so = d.so\n  const nz = (base: V3, dir: V3, nps: number, standout = so(nps), studs = true, blind = false) =>\n    openNozzle(k, { base, dir, nps, rating: p.rating, standout, pad: pipeOD(nps) * 1.8, mat: P, studs, blind, shellR: Ro })\n  const top = (x: number, R = Ro, y = yc): V3 => [x, y + R, 0]\n  const bot = (x: number, R = Ro, y = yc): V3 => [x, y - R, 0]\n\n  k.at([0, yc, 0], undefined, () => {\n    // ---------------- front end: A channel with flat cover, or B bonnet\n    if (d.front === 'A') {\n      const xC = d.xFront + d.tcov\n      girthFlange(k, P, xC, Ro, G, 1)\n      girthFlange(k, P, 0, Ro, G, -1)\n      shellX(k, P, Ro, xC + fl - 0.001, -fl + 0.001)\n      k.cyl(P, G.Rf, d.tcov, [xC - d.tcov / 2, 0, 0], 'x', 64)\n      girthStuds(k, xC - d.tcov, xC + G.tf, G)\n      liftingLug(k, [xC - d.tcov - 0.001, G.Rf * 0.45, 0], 'z', clamp(Ro / 0.5, 0.7, 1.3), P)\n      k.cyl(P, 0.05, 0.08, [xC - d.tcov - 0.04, -G.Rf * 0.3, 0], 'x', 12) // jacking lug\n      if (role !== 'lower') davit(k, P, d, xC)\n    } else {\n      girthFlange(k, P, 0, Ro, G, -1)\n      shellX(k, P, Ro, d.xFront + d.hd, -fl + 0.001)\n      head(k, P, Ro, d.hd, d.xFront + d.hd, -1)\n      seamX(k, P, Ro, d.xFront + d.hd)\n      liftingLug(k, [d.xFront + d.hd * 0.6, Ro * 0.8, 0], 'x', 0.8, P)\n    }\n    // stationary tubesheet rim, sandwiched (A) or extended as a flange (B)\n    k.lathe('flangeSteel', [[Ro, 0], [G.Rf - 0.004, 0], [G.Rf - 0.004, tts], [Ro, tts]], [0, 0, 0], 'x', 64)\n    girthFlange(k, P, xT1, Ro, G, 1)\n    girthStuds(k, -G.tf, xT1 + G.tf, G)\n\n    // ---------------- shell barrel and rear end\n    const Rk = d.Rk\n    if (d.kettle) {\n      shellX(k, S, Ro, xT1 + fl - 0.001, d.xK0)\n      eccentricCone(k, S, d.xK0, Ro, 0, d.xK1, Rk, Rk - Ro, 64)\n      k.at([0, Rk - Ro, 0], undefined, () => {\n        shellX(k, S, Rk, d.xK1, d.xShellEnd)\n        head(k, S, Rk, d.hdk, d.xShellEnd, 1)\n        seamX(k, P, Rk, d.xK1)\n        seamX(k, P, Rk, d.xShellEnd)\n      })\n      seamX(k, P, Ro, d.xK0)\n    } else if (d.rear === 'S') {\n      const xR = d.xShellEnd\n      const { Rc, Gc, coneL } = d\n      const flc = Gc.tf + Gc.hubL\n      const xc0 = xR - flc - coneL\n      shellX(k, S, Ro, xT1 + fl - 0.001, xc0)\n      k.lathe(S, [[Ro, xc0], [Rc, xc0 + coneL]], [0, 0, 0], 'x', 64)\n      seamX(k, P, Ro, xc0)\n      shellX(k, S, Rc, xc0 + coneL - 0.001, xR - flc + 0.001)\n      girthFlange(k, P, xR - GASKET / 2, Rc, Gc, -1)\n      girthFlange(k, P, xR + GASKET / 2, Rc, Gc, 1)\n      girthStuds(k, xR - Gc.tf, xR + Gc.tf, Gc)\n      const xH = d.xRear - d.hdc\n      shellX(k, S, Rc, xR + flc - 0.001, xH)\n      head(k, S, Rc, d.hdc, xH, 1)\n      seamX(k, P, Rc, xH)\n      liftingLug(k, [xH + d.hdc * 0.55, Rc * 0.78, 0], 'x', 0.8, P)\n      k.cyl(P, 0.05, 0.08, [d.xRear + 0.03, -Rc * 0.2, 0], 'x', 12) // pulling eye boss\n    } else if (d.rear === 'U') {\n      shellX(k, S, Ro, xT1 + fl - 0.001, d.xShellEnd)\n      head(k, S, Ro, d.hd, d.xShellEnd, 1)\n      seamX(k, P, Ro, d.xShellEnd)\n    } else {\n      // BEM: fixed tubesheets, thick-walled expansion bellows mid-shell, rear bonnet\n      const xR = d.xShellEnd\n      shellX(k, S, Ro, xT1 + fl - 0.001, xR)\n      const xm = (xT1 + xR) / 2\n      const eb = clamp(0.06 + 0.05 * p.diameter, 0.07, 0.14)\n      for (const s of [-1, 1]) k.cyl(P, Ro + 0.012, 0.03, [xm + s * eb * 1.6, 0, 0], 'x', 64)\n      k.lathe(P, [[Ro, xm - eb * 1.4], [Ro + eb, xm - eb * 0.6], [Ro + eb, xm + eb * 0.6], [Ro, xm + eb * 1.4]], [0, 0, 0], 'x', 64)\n      k.lathe('flangeSteel', [[Ro, xR], [G.Rf - 0.004, xR], [G.Rf - 0.004, xR + tts], [Ro, xR + tts]], [0, 0, 0], 'x', 64)\n      const xB = xR + tts\n      girthFlange(k, P, xB, Ro, G, 1)\n      girthStuds(k, xR - 0.005, xB + G.tf, G)\n      shellX(k, P, Ro, xB + fl - 0.001, d.xRear - d.hd)\n      head(k, P, Ro, d.hd, d.xRear - d.hd, 1)\n      seamX(k, P, Ro, d.xRear - d.hd)\n    }\n    for (let x = xT1 + fl + 2.4; x < (d.kettle ? d.xK0 : d.xShellEnd) - 0.6; x += 2.4) seamX(k, P, Ro, x)\n  })\n\n  // ---------------- nozzles\n  const tNps = p.tubeNps\n  const sNps = p.shellNps\n  const sI = d.sI\n  if (d.kettle) {\n    const lq = smallNps(sNps)\n    const ycK = d.ycK\n    nz(bot(d.xs1, d.Rk, ycK), [0, -1, 0], lq)\n    nz(top(d.xs2, d.Rk, ycK), [0, 1, 0], sNps)\n    nz(bot(d.xShellEnd - 0.35, d.Rk, ycK), [0, -1, 0], lq)\n    nz(top(d.xK1 + 0.3, d.Rk, ycK), [0, 1, 0], 2, 0.2, true, true) // vent\n    nz(top(d.xShellEnd - 0.4, d.Rk, ycK), [0, 1, 0], 3, so(3), true, true) // PSV / spare\n    // level gauge on the kettle end\n    const xL = d.xShellEnd - 0.7\n    const zL = d.Rk + 0.3\n    k.cyl('stainless', 0.04, d.Rk * 1.3, [xL, ycK, zL], 'y', 16)\n    for (const y of [ycK + d.Rk * 0.55, ycK - d.Rk * 0.55]) k.pipe('stainless', [xL, y, Math.sqrt(d.Rk ** 2 - (y - ycK) ** 2) - 0.02], [xL, y, zL], 0.05, 0.005)\n    k.box('machined', [0.07, d.Rk * 1.0, 0.05], [xL, ycK, zL + 0.05])\n    k.box('glass', [0.03, d.Rk * 0.9, 0.006], [xL, ycK, zL + 0.078])\n  } else {\n    const tin: V3 = bot(d.xn, Ro, yc)\n    const toutX = d.odd && role !== 'upper' ? d.xnr : d.xn\n    const tinX = role === 'upper' && d.odd ? d.xnr : d.xn\n    if (role === 'single') {\n      nz(top(d.xs1, Ro, yc), [0, 1, 0], sNps)\n      nz(bot(d.xs2, Ro, yc), [0, -1, 0], sNps)\n      nz(tin, [0, -1, 0], tNps)\n      nz(top(toutX, Ro, yc), [0, 1, 0], tNps)\n    } else if (role === 'lower') {\n      nz(top(d.xs2, Ro, yc), [0, 1, 0], sNps, sI, false)\n      nz(bot(d.xs1, Ro, yc), [0, -1, 0], sNps)\n      nz(tin, [0, -1, 0], tNps)\n      nz(top(toutX, Ro, yc), [0, 1, 0], tNps, sI, false)\n      const ym = yc + Ro + sI + GASKET / 2\n      jointBolting(k, sNps, p.rating, [d.xs2, ym, 0], [0, 1, 0])\n      jointBolting(k, tNps, p.rating, [toutX, ym, 0], [0, 1, 0])\n    } else {\n      nz(top(d.xs1, Ro, yc), [0, 1, 0], sNps)\n      nz(bot(d.xs2, Ro, yc), [0, -1, 0], sNps, sI, false)\n      nz(bot(tinX, Ro, yc), [0, -1, 0], tNps, sI, false)\n      nz(top(d.xn, Ro, yc), [0, 1, 0], tNps)\n    }\n    // shell vent and drain (blinded)\n    const xv = d.rear === 'S' ? d.xShellEnd - d.Gc.tf - d.Gc.hubL - d.coneL - 0.18 : d.xShellEnd - 0.2\n    const a = 0.6\n    nz([xv, yc + Ro * Math.cos(a), Ro * Math.sin(a)], [0, Math.cos(a), Math.sin(a)], 1.5, 0.2, true, true)\n    nz([xT1 + fl + 0.18, yc - Ro * Math.cos(a), Ro * Math.sin(a)], [0, -Math.cos(a), Math.sin(a)], 1.5, 0.2, true, true)\n    // thermowell + gauge on the shell inlet side\n    const xg = d.xs1 + pipeOD(sNps) * 0.9 + 0.1\n    k.pipe('stainless', [xg, yc + Ro * Math.cos(0.8), Ro * Math.sin(0.8) - 0.01], [xg, yc + (Ro + 0.12) * Math.cos(0.8), (Ro + 0.12) * Math.sin(0.8)], 0.034, 0.004)\n    gauge(k, [xg, yc + (Ro + 0.17) * Math.cos(0.8), (Ro + 0.17) * Math.sin(0.8)], [0, Math.cos(0.8), Math.sin(0.8)], 0.1, '°C', 200)\n  }\n  // channel / bonnet vent and drain on the front end (blinded)\n  {\n    const a = 0.75\n    const x = d.front === 'A' ? d.xn + pipeOD(tNps) * 0.5 + 0.08 : d.xn\n    for (const s of [1, -1]) nz([x, yc + s * Ro * Math.cos(a), Ro * Math.sin(a)], [0, s * Math.cos(a), Math.sin(a)], 1, 0.18, true, true)\n  }\n\n  // ---------------- saddles (fixed at the channel end, sliding at the rear), lugs, nameplate, earthing\n  const Rs = d.kettle ? d.Rk : Ro\n  const ys = d.kettle ? d.ycK : yc\n  if (role !== 'upper')\n    k.at([0, ys, 0], undefined, () => {\n      saddle(k, d.saddles[0], Rs, -ys, { mat: P })\n      saddle(k, d.saddles[1], Rs, -ys, { mat: P, sliding: true })\n    })\n  if (role !== 'lower') for (const x of d.saddles) liftingLug(k, [x + (x === d.saddles[0] ? 0.35 : -0.35), ys + Rs - 0.01, 0], 'x', clamp(Rs / 0.5, 0.8, 1.5), P)\n  if (role !== 'lower') {\n    const xp = d.saddles[0] + Math.min(0.9, (d.saddles[1] - d.saddles[0]) * 0.3)\n    const a = -0.35\n    const n = new THREE.Vector3(0, Math.sin(a), Math.cos(a))\n    const c = (r: number): V3 => [xp, ys + n.y * r, n.z * r]\n    const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), n)\n    for (const dx of [-0.12, 0.12]) k.box(P, [0.02, 0.07, 0.06], [xp + dx, ys + n.y * (Rs + 0.03), n.z * (Rs + 0.03)], q)\n    k.box(P, [0.36, 0.22, 0.006], c(Rs + 0.063), q)\n    tagPlate(k, p.tag, c(Rs + 0.067), [0, n.y, n.z], 0.3)\n  }\n  if (role !== 'upper') earthBoss(k, [d.saddles[0] + 0.2, 0.12, Rs * 0.88 * 0.7], 'z')\n}\n\n// Hinged cover davit on the channel's +Z side: bracket, vertical post, arm over the cover, hook.\nfunction davit(k: Kit, P: MatKey, d: Dims, xC: number) {\n  const { Ro, G } = d\n  const z = G.Rf + 0.09\n  const xp = xC + d.G.tf + d.G.hubL + 0.08\n  const yTop = G.Rf + 0.28\n  for (const y of [-Ro * 0.35, Ro * 0.45]) {\n    k.box(P, [0.12, 0.08, z - Ro * 0.9], [xp, y, (z + Ro * 0.9) / 2])\n    k.cyl(P, 0.05, 0.1, [xp, y, z], 'y', 16)\n  }\n  k.cyl('flangeSteel', 0.03, yTop + Ro * 0.45, [xp, (yTop - Ro * 0.45) / 2, z], 'y', 12)\n  const xa = xC - d.tcov / 2\n  k.tube(P, [[xp, yTop, z], [xp, yTop + 0.12, z * 0.5], [xa, yTop + 0.12, 0]], 0.035, 10, 0.12)\n  k.cyl(P, 0.04, 0.06, [xa, yTop + 0.08, 0], 'y', 10)\n  k.rod('nut', [xa, yTop + 0.06, 0], [xa, G.Rf + 0.1, 0], 0.01, 6)\n  k.torus('nut', 0.03, 0.008, [xa, G.Rf + 0.08, 0], 'z', 6, 14)\n}\n\n// Bolted stacking saddles between the two shells.\nfunction stackingSaddles(k: Kit, p: P, d: Dims) {\n  const yi = d.yc1 + d.Ro + d.gap / 2\n  for (const [i, x] of d.saddles.entries()) {\n    k.at([0, d.yc2, 0], undefined, () => saddle(k, x, d.Ro, yi - d.yc2, { mat: p.paint, sliding: i === 1 }))\n    k.with(mat4([0, d.yc1, 0], undefined, [1, -1, 1]), () => saddle(k, x, d.Ro, -(yi - d.yc1), { mat: p.paint }))\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": "ShellAndTubeExchanger",
    "anchor": "base",
    "standards": [
      "TEMA 10th ed.",
      "ASME BPVC VIII-1",
      "ASME B16.5",
      "API 660"
    ]
  }
}