{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fired-heater",
  "type": "registry:component",
  "title": "Fired heater (vertical cylindrical)",
  "description": "API 560 vertical-cylindrical process heater: radiant casing on legs with buckstays, stiffener rings, sight and access doors; upward-firing floor burners with windboxes, air registers, a fuel gas ring and a grade-level burner management panel; snuffing-steam ring, explosion doors, draft-gauge tappings; breeching, convection section with bolted header-box covers and rotary sootblowers, external crossovers into the radiant section, transition hood, damper and open lined stack. Platforms at the sight doors, roof and damper with caged ladders.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "thermal/fired-heater.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/fired-heater.tsx",
      "content": "// Vertical-cylindrical fired heater: radiant section on legs with floor burners and sight doors, breeching, convection section with header boxes, crossovers, stack with damper, platforms and ladders. API 560.\nimport * as THREE from 'three'\nimport { Kit, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { paint } from '../lib/materials'\nimport { flange, ladder, member, pipeRun, ringPlatform, tagPlate, weldBead } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { nozzleStandout, pipeOD, type Rating } from '../lib/standards'\nimport { basePlate } from './_lattice'\nimport { clamp, openNozzle } from './_thermal'\n\nexport interface FiredHeaterProps {\n  /** radiant casing diameter, m */\n  diameter?: number\n  /** radiant section height (floor to roof), m */\n  radiantHeight?: number\n  /** stack height above the convection section, m */\n  stackHeight?: number\n  burners?: number\n  passes?: 1 | 2 | 4\n  nps?: number\n  rating?: Rating\n  tag?: string\n}\n\nexport const defaults = {\n  diameter: 4.5,\n  radiantHeight: 9,\n  stackHeight: 14,\n  burners: 6,\n  passes: 2 as 1 | 2 | 4,\n  nps: 6,\n  rating: 600 as Rating,\n  tag: 'H-100',\n}\n\ntype P = typeof defaults\n\nconst Y_FLOOR = 2.4 // typical burner access headroom under the floor\n\nfunction dims(p: P) {\n  const R = p.diameter / 2\n  const yR1 = Y_FLOOR + p.radiantHeight\n  const Wc = 0.8 * p.diameter\n  const Dc = clamp(0.45 * p.diameter, 1.4, 3)\n  const hb = 0.45 // header box depth\n  const yC0 = yR1 + 0.6 // breeching\n  const Hc = clamp(0.35 * p.radiantHeight, 2.5, 5)\n  const yC1 = yC0 + Hc\n  const Ds = clamp(0.32 * p.diameter, 0.9, 2.6)\n  const yS0 = yC1 + 1.2 // transition hood\n  const yS1 = yS0 + p.stackHeight\n  const so = nozzleStandout(p.nps, p.rating)\n  const yOut = Y_FLOOR + 0.9\n  const yFuel = Y_FLOOR - 1.35\n  return { R, yR1, Wc, Dc, hb, yC0, Hc, yC1, Ds, yS0, yS1, so, yOut, yFuel }\n}\n\nexport function ports(props: FiredHeaterProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const d = dims(p)\n  return [\n    port('inlet', 'Process inlet (convection)', [-(d.Wc / 2 + d.hb / 2), d.yC1 + d.so, 0], [0, 1, 0], p.nps, p.rating),\n    port('outlet', 'Process outlet (radiant)', [-d.R - d.so, d.yOut, 0], [-1, 0, 0], p.nps, p.rating),\n    port('fuel', 'Fuel gas', [0, d.yFuel, -(d.R + 0.6)], [0, 0, -1], 2, 300, 'utility'),\n  ]\n}\n\nexport function FiredHeater(props: FiredHeaterProps & GroupProps) {\n  const { diameter, radiantHeight, stackHeight, burners, passes, nps, rating, tag, ...group } = { ...defaults, ...props }\n  const p = { diameter, radiantHeight, stackHeight, burners, passes, nps, rating, tag } 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 FiredHeater\n\n// Four-sided frustum between two axis-aligned rectangles (half sizes), flat shaded.\nfunction frustum(k: Kit, mat: THREE.Material | 'structSteel', y0: number, ax: number, az: number, y1: number, bx: number, bz: number) {\n  const c0 = [[-ax, -az], [ax, -az], [ax, az], [-ax, az]]\n  const c1 = [[-bx, -bz], [bx, -bz], [bx, bz], [-bx, bz]]\n  const pos: number[] = []\n  for (let i = 0; i < 4; i++) {\n    const j = (i + 1) % 4\n    const q = [[c0[i][0], y0, c0[i][1]], [c0[j][0], y0, c0[j][1]], [c1[j][0], y1, c1[j][1]], [c1[i][0], y1, c1[i][1]]]\n    for (const t of [0, 2, 1, 0, 3, 2]) pos.push(...q[t])\n  }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n// Ring platform split into arcs around ladder gaps (angles, radians, 0 = +X, π/2 = -Z).\nfunction ringGaps(k: Kit, y: number, rIn: number, w: number, gaps: number[], a0 = 0, a1 = Math.PI * 2, gw = 0.32) {\n  const cuts = [a0, ...gaps.flatMap((g) => [g - gw, g + gw]), a1].sort((a, b) => a - b)\n  for (let i = 0; i < cuts.length; i += 2) if (cuts[i + 1] - cuts[i] > 0.05) ringPlatform(k, y, rIn, w, cuts[i], cuts[i + 1])\n}\n\nfunction build(k: Kit, p: P) {\n  const d = dims(p)\n  const { R, yR1, Wc, Dc, hb, yC0, yC1, Ds, yS0, yS1 } = d\n  const casing = paint('#8d9498', { metalness: 0.45, roughness: 0.55, clearcoat: 0, weather: 1.2 }) // heat-resistant aluminium-grey\n  const steel = 'structSteel' as const\n  const at = (r: number, a: number, y: number): V3 => [Math.cos(a) * r, y, -Math.sin(a) * r]\n\n  // legs, floor beams, bracing\n  const nl = p.diameter > 5 ? 10 : 8\n  for (let i = 0; i < nl; i++) {\n    const a = ((i + 0.5) / nl) * Math.PI * 2\n    const c = at(R - 0.12, a, 0)\n    member(k, [c[0], 0.03, c[2]], [c[0], Y_FLOOR - 0.3, c[2]], 'HEB 260', steel, [Math.cos(a), 0, -Math.sin(a)])\n    basePlate(k, c[0], c[2], 0.5, steel)\n    member(k, at(R - 0.12, a, Y_FLOOR - 0.15), at(0.4, a, Y_FLOOR - 0.15), 'HEB 300', steel, [0, 1, 0])\n    if (i % 2 === 0) {\n      const b = ((i + 1.5) / nl) * Math.PI * 2\n      k.rod(steel, at(R - 0.12, a, 0.3), at(R - 0.12, b, Y_FLOOR - 0.4), 0.04, 8)\n      k.rod(steel, at(R - 0.12, b, 0.3), at(R - 0.12, a, Y_FLOOR - 0.4), 0.04, 8)\n    }\n    // buckstays up the radiant casing\n    member(k, at(R + 0.08, a, Y_FLOOR), at(R + 0.08, a, yR1), 'UPN 200', casing, [Math.cos(a), 0, -Math.sin(a)])\n  }\n  k.cyl(steel, 0.45, 0.3, [0, Y_FLOOR - 0.15, 0], 'y', 24)\n  // radiant section: floor, casing with stiffener rings, roof\n  k.cyl(casing, R + 0.02, 0.3, [0, Y_FLOOR + 0.15, 0], 'y', 64)\n  k.lathe(casing, [[R, Y_FLOOR + 0.3], [R, yR1]], [0, 0, 0], 'y', 64)\n  for (let y = Y_FLOOR + 1.5; y < yR1 - 0.3; y += 1.5) k.cyl(casing, R + 0.03, 0.12, [0, y, 0], 'y', 64)\n  k.cyl(casing, R + 0.06, 0.2, [0, yR1, 0], 'y', 64)\n  // breeching, convection box, header boxes with bolted covers\n  frustum(k, casing, yR1 + 0.1, Wc / 2 - 0.1, Dc / 2 - 0.1, yC0, Wc / 2, Dc / 2)\n  k.box(casing, [Wc, d.Hc, Dc], [0, (yC0 + yC1) / 2, 0])\n  for (let x = -Wc / 2 + 0.6; x < Wc / 2 - 0.2; x += 0.6) for (const s of [-1, 1]) k.box(steel, [0.08, d.Hc, 0.08], [x, (yC0 + yC1) / 2, s * (Dc / 2 + 0.04)])\n  for (const s of [-1, 1]) {\n    const xh = s * (Wc / 2 + hb / 2)\n    k.box(casing, [hb, d.Hc - 0.1, Dc - 0.1], [xh, (yC0 + yC1) / 2, 0])\n    const panels = Math.max(2, Math.round(d.Hc / 1.2))\n    for (let i = 0; i < panels; i++) {\n      const ph = (d.Hc - 0.3) / panels\n      const y = yC0 + 0.15 + (i + 0.5) * ph\n      k.box(casing, [0.02, ph - 0.05, Dc - 0.25], [s * (Wc / 2 + hb + 0.01), y, 0])\n      for (const dz of [-1, 1]) for (const dy of [-1, 0, 1]) k.hex('nut', 0.04, 0.025, [s * (Wc / 2 + hb + 0.025), y + (dy * (ph - 0.15)) / 2, (dz * (Dc - 0.35)) / 2], 'x')\n      k.box('handwheel', [0.03, 0.02, 0.25], [s * (Wc / 2 + hb + 0.05), y, 0])\n    }\n  }\n  // transition hood, damper, stack (open, refractory-lined bore)\n  frustum(k, casing, yC1, Wc / 2, Dc / 2, yS0, Ds / 2 + 0.05, Ds / 2 + 0.05)\n  k.box(casing, [Ds + 0.16, 0.5, Ds + 0.16], [0, yS0 + 0.25, 0])\n  k.cyl('castIron', 0.04, Ds + 0.5, [0, yS0 + 0.25, 0], 'z', 10)\n  k.box('actuatorBlue', [0.3, 0.3, 0.2], [0, yS0 + 0.25, Ds / 2 + 0.38])\n  k.box(steel, [0.5, 0.04, 0.04], [0.2, yS0 + 0.25, Ds / 2 + 0.26])\n  k.pipe(casing, [0, yS0 + 0.5, 0], [0, yS1, 0], Ds, 0.09, 48)\n  k.torus(casing, Ds / 2 + 0.02, 0.03, [0, yS1 - 0.02, 0], 'y', 6, 48)\n  for (let y = yS0 + 3; y < yS1 - 1; y += 3) k.cyl(casing, Ds / 2 + 0.025, 0.08, [0, y, 0], 'y', 48)\n  // sight doors and access door\n  const yP1 = Y_FLOOR + Math.min(3.5, p.radiantHeight * 0.4)\n  for (let i = 0; i < 6; i++) {\n    const a = (i / 6) * Math.PI * 2 + Math.PI / 6\n    for (const y of [yP1 + 1.1, yR1 - 1.2]) {\n      const c = at(R + 0.04, a, y)\n      const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n      k.box('castIron', [0.08, 0.22, 0.28], c, q)\n      k.box('castIron', [0.04, 0.17, 0.2], at(R + 0.1, a, y), q)\n      k.cyl('brass', 0.015, 0.06, at(R + 0.12, a, y + 0.06), 'y', 8)\n    }\n  }\n  {\n    const a = Math.PI * 1.25\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    k.box('castIron', [0.1, 0.9, 0.7], at(R + 0.05, a, yP1 + 0.6), q)\n    for (const dy of [-0.3, 0.3]) k.box(steel, [0.06, 0.08, 0.1], at(R + 0.12, a + 0.17, yP1 + 0.6 + dy), q)\n  }\n  // floor burners: windbox, air register, fuel gas ring with riser and isolation valve\n  const nb = Math.max(1, Math.round(p.burners))\n  const Rb = nb === 1 ? 0 : R * 0.5\n  const ringY = d.yFuel\n  k.torus('pipePaint', Math.max(Rb, 0.3), pipeOD(2) / 2, [0, ringY, 0], 'y', 10, 48)\n  for (let i = 0; i < nb; i++) {\n    const a = (i / nb) * Math.PI * 2\n    const c = at(Rb, a, 0)\n    k.cyl('castIron', 0.3, 0.55, [c[0], Y_FLOOR - 0.28, c[2]], 'y', 24)\n    k.cyl(casing, 0.33, 0.2, [c[0], Y_FLOOR - 0.65, c[2]], 'y', 24)\n    for (let j = 0; j < 8; j++) {\n      const b = (j / 8) * Math.PI * 2\n      k.box('castIron', [0.02, 0.18, 0.12], [c[0] + Math.cos(b) * 0.34, Y_FLOOR - 0.65, c[2] + Math.sin(b) * 0.34], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -b + 0.4))\n    }\n    k.cyl('castIron', 0.12, 0.25, [c[0], Y_FLOOR - 0.87, c[2]], 'y', 16)\n    const rr = Math.max(Rb, 0.3)\n    const g = at(rr, a + 0.25, ringY)\n    k.tube('pipePaint', [g, [g[0], Y_FLOOR - 0.95, g[2]], [c[0], Y_FLOOR - 0.95, c[2]]], 0.021, 8, 0.08)\n    k.handwheel('handwheel', 0.12, [g[0], ringY + 0.25, g[2]], 'y')\n    k.box('valveBody', [0.09, 0.1, 0.09], [g[0], ringY + 0.15, g[2]])\n  }\n  // fuel gas feed to the ring (utility port on -Z)\n  {\n    const rr = Math.max(Rb, 0.3)\n    const zf = -(R + 0.6)\n    const fd = flange(k, 2, 300, [0, ringY, zf], [0, 0, -1], true)\n    pipeRun(k, [[0, ringY, zf + fd], [0, ringY, -rr - pipeOD(2) * 0.45]], 2)\n    k.disc('pipeBore', pipeOD(2) / 2 - 0.004, [0, ringY, -rr - pipeOD(2) * 0.45 + 0.002], '-z', 16)\n  }\n  // process outlet (radiant) on -X and convection inlet on top of the -X header box\n  openNozzle(k, { base: [-R, d.yOut, 0], dir: [-1, 0, 0], nps: p.nps, rating: p.rating, standout: d.so, pad: pipeOD(p.nps) * 1.8, mat: casing, shellR: R })\n  openNozzle(k, { base: [-(Wc / 2 + hb / 2), yC1, 0], dir: [0, 1, 0], nps: p.nps, rating: p.rating, standout: d.so, mat: casing })\n  // crossovers: +X header box down the outside into the top of the radiant casing\n  const np = p.passes\n  for (let i = 0; i < np; i++) {\n    const z = (i - (np - 1) / 2) * Math.min(0.55, (Dc - 0.4) / Math.max(1, np - 1))\n    const xo = Wc / 2 + hb\n    const xr = Math.sqrt(R * R - z * z)\n    const yc = yC0 + 0.4\n    pipeRun(k, [[xo - 0.05, yc, z], [R + 0.35, yc, z], [R + 0.35, yR1 - 0.7, z], [xr - 0.05, yR1 - 0.7, z]], p.nps, { mat: 'pipePaint' })\n    weldBead(k, p.nps, [xo + 0.01, yc, z], [1, 0, 0])\n  }\n  // platforms: mid radiant (sight doors), roof, stack damper; ladders between them\n  const w = 0.9\n  const rIn = R + 0.15\n  const aL1 = -Math.PI / 2 // grade → P1, on +Z\n  const aL2 = Math.PI / 2 // P1 → roof, on -Z\n  ringGaps(k, yP1, rIn, w, [aL1, aL2])\n  ringGaps(k, yR1 + 0.1, rIn - 0.08, w + 0.08, [aL2], 0.55, Math.PI * 2 - 0.55)\n  const rOut = rIn + w\n  ladder(k, at(rOut + 0.15, aL1, 0), yP1, [0, 0, 1])\n  ladder(k, at(rOut + 0.15, aL2, yP1), yR1 + 0.1, [0, 0, -1])\n  const yP3 = yS0 + 1.1\n  const r3 = Ds / 2 + 0.06\n  const w3 = Math.max(0.9, Dc / 2 + 0.6 - r3)\n  ringGaps(k, yP3, r3, w3, [-Math.PI / 2])\n  ladder(k, [0, yR1 + 0.12, r3 + w3 + 0.15], yP3, [0, 0, 1])\n  // snuffing steam ring with injection stubs into the firebox (insulated steam line)\n  {\n    const ys = Y_FLOOR + 0.55\n    k.torus('insulation', R + 0.35, 0.045, [0, ys, 0], 'y', 8, 64)\n    for (let i = 0; i < 4; i++) {\n      const a = (i / 4) * Math.PI * 2 + Math.PI / 4\n      k.rod('insulation', at(R + 0.35, a, ys), at(R - 0.02, a, ys), 0.035, 10)\n      k.box('valveBody', [0.08, 0.08, 0.08], at(R + 0.2, a, ys))\n      k.handwheel('handwheel', 0.12, at(R + 0.2, a, ys + 0.12), 'y')\n    }\n    for (const a of [Math.PI * 0.15, -Math.PI * 0.15]) k.rod('insulation', at(R + 0.35, a, ys), at(R + 0.35, a, 0.05), 0.045, 10)\n  }\n  // explosion doors (hinged relief flaps) low on the radiant casing\n  for (const a of [Math.PI * 0.62, Math.PI * 1.62]) {\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    k.box('castIron', [0.06, 0.7, 0.7], at(R + 0.03, a, Y_FLOOR + 1.0), q)\n    k.box('safetyYellow', [0.02, 0.6, 0.6], at(R + 0.07, a, Y_FLOOR + 1.0), q)\n    k.rod(steel, at(R + 0.08, a - 0.12, Y_FLOOR + 1.36), at(R + 0.08, a + 0.12, Y_FLOOR + 1.36), 0.02, 8)\n  }\n  // rotary sootblowers on the convection side walls, draft gauge tappings\n  for (const sz of [-1, 1])\n    for (const [i, fx] of [-0.25, 0.25].entries()) {\n      const y = yC0 + d.Hc * (0.3 + 0.4 * i)\n      const z = sz * (Dc / 2 + 0.08)\n      k.box('castIron', [0.3, 0.3, 0.16], [fx * Wc, y, z])\n      k.cyl('pipePaint', 0.05, 0.35, [fx * Wc, y, z + sz * 0.25], sz > 0 ? 'z' : '-z', 12)\n      k.cyl('motorPaint', 0.11, 0.28, [fx * Wc, y, z + sz * 0.55], sz > 0 ? 'z' : '-z', 16)\n      k.box('motorPaint', [0.16, 0.12, 0.12], [fx * Wc, y + 0.15, z + sz * 0.55])\n    }\n  for (const [y, r] of [[yR1 - 0.4, R], [yC1 - 0.3, Dc / 2]] as const) {\n    k.pipe('stainless', [0, y, -r + 0.01], [0, y, -r - 0.14], 0.034, 0.004)\n    k.cyl('stainless', 0.03, 0.06, [0, y, -r - 0.17], 'z', 10)\n  }\n  // burner management system panel at grade, cabled to the burners\n  {\n    const xb = R + 2.2\n    const zb = -1.2\n    k.box('galvanised', [0.08, 1.3, 0.08], [xb - 0.3, 0.65, zb])\n    k.box('galvanised', [0.08, 1.3, 0.08], [xb + 0.3, 0.65, zb])\n    k.box('galvanised', [0.8, 0.02, 0.4], [xb, 0.01, zb])\n    k.rbox(paint('#c9ccc8'), [0.8, 1.0, 0.3], 0.02, [xb, 1.65, zb])\n    k.box('glass', [0.25, 0.18, 0.01], [xb - 0.15, 1.85, zb + 0.155])\n    for (let i = 0; i < 4; i++) k.cyl(i < 2 ? 'safetyGreen' : 'actuatorRed', 0.025, 0.02, [xb + 0.1 + (i % 2) * 0.12, 1.75 - Math.floor(i / 2) * 0.12, zb + 0.16], 'z', 12)\n    k.box('nameplate', [0.3, 0.06, 0.004], [xb, 2.05, zb + 0.152])\n    k.spline('cable', [[xb, 1.15, zb], [xb - 0.5, 0.15, zb + 0.3], [R * 0.6, 0.15, 0], [R * 0.5, Y_FLOOR - 1.0, 0]], 0.025, 8, 24)\n  }\n  // sampling port and tag\n  openNozzle(k, { base: [Ds / 2, yP3 + 1.0, 0], dir: [1, 0, 0], nps: 3, rating: 150, standout: 0.2, mat: casing, blind: true })\n  tagPlate(k, p.tag, at(R + 0.03, -Math.PI / 2 + 0.3, yP1 + 1.5), [Math.cos(-Math.PI / 2 + 0.3), 0, -Math.sin(-Math.PI / 2 + 0.3)], 0.4)\n}\n"
    },
    {
      "path": "thermal/_lattice.ts",
      "type": "registry:file",
      "target": "components/deck-twin/thermal/_lattice.ts",
      "content": "// Tubular lattice steel for booms, derricks and towers: tapered box trusses and three-leg towers built from solid rods.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\n\nexport interface BoxTrussOpts {\n  /** width (across, along `side`) and depth (along `up`) at a and b */\n  w0: number\n  h0: number\n  w1?: number\n  h1?: number\n  /** approximate up direction for the depth axis */\n  up?: V3\n  chordR: number\n  braceR: number\n  panel?: number\n  mat?: MatKey\n}\n\nconst vec = (v: THREE.Vector3) => v.toArray() as V3\n\n/** Tapered rectangular Warren truss from a to b. Returns a function giving chord corner i (0..3) at fraction t. */\nexport function boxTruss(k: Kit, a: V3, b: V3, o: BoxTrussOpts) {\n  const A = new THREE.Vector3(...a)\n  const B = new THREE.Vector3(...b)\n  const d = B.clone().sub(A)\n  const L = d.length()\n  d.normalize()\n  const upRef = new THREE.Vector3(...(o.up ?? [0, 1, 0]))\n  const side = new THREE.Vector3().crossVectors(d, upRef).normalize()\n  const up = new THREE.Vector3().crossVectors(side, d).normalize()\n  const mat = o.mat ?? 'structSteel'\n  const w1 = o.w1 ?? o.w0\n  const h1 = o.h1 ?? o.h0\n  const S = [[-1, -1], [1, -1], [1, 1], [-1, 1]]\n  const at = (t: number, i: number): V3 => {\n    const w = o.w0 + (w1 - o.w0) * t\n    const h = o.h0 + (h1 - o.h0) * t\n    const [ss, su] = S[((i % 4) + 4) % 4]\n    return vec(A.clone().addScaledVector(d, L * t).addScaledVector(side, (ss * w) / 2).addScaledVector(up, (su * h) / 2))\n  }\n  for (let i = 0; i < 4; i++) k.rod(mat, at(0, i), at(1, i), o.chordR, 10)\n  const n = Math.max(2, Math.round(L / (o.panel ?? Math.max(1.5, o.w0 * 0.9))))\n  for (let s = 0; s <= n; s++) {\n    const t = s / n\n    for (let i = 0; i < 4; i++) k.rod(mat, at(t, i), at(t, i + 1), o.braceR, 6)\n    if (s % 2 === 0) k.rod(mat, at(t, 0), at(t, 2), o.braceR * 0.8, 6)\n    if (s === n) break\n    const t1 = (s + 1) / n\n    for (let i = 0; i < 4; i++) {\n      if (s % 2) k.rod(mat, at(t, i), at(t1, i + 1), o.braceR, 6)\n      else k.rod(mat, at(t, i + 1), at(t1, i), o.braceR, 6)\n    }\n  }\n  return { at, dir: vec(d), side: vec(side), up: vec(up), L, n }\n}\n\n/** Three-leg tapered tower about +Y from y0 to y1; radius r0 at the bottom, r1 at the top. Returns the leg position helper. */\nexport function triTower(k: Kit, centre: V3, y0: number, y1: number, r0: number, r1: number, o: { legR: number; braceR: number; panel?: number; mat?: MatKey; phase?: number; guides?: number }) {\n  const mat = o.mat ?? 'galvanised'\n  const ph = o.phase ?? Math.PI / 2\n  const at = (y: number, i: number): V3 => {\n    const u = (y - y0) / (y1 - y0)\n    const r = r0 + (r1 - r0) * u\n    const a = ph + (i * 2 * Math.PI) / 3\n    return [centre[0] + Math.cos(a) * r, y, centre[2] + Math.sin(a) * r]\n  }\n  for (let i = 0; i < 3; i++) k.rod(mat, at(y0, i), at(y1, i), o.legR, 10)\n  const n = Math.max(2, Math.round((y1 - y0) / (o.panel ?? 3)))\n  for (let s = 0; s <= n; s++) {\n    const y = y0 + ((y1 - y0) * s) / n\n    const ya = y0 + ((y1 - y0) * (s + 1)) / n\n    for (let i = 0; i < 3; i++) {\n      k.rod(mat, at(y, i), at(y, (i + 1) % 3), o.braceR, 6)\n      if (s < n) {\n        k.rod(mat, at(y, i), at(ya, (i + 1) % 3), o.braceR * 0.85, 6)\n        k.rod(mat, at(y, (i + 1) % 3), at(ya, i), o.braceR * 0.85, 6)\n      }\n      // guide brackets to a central riser\n      if (o.guides && s > 0) {\n        const p = at(y, i)\n        const dx = p[0] - centre[0]\n        const dz = p[2] - centre[2]\n        const f = o.guides / Math.hypot(dx, dz)\n        k.rod(mat, p, [centre[0] + dx * f, y, centre[2] + dz * f], o.braceR * 0.8, 6)\n      }\n    }\n  }\n  return { at, n }\n}\n\n/** Square base plate with four anchor bolts and nuts at deck level. */\nexport function basePlate(k: Kit, x: number, z: number, size: number, mat: MatKey = 'galvanised', y = 0) {\n  const t = Math.max(0.02, size * 0.06)\n  k.box(mat, [size, t, size], [x, y + t / 2, z])\n  const o = size / 2 - Math.max(0.04, size * 0.15)\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      k.rod('nut', [x + sx * o, y, z + sz * o], [x + sx * o, y + t + 0.06, z + sz * o], 0.012, 8)\n      k.hex('nut', 0.04, 0.02, [x + sx * o, y + t + 0.012, z + sz * o])\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": "FiredHeater",
    "anchor": "base",
    "standards": [
      "API 560 / ISO 13705",
      "API 535",
      "ASME B16.5",
      "ISO 14122"
    ]
  }
}