{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "centrifugal-compressor",
  "type": "registry:component",
  "title": "Centrifugal compressor package (API 617)",
  "description": "Gas compressor train on a common skid with walkways: barrel or horizontally split casing with dry gas seals, bearing housings and probes, suction / discharge nozzles up or down (down nozzles drop through deck openings), an anti-surge valve on a reducing tee off the discharge, a temporary suction strainer with dP gauge, dry gas seal panel and API 614 overhead rundown tank. Driver is a large induction motor (CACA cooler) with a speed-increasing gearbox and API 614 lube console, or a two-shaft gas turbine in its own acoustic enclosure with filter house, exhaust stack and fuel gas skid. Casing, motor and turbine size from `mw`.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "rotating/centrifugal-compressor.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/rotating/centrifugal-compressor.tsx",
      "content": "// API 617 centrifugal compressor package: barrel or horizontally split casing, motor + gearbox or gas turbine driver, guarded couplings, API 614 lube console, dry gas seal panel, anti-surge valve.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { flange, gauge, grating, handrail, ladder, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { Rotors, guardedCoupling, type RotorSpec } from '../lib/rotors'\nimport { signMaterial } from '../lib/textures'\nimport { flange as flangeDims, pipeOD, pipeWall, type Rating } from '../lib/standards'\nimport { OIL, SKID, baseFrame, clamp, annulus, controlValve, joint, faceQ, gasDetector, gearbox, largeMotor, lineRun, louvre, lubeConsole, neckTo, roofFan, teePipe, UP } from './_compressors-kit'\nimport { TRIM, enclosure, exhaustCollector, exhaustStack, filterHouse, fuelTrain, gtCore, gtMats, inletPlenum } from './_compressors-gt'\n\nexport interface CentrifugalCompressorProps {\n  suctionNps?: number\n  dischargeNps?: number\n  rating?: Rating\n  /** driver shaft power, MW */\n  mw?: number\n  casing?: 'barrel' | 'split'\n  /** nozzle orientation; down nozzles pass through deck openings in the skid */\n  nozzles?: 'up' | 'down'\n  driver?: 'motor' | 'gas-turbine'\n  /** gas turbine driver: leave out the +Z enclosure wall */\n  cutaway?: boolean\n  /** gas turbine driver: swing the enclosure doors open */\n  doorsOpen?: boolean\n  tag?: string\n  running?: boolean\n}\n\nexport const defaults = {\n  suctionNps: 12,\n  dischargeNps: 10,\n  rating: 600 as Rating,\n  mw: 6.5,\n  casing: 'barrel' as 'barrel' | 'split',\n  nozzles: 'up' as 'up' | 'down',\n  driver: 'motor' as 'motor' | 'gas-turbine',\n  cutaway: false,\n  doorsOpen: false,\n  tag: 'K-2200',\n  running: true,\n}\n\ntype P = typeof defaults\n\nconst WALK = 0.75\nconst HS = 0.5\n\nfunction layout(p: P) {\n  const mw = clamp(p.mw, 0.5, 40)\n  const gt = p.driver === 'gas-turbine'\n  const sOD = pipeOD(p.suctionNps)\n  const dOD = pipeOD(p.dischargeNps)\n  const fS = flangeDims(p.suctionNps, p.rating)\n  const fD = flangeDims(p.dischargeNps, p.rating)\n  const Rb = Math.max(clamp(0.38 + 0.11 * Math.sqrt(mw), 0.45, 1.1), Math.max(sOD, dOD) * 0.75 + 0.15)\n  const R = gt ? clamp(0.25 + 0.11 * Math.sqrt(mw), 0.3, 0.85) : 0\n  const ys = Math.max(HS + Rb + 0.6, gt ? HS + 0.2 + 1.3 * R : 0)\n  const span = Math.max(Rb * 1.4, (fS.od + fD.od) / 2 + 0.4)\n  const Lc = Math.max(2.0, 3.4 * Rb, span + 1.3)\n  // stations measured from the +X skid end (x = 0)\n  const c1 = -2.0\n  const c0 = c1 - Lc\n  const cx = (c0 + c1) / 2\n  const xShaft = c0 - 0.84\n  const s: Record<string, number> = { c0, c1, cx, xShaft }\n  let start: number\n  let encW = 0\n  if (!gt) {\n    const Lm = clamp(0.9 + Math.pow(mw * 1000, 0.33) * 0.13, 1.6, 4.6) // typical large induction motor frame\n    s.mW = clamp(1.1 + 0.12 * mw, 1.2, 2.4)\n    s.gbL = clamp(0.8 + 0.05 * mw, 0.9, 1.8)\n    s.gb1 = xShaft - 0.65\n    s.gb0 = s.gb1 - s.gbL\n    s.m1 = s.gb0 - 0.55\n    s.mB1 = s.m1 - 0.8\n    s.mB0 = s.mB1 - (Lm - 0.7)\n    s.lL = clamp(1.6 + 0.06 * mw, 1.8, 2.8)\n    s.lx1 = s.mB0 - 0.75\n    s.lx0 = s.lx1 - s.lL\n    start = s.lx0 - 0.15\n  } else {\n    const Rs = clamp(0.1 + 0.2 * Math.sqrt(mw), 0.4, 1.2)\n    encW = clamp(2.2 + 0.1 * mw, 2.6, 4.4)\n    const hw = encW / 2 - 0.08\n    const cW = 1.3 * R\n    const Rd = Math.min(Rs, cW, hw - 0.12)\n    const cHL = Rd + 0.2\n    s.Rs = Rs\n    s.Rsil = Math.min(Rs * 1.28, encW / 2 + 0.6)\n    s.Rd = Rd\n    s.cHL = cHL\n    s.ex1 = xShaft - 0.85\n    s.xst = s.ex1 - 0.35 - cHL\n    s.xc1 = s.xst - cHL\n    s.LcG = 5.4 * R\n    s.xc0 = s.xc1 - s.LcG\n    s.plL = Math.max(0.9, 1.6 * R)\n    s.sPl1 = s.xc0\n    s.sPl0 = s.sPl1 - s.plL\n    s.xp = (s.sPl0 + s.sPl1) / 2\n    s.FL = clamp(2.0 + 0.07 * mw, 2.4, 4.0)\n    s.xh = Math.min(s.xp + (s.FL - s.plL) / 2, s.xst - s.Rsil - 1.3 - s.FL / 2)\n    s.ex0 = Math.min(s.sPl0 - 1.4, s.xh - s.FL / 2 + 0.02)\n    start = s.ex0 - 0.2\n  }\n  const SL = -start\n  const shift = SL / 2\n  for (const key of ['c0', 'c1', 'cx', 'xShaft', 'gb0', 'gb1', 'm1', 'mB0', 'mB1', 'lx0', 'lx1', 'ex0', 'ex1', 'xst', 'xc0', 'xc1', 'sPl0', 'sPl1', 'xp', 'xh'])\n    if (key in s) s[key] += shift\n  const zR = Rb + 0.75\n  const m = 0.75\n  const SW = Math.max(3.6, 2 * (zR + WALK), gt ? 0 : s.mW + 0.9 + 2 * WALK, gt ? encW + 2 * m : 0)\n  const up = p.nozzles === 'up'\n  const so = 0.45 + Math.max(sOD, dOD) * 1.5\n  const yF = up ? ys + Rb + so : 0.12\n  const xs = s.cx + span / 2\n  const xd = s.cx - span / 2\n  const yT = up ? ys + Rb + 0.12 + dOD * 0.9 : ys - Rb - 0.12 - dOD * 0.9\n  const bN = Math.min(p.dischargeNps, [2, 3, 4, 6, 8, 10].find((n) => n >= p.dischargeNps * 0.5) ?? 10)\n  return { mw, gt, Rb, R, ys, Lc, span, SL, SW, s, up, yF, xs, xd, yT, bN, zR, m, encW, hs: HS }\n}\n\nexport function ports(props: CentrifugalCompressorProps = {}): Port[] {\n  const p = { ...defaults, ...props }\n  const L = layout(p)\n  const dir: [number, number, number] = L.up ? [0, 1, 0] : [0, -1, 0]\n  const out = [\n    port('suction', 'Suction', [L.xs, L.yF, 0], dir, p.suctionNps, p.rating),\n    port('discharge', 'Discharge', [L.xd, L.yF, 0], dir, p.dischargeNps, p.rating),\n    port('recycle', 'Anti-surge recycle', [L.xd, L.yT, -L.zR], [0, 0, -1], L.bN, p.rating),\n  ]\n  if (L.gt) out.push(port('fuel-gas', 'Fuel gas', [fuelX(L), 0.9, -L.SW / 2 - 0.3], [0, 0, -1], 3, 600))\n  return out\n}\n\nconst fuelX = (L: ReturnType<typeof layout>) => L.s.ex0 + 0.9\n\nexport function CentrifugalCompressor(props: CentrifugalCompressorProps & GroupProps) {\n  const { suctionNps, dischargeNps, rating, mw, casing, nozzles, driver, cutaway, doorsOpen, tag, running, ...group } = { ...defaults, ...props }\n  const { built, rotors } = useMemo(\n    () => build({ suctionNps, dischargeNps, rating, mw, casing, nozzles, driver, cutaway, doorsOpen, tag, running }),\n    [suctionNps, dischargeNps, rating, mw, casing, nozzles, driver, cutaway, doorsOpen, tag, running],\n  )\n  useEffect(() => () => [built, ...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 CentrifugalCompressor\n\nconst COMP: MatKey = 'equipmentPaint'\n\nfunction build(p: P) {\n  const k = new Kit()\n  const L = layout(p)\n  const { Rb, ys, SL, SW, s, up, yF, xs, xd, yT, bN, zR, hs } = L\n  const { c0, c1 } = s\n  const rotors: RotorSpec[] = []\n  const holeW = Math.max(flangeDims(p.suctionNps, p.rating).od, flangeDims(p.dischargeNps, p.rating).od) + 0.25\n  baseFrame(k, SL, SW, hs, { holes: up ? [] : [{ x: xs, z: 0, w: holeW }, { x: xd, z: 0, w: holeW }] })\n\n  // walkways with handrails along both long edges (compressor section only behind a GT enclosure)\n  const wx0 = L.gt ? s.ex1 + 0.15 : -SL / 2\n  const wL = SL / 2 - wx0\n  for (const sz of [-1, 1]) {\n    const zw = sz * (SW / 2 - WALK / 2)\n    const n = Math.max(2, Math.round(wL / 2))\n    for (let i = 0; i < n; i++) grating(k, [wx0 + (wL * (i + 0.5)) / n, hs + 0.03, zw], wL / n - 0.01, WALK - 0.02)\n    const ze = sz * (SW / 2 - 0.03)\n    const xm = wx0 + wL * 0.5\n    handrail(k, [[wx0 + 0.03, hs + 0.03, ze], [xm - 0.6, hs + 0.03, ze]])\n    handrail(k, [[xm + 0.6, hs + 0.03, ze], [SL / 2 - 0.03, hs + 0.03, ze]])\n    for (let j = 0; j < 2; j++) k.box('walkwayPaint', [1.0, 0.03, 0.25], [xm, hs - (j + 1) * (hs / 3), sz * (SW / 2 + 0.13 + j * 0.25)])\n    for (const sx of [-1, 1]) k.box('structSteel', [0.012, hs, 0.55], [xm + sx * 0.5, hs / 2, sz * (SW / 2 + 0.27)])\n    handrail(k, [[SL / 2 - 0.03, hs + 0.03, sz * (SW / 2 - 0.03)], [SL / 2 - 0.03, hs + 0.03, sz * (SW / 2 - WALK)]])\n    if (!L.gt) handrail(k, [[-SL / 2 + 0.03, hs + 0.03, sz * (SW / 2 - 0.03)], [-SL / 2 + 0.03, hs + 0.03, sz * (SW / 2 - WALK)]])\n  }\n\n  // compressor casing, nozzles, anti-surge branch\n  compressorCasing(k, p, L)\n  const dir: V3 = up ? [0, 1, 0] : [0, -1, 0]\n  const yb = ys + dir[1] * Rb\n  neckTo(k, [xs, yb, 0], [xs, yF, 0], dir, p.suctionNps, p.rating, COMP)\n  // temporary cone strainer between flanges in the suction neck: joint, paddle tab, dP transmitter\n  const sOD = pipeOD(p.suctionNps)\n  const yJ = (yb + yF) / 2 + dir[1] * 0.05\n  joint(k, p.suctionNps, p.rating, [xs, yJ, 0], 'y')\n  const fS = flangeDims(p.suctionNps, p.rating)\n  k.box('actuatorRed', [0.03, 0.006, 0.12], [xs + fS.od / 2 + 0.01, yJ, 0])\n  k.plane(signMaterial('ler', 'STRAINER'), 0.1, 0.03, [xs + fS.od / 2 + 0.01, yJ + 0.004, 0], new THREE.Quaternion().setFromEuler(new THREE.Euler(-Math.PI / 2, 0, 0)))\n  for (const dy of [-0.18, 0.18]) k.rod('stainless', [xs, yJ + dy, sOD / 2 - 0.004], [xs, yJ + dy, sOD / 2 + 0.12], 0.008, 8)\n  k.rod('tubing', [xs, yJ - 0.18, sOD / 2 + 0.12], [xs, yJ + 0.18, sOD / 2 + 0.12], 0.006, 6)\n  k.rbox('actuatorBlue', [0.1, 0.12, 0.08], 0.015, [xs, yJ, sOD / 2 + 0.17])\n  // discharge neck with a reducing tee: branch to the anti-surge valve, outlet flange facing -Z\n  const dNps = p.dischargeNps\n  const bOD = pipeOD(bN)\n  const zv = Rb + 0.38\n  const vL = controlLen(bN)\n  const Lfl = flange(k, dNps, p.rating, [xd, yF, 0], dir, true)\n  teePipe(k, [xd, yb, 0], [xd, yF - dir[1] * Lfl, 0], [xd, yT, 0], [0, 0, -1], pipeOD(dNps), pipeWall(dNps, '80'), bOD, pipeWall(bN, '80'), zv - vL / 2, COMP)\n  k.disc('pipeBore', pipeOD(dNps) / 2 - pipeWall(dNps, '80') + 0.001, [xd, yb, 0], dir, 24)\n  k.at([xd, yT, -zv], new THREE.Quaternion().setFromAxisAngle(UP, Math.PI / 2), () => controlValve(k, 0, 0, 0, bN, p.rating, 'actuatorYellow', true))\n  neckTo(k, [xd, yT, -(zv + vL / 2)], [xd, yT, -zR], [0, 0, -1], bN, p.rating, 'pipePaint', false)\n  k.plane(signMaterial('ler', 'ANTI-SURGE'), 0.36, 0.09, [xd + 0.2, yT - bOD, -zv + 0.001], new THREE.Quaternion())\n  // pipe supports under down-nozzle branch / up-nozzle tee\n  if (!up) k.box(SKID, [0.12, yT - bOD / 2 - hs, 0.12], [xd, (yT - bOD / 2 + hs) / 2, -(zv + vL / 2 + 0.15)])\n\n  // dry gas seal panel at the +X end\n  const px0 = c1 + 0.14 + 0.85\n  if (SL / 2 - px0 > 0.4) {\n    const pw = Math.min(0.8, SL / 2 - px0 - 0.1)\n    const ppx = px0 + pw / 2 + 0.05\n    const ppz = -(SW / 2 - WALK) + 0.4\n    for (const sx of [-1, 1]) k.box('structSteel', [0.06, 1.6, 0.06], [ppx + sx * pw / 2, hs + 0.8, ppz])\n    k.box('stainless', [pw, 1.0, 0.02], [ppx, hs + 1.0, ppz + 0.04])\n    k.plane(signMaterial('ler', 'DRY GAS SEAL PANEL'), pw * 0.9, pw * 0.12, [ppx, hs + 1.43, ppz + 0.052])\n    for (let i = 0; i < 3; i++) {\n      const gxp = ppx - pw * 0.3 + i * pw * 0.3\n      gauge(k, [gxp, hs + 1.25, ppz + 0.07], 'z', 0.09, 'bar', 160)\n      k.box('valveBody', [0.06, 0.06, 0.08], [gxp, hs + 0.95, ppz + 0.08])\n      k.box('stainless', [0.08, 0.14, 0.06], [gxp, hs + 0.75, ppz + 0.08])\n      k.tube('tubing', [[gxp, hs + 0.68, ppz + 0.08], [gxp, hs + 0.55, ppz + 0.08], [c1 + 0.5, hs + 0.55, ppz + 0.08], [c1 + 0.5, ys - Rb * 0.3, -Rb * 0.45]], 0.008)\n    }\n  }\n\n  // lube oil supply / return headers along -Z with drops to each bearing\n  const zh = -(SW / 2 - WALK) + 0.12\n  const oilX0 = L.gt ? s.ex1 + 0.2 : s.lx1 - 0.3\n  lineRun(k, [[oilX0, hs + 0.2, zh], [c1 + 0.7, hs + 0.2, zh]], 2, OIL)\n  lineRun(k, [[oilX0, hs + 0.08, zh + 0.15], [c1 + 0.8, hs + 0.08, zh + 0.15]], 3, OIL)\n  const bearings = [c0 - 0.5, c1 + 0.6]\n  if (!L.gt) bearings.push((s.gb0 + s.gb1) / 2 - 0.3, (s.gb0 + s.gb1) / 2 + 0.3)\n  for (const xb of bearings) {\n    lineRun(k, [[xb, hs + 0.2, zh], [xb, ys + 0.35, zh], [xb, ys + 0.35, -0.25]], 1, 'stainless')\n    lineRun(k, [[xb + 0.12, ys - 0.25, -0.25], [xb + 0.12, ys - 0.25, zh + 0.15], [xb + 0.12, hs + 0.08, zh + 0.15]], 1.5, OIL)\n  }\n  // F&G post and sign\n  k.rod('structSteel', [SL / 2 - 0.25, hs, SW / 2 - WALK - 0.1], [SL / 2 - 0.25, hs + 2.4, SW / 2 - WALK - 0.1], 0.03, 10)\n  gasDetector(k, [SL / 2 - 0.25, hs + 2.3, SW / 2 - WALK - 0.02])\n\n  if (!L.gt) motorTrain(k, p, L, rotors)\n  else gtTrain(k, p, L, rotors)\n  return { built: k.build(), rotors }\n}\n\nconst controlLen = (nps: number) => Math.max(0.3, pipeOD(nps) * 3.6)\n\nfunction compressorCasing(k: Kit, p: P, L: ReturnType<typeof layout>) {\n  const { Rb, ys, Lc, s, up, xs, xd, hs } = L\n  const { c0, c1, cx } = s\n  const split = p.casing === 'split'\n  if (split) {\n    // horizontally split casing: halves, split-line flange with bolting, stiffening ribs\n    k.cyl(COMP, Rb, Lc, [cx, ys, 0], 'x', 64)\n    const fw = Rb + 0.16\n    k.box(COMP, [Lc + 0.1, 0.12, 2 * fw], [cx, ys, 0])\n    const nb = Math.max(8, Math.round(Lc / 0.16))\n    for (let i = 0; i < nb; i++)\n      for (const sz of [-1, 1]) {\n        const bx = c0 + 0.05 + (i * (Lc - 0.1)) / (nb - 1)\n        k.cyl('boltXylan', 0.018, 0.22, [bx, ys + 0.03, sz * (fw - 0.07)], 'y', 8)\n        k.hex('nut', 0.055, 0.03, [bx, ys + 0.075, sz * (fw - 0.07)])\n        k.hex('nut', 0.055, 0.03, [bx, ys - 0.075, sz * (fw - 0.07)])\n      }\n    for (let i = 1; i < 5; i++) k.cyl(COMP, Rb * 1.03, 0.05, [c0 + (i * Lc) / 5, ys, 0], 'x', 64)\n    for (const sx of [-1, 1]) k.cyl(COMP, Rb * 1.06, 0.14, [cx + sx * (Lc / 2 - 0.07), ys, 0], 'x', 64)\n    // top-half lifting lugs and jacking bolts\n    for (const sx of [-1, 1]) k.torus('nut', 0.07, 0.022, [cx + sx * Lc * 0.3, ys + Rb + 0.06, 0], 'x', 6, 16)\n  } else {\n    k.cyl(COMP, Rb, Lc, [cx, ys, 0], 'x', 64)\n    for (const sx of [-1, 1]) k.cyl(COMP, Rb * 1.04, 0.12, [cx + sx * (Lc / 2 - 0.06), ys, 0], 'x', 64)\n    for (const sx of [-1, 1]) k.torus('nut', 0.07, 0.022, [cx + sx * Lc * 0.22, ys + Rb + 0.04, 0], 'x', 6, 16)\n  }\n  // cast nozzle bosses\n  const dir = up ? 1 : -1\n  for (const [x, nps] of [[xs, p.suctionNps], [xd, p.dischargeNps]] as const) {\n    const r = (pipeOD(nps) / 2) * 1.35\n    const yb = ys + dir * Rb * 0.995\n    if (split) k.rbox(COMP, [r * 2.3, Rb * 0.6, r * 2.3], 0.05, [x, ys + dir * Rb * 0.695, 0])\n    else k.cyl(COMP, r, Rb * 0.55, [x, ys + dir * Rb * 0.72, 0], 'y', 32)\n    annulus(k, COMP, pipeOD(nps) / 2, r * 1.15, 0.05, [x, yb + dir * 0.025, 0], 'y', 32)\n  }\n  // ends: end covers (barrel shear-ring studs), seal housings, bearing housings, probes, seal vents, feet\n  for (const sx of [-1, 1]) {\n    const xe = sx > 0 ? c1 : c0\n    if (!split) {\n      k.cyl(COMP, Rb * 0.86, 0.14, [xe + sx * 0.07, ys, 0], 'x', 56)\n      k.boltCircle('boltXylan', 'nut', 28, Rb * 0.78, [xe + sx * 0.13, ys, 0], sx > 0 ? 'x' : '-x', 0.04, 0.12, [0.06])\n    } else k.cyl(COMP, Rb * 0.8, 0.14, [xe + sx * 0.07, ys, 0], 'x', 56)\n    const xb = xe + sx * 0.14\n    k.cyl(COMP, Rb * 0.42, 0.25, [xb + sx * 0.125, ys, 0], 'x', 36)\n    k.cyl(COMP, Rb * 0.48, 0.45, [xb + sx * 0.47, ys, 0], 'x', 36)\n    k.cyl(COMP, Rb * 0.52, 0.04, [xb + sx * 0.26, ys, 0], 'x', 36)\n    k.box(COMP, [0.45, 0.04, Rb * 1.08], [xb + sx * 0.47, ys, 0])\n    k.boltCircle('boltXylan', 'nut', 12, Rb * 0.5, [xb + sx * 0.25, ys, 0], sx > 0 ? 'x' : '-x', 0.018, 0.06, [0.04])\n    for (const a of [Math.PI / 4, (3 * Math.PI) / 4]) {\n      const q: V3 = [xb + sx * 0.4, ys + Math.sin(a) * Rb * 0.48, Math.cos(a) * Rb * 0.48]\n      k.rod('stainless', q, [q[0], ys + Math.sin(a) * (Rb * 0.48 + 0.08), Math.cos(a) * (Rb * 0.48 + 0.08)], 0.015, 10)\n    }\n    k.tube('tubing', [[xb + sx * 0.12, ys + Rb * 0.42, 0], [xb + sx * 0.12, ys + Rb * 0.42 + 0.25, 0], [xb + sx * 0.12, ys + Rb * 0.42 + 0.25, -Rb * 0.6]], 0.01)\n    for (const sz of [-1, 1]) {\n      k.box(COMP, [0.3, 0.1, 0.35], [xe + sx * 0.05, ys - 0.05, sz * (Rb + 0.12)])\n      k.box(SKID, [0.4, ys - 0.1 - hs, 0.32], [xe + sx * 0.05, (ys - 0.1 + hs) / 2, sz * (Rb + 0.12)])\n      k.box('stainless', [0.32, 0.006, 0.36], [xe + sx * 0.05, ys - 0.1 + 0.003, sz * (Rb + 0.12)])\n    }\n  }\n  k.dome(COMP, Rb * 0.42, [c1 + 0.14 + 0.7, ys, 0], 'x', Math.PI / 2, 0.4)\n  k.cyl('stainless', 0.08, 0.2, [s.xShaft - 0.1, ys, 0], 'x', 20)\n  // casing drain, nameplate, tag\n  if (up) {\n    k.cyl(COMP, 0.05, ys - Rb - hs - 0.05, [cx, (ys - Rb + hs + 0.05) / 2, 0], 'y', 14)\n    k.hex('nut', 0.1, 0.04, [cx, hs + 0.07, 0])\n  }\n  k.box('nameplate', [0.4, 0.25, 0.01], [cx + 0.3, ys + 0.05, Rb + 0.004])\n  tagPlate(k, p.tag, [cx - 0.25, ys + 0.1, Rb + 0.012], 'z', 0.4)\n}\n\nfunction motorTrain(k: Kit, p: P, L: ReturnType<typeof layout>, rotors: RotorSpec[]) {\n  const { ys, SL, SW, s, hs, mw } = L\n  const { xShaft, gb0, gb1, m1, mB0, mB1, mW, lx0, lx1 } = s\n  // speed-increasing gearbox: motor shaft low, pinion at compressor height\n  const ym = ys - clamp(0.25 + 0.03 * mw, 0.25, 0.55)\n  gearbox(k, { x0: gb0, x1: gb1, ySlow: ym, yFast: ys, hs, W: Math.min(1.5, SW - 2 * WALK - 0.4) })\n  rotors.push(guardedCoupling(k, gb1 + 0.05, xShaft - 0.18, ys, 0.28, hs + 0.03, 'safetyYellow', 235, 0, 0.08))\n  rotors.push(guardedCoupling(k, m1 - 0.05, gb0 - 0.05, ym, 0.3, hs + 0.03, 'safetyYellow', 125, 0, 0.11))\n  rotors.push(largeMotor(k, { mB0, mB1, xEnd: m1, ym, hs, mW, rpm: 125 }))\n  const mx = (mB0 + mB1) / 2\n  k.plane(signMaterial('ler', `${p.tag}  ${Math.round(mw * 10) / 10} MW`), 1.2, 0.3, [mx, hs + 0.25, SW / 2 + 0.005])\n  // lube oil console at the -X end\n  const lD = SW - 2 * WALK - 0.4\n  // API 614 overhead rundown tank on a stand above the console, drop line to the supply header\n  const rx = (lx0 + lx1) / 2\n  const rL = Math.min(lx1 - lx0 - 0.3, 1.6)\n  const rR = 0.28\n  const yR = ys + 1.25\n  const yBeam = yR - rR - 0.05\n  for (const sx of [-1, 1]) {\n    for (const sz of [-1, 1]) k.box('structSteel', [0.08, yBeam - 0.05 - hs, 0.08], [rx + sx * (rL / 2 - 0.1), (yBeam - 0.05 + hs) / 2, sz * (lD / 2 + 0.1)])\n    k.box('structSteel', [0.1, 0.1, lD + 0.28], [rx + sx * (rL / 2 - 0.1), yBeam, 0])\n  }\n  k.cyl('stainless', rR, rL, [rx, yR, -lD / 2 + 0.1], 'x', 28)\n  for (const sx of [-1, 1]) k.dome('stainless', rR, [rx + sx * rL / 2, yR, -lD / 2 + 0.1], sx > 0 ? 'x' : '-x', Math.PI / 2, 0.4, 24)\n  k.box('glass', [0.3, 0.12, 0.012], [rx, yR, -lD / 2 + 0.1 + rR + 0.004])\n  k.rod('stainless', [rx + rL * 0.3, yR + rR - 0.02, -lD / 2 + 0.1], [rx + rL * 0.3, yR + rR + 0.25, -lD / 2 + 0.1], 0.02, 8)\n  k.dome('stainless', 0.04, [rx + rL * 0.3, yR + rR + 0.25, -lD / 2 + 0.1], '-y', Math.PI / 2, 1, 10)\n  lineRun(k, [[rx - rL * 0.3, yR - rR + 0.02, -lD / 2 + 0.1], [rx - rL * 0.3, hs + 0.2, -lD / 2 + 0.1], [rx - rL * 0.3, hs + 0.2, -(SW / 2 - WALK) + 0.12]], 2, 'stainless')\n  tagPlate(k, 'RUNDOWN TANK', [rx, yR - 0.12, -lD / 2 + 0.1 + rR * 0.95], 'z', 0.24)\n  rotors.push(...lubeConsole(k, { x: (lx0 + lx1) / 2, z: 0, L: lx1 - lx0, D: lD, hs, cooler: 'shell', front: 1 }))\n  void SL\n}\n\nfunction gtTrain(k: Kit, p: P, L: ReturnType<typeof layout>, rotors: RotorSpec[]) {\n  const { ys, SL, SW, s, hs, mw, R, m, encW } = L\n  const M = gtMats()\n  const t = 0.08\n  const z0 = -encW / 2\n  const z1 = encW / 2\n  const hw = encW / 2 - t\n  const { ex0, ex1, xst, xc0, xc1, sPl0, sPl1, xp, xh, FL, Rs, Rsil, Rd, cHL, LcG } = s\n  const H = Math.max(clamp(3.3 + 0.075 * mw, 3.6, 6.5), ys + 1.3 * R + 0.5)\n  // couplings: power turbine output through the enclosure wall to the compressor\n  k.cyl('stainless', 0.09, ex1 + 0.1 - xst, [(xst + ex1 + 0.1) / 2, ys, 0], 'x', 16)\n  rotors.push(guardedCoupling(k, ex1 + 0.1, s.xShaft - 0.18, ys, 0.3, hs + 0.03, 'safetyYellow', 230, 0, 0.09))\n  const xl = clamp((ex0 + ex1) / 2, ex0 + 1.2, ex1 - 1.2)\n  const lcL = clamp(ex1 - ex0 - 2.8, 1.8, 3.0)\n  const xGas = fuelX(L)\n  const xpen = xGas + 1.5\n  const doorsNz = xpen + 1.0 < ex1 - 0.7 ? [Math.min(ex1 - 0.8, xpen + 1.0)] : []\n  const doorsPz = xl + lcL / 2 + 1.0 < ex1 - 0.7 ? [ex1 - 0.8] : []\n  const { dH, wallH } = enclosure(k, { ex0, ex1, z0, z1, hs, H, t, doorsPz, doorsNz, endNx: Math.min(1.6, encW - 1.2), endPx: [encW / 2 - 0.6, 0.8], cutaway: p.cutaway, open: p.doorsOpen, monorail: [sPl1 + 0.15, xst - Rd - 0.2] })\n  k.at([ex0, hs, 0], faceQ('-x'), () => louvre(k, Math.min(1.0, encW * 0.3), 0.45, 0, Math.min(wallH - 0.4, dH + 0.5)))\n  ladder(k, [ex0 + 0.5, hs, z1 + 0.2], H, [0, 0, 1], { cage: true })\n  const plW = 2 * (hw - 0.04)\n  inletPlenum(k, { a: sPl0, b: sPl1, ys, R, hs, top: H - 0.1, W: plW, M })\n  const core = gtCore(k, { xc0, Lc: LcG, ys, R, hs, MW: mw, xBleed: xc1 + 0.05, M })\n  exhaustCollector(k, { xst, ys, R, cHL, cW: 1.3 * R, Rd, hs, H, rExit: core.prof(0.99), M })\n  // starter / accessory skid in front of the plenum\n  k.rbox(OIL, [Math.max(0.5, sPl0 - ex0 - 0.5), 0.7, Math.min(1.0, encW - 0.6)], 0.03, [(ex0 + sPl0) / 2, hs + 0.35, 0])\n  // fuel gas skid on the -Z apron, gas line to the manifold\n  const zA = -SW / 2 + m / 2\n  fuelTrain(k, { xGas, xLiq: null, y: 0.9, faceZ: -SW / 2 - 0.3, gasNps: 3, rating: 600, xpen, z0, zA, m, hs })\n  const zf = -core.rr - clamp((hw - core.rr) * 0.5, 0.06, 0.35)\n  lineRun(k, [[xpen, 0.9, z0], [xpen, 0.9, zf], [core.xcomb, 0.9, zf], [core.xcomb, ys, zf], [core.xcomb, ys, -core.rr]], 2, 'stainless')\n  // roof: filter house and stack, ventilation fan\n  const dHt = clamp(0.9 + 0.02 * mw, 1.0, 1.4)\n  filterHouse(k, { xp, xh, dL: sPl1 - sPl0, dW: plW, H, dHt, FL, FW: Math.min(SW - 0.65, encW + 0.9), FH: clamp(1.8 + 0.05 * mw, 2.0, 3.2), roofHalfW: encW / 2 })\n  exhaustStack(k, { xst, H, Rd, Rs, Rsil, stackH: 6, legR: Math.min(Rsil * 0.72, encW / 2 - 0.1), M })\n  if (xh + FL / 2 + 0.9 < xst - Rsil) rotors.push(roofFan(k, (xh + FL / 2 + xst - Rsil) / 2, -encW * 0.2, H, 0.32, TRIM))\n  // lube console with fin-fan cooler on the +Z apron, oil through the wall\n  const lD = m - 0.14\n  const lzc = (z1 + SW / 2) / 2\n  rotors.push(...lubeConsole(k, { x: xl, z: lzc, L: lcL, D: lD, hs, cooler: 'finfan', coolerY: Math.min(hs + 2.0, H + 0.2), front: 1 }))\n  for (const [i, xx] of [xl, xl + 0.25].entries()) lineRun(k, [[xx, hs + 0.45, lzc - lD / 2 + 0.01], [xx, hs + 0.45, z1 - 0.01]], i === 0 ? 1.5 : 2, OIL)\n  if (!p.cutaway) k.plane(signMaterial('ler', `${p.tag}  GAS TURBINE DRIVER`), 2.0, 0.5, [(ex0 + ex1) / 2 + 0.6, H - 0.6, z1 + 0.02])\n  void SL\n}\n"
    },
    {
      "path": "rotating/_compressors-kit.ts",
      "type": "registry:file",
      "target": "components/deck-twin/rotating/_compressors-kit.ts",
      "content": "// Shared machinery parts for compressor, turbine and generator packages: base frames, louvres, doors, valves, gearboxes, lube consoles, fans.\nimport * as THREE from 'three'\nimport { Kit, eul, latheCrisp, mat4, orientQuat, uvScale, type MatKey, type V3 } from '../lib/kit'\nimport { flange, gauge, pipeRun } from '../lib/parts'\nimport { fanRotor, type RotorSpec } from '../lib/rotors'\nimport { paint } from '../lib/materials'\nimport { fanGuardMaterial, meshGuardMaterial, signMaterial } from '../lib/textures'\nimport { elbow90, flange as flangeDims, pipeOD, pipeWall, type Rating } from '../lib/standards'\n\nexport const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v))\nexport const UP = new THREE.Vector3(0, 1, 0)\nexport type Face = 'x' | '-x' | 'z' | '-z'\n/** Quaternion taking a face frame (x along the face, y up, z outward) onto the outward normal. */\nexport const faceQ = (f: Face) => new THREE.Quaternion().setFromAxisAngle(UP, f === 'z' ? 0 : f === '-z' ? Math.PI : f === 'x' ? Math.PI / 2 : -Math.PI / 2)\n\nexport const SKID: MatKey = 'equipmentPaintDark'\nexport const OIL: MatKey = 'machineryGreen'\nexport const screen = () => paint('#2c3034', { metalness: 0.4, roughness: 0.6, clearcoat: 0 })\n\n/** Light solid pipe along an orthogonal polyline with swept bends (small-bore services, no flanges). */\nexport function lineRun(k: Kit, pts: V3[], nps: number, mat: MatKey = 'pipePaint') {\n  const r = pipeOD(nps) / 2\n  const radial = nps >= 3 ? 16 : 12\n  const P = pts.map((p) => new THREE.Vector3(...p))\n  let cur = P[0].clone()\n  for (let i = 1; i < P.length; i++) {\n    const a = P[i - 1]\n    const b = P[i]\n    if (i === P.length - 1) {\n      k.rod(mat, cur.toArray() as V3, b.toArray() as V3, r, radial)\n      break\n    }\n    const c = P[i + 1]\n    const rb = Math.min(elbow90(Math.max(nps, 0.5)), a.distanceTo(b) * 0.45, b.distanceTo(c) * 0.45)\n    const p1 = b.clone().add(a.clone().sub(b).normalize().multiplyScalar(rb))\n    const p2 = b.clone().add(c.clone().sub(b).normalize().multiplyScalar(rb))\n    if (cur.distanceTo(p1) > 1e-4) k.rod(mat, cur.toArray() as V3, p1.toArray() as V3, r, radial)\n    if (rb > 1e-4) k.add(mat, new THREE.TubeGeometry(new THREE.QuadraticBezierCurve3(p1, b.clone(), p2), 6, r, radial, false))\n    cur = p2\n  }\n}\n\n/** Hollow pipe run ending in a weld-neck flange at `face` (pointing `dir`), with studs. Returns the face. */\nexport function runToFlange(k: Kit, pts: V3[], face: V3, dir: V3, nps: number, rating: Rating, mat: MatKey = 'pipePaint', cap = true) {\n  const L = flange(k, nps, rating, face, dir, true)\n  const end: V3 = [face[0] - dir[0] * L, face[1] - dir[1] * L, face[2] - dir[2] * L]\n  if (pts.length) {\n    pipeRun(k, [...pts, end], nps, { mat })\n    const n = new THREE.Vector3(...pts[0]).sub(new THREE.Vector3(...(pts[1] ?? end))).normalize()\n    if (cap) k.disc('pipeBore', pipeOD(nps) / 2 - pipeWall(nps) + 0.001, pts[0], n.negate().toArray() as V3, 20)\n  }\n  return face\n}\n\n/** Flanged nozzle neck from a base point on a casing straight out to the face; the bore is closed deep inside by a dark cap. */\nexport function neckTo(k: Kit, base: V3, face: V3, dir: V3, nps: number, rating: Rating, mat: MatKey, cap = true) {\n  const L = flange(k, nps, rating, face, dir, true)\n  k.pipe(mat, base, [face[0] - dir[0] * L, face[1] - dir[1] * L, face[2] - dir[2] * L], pipeOD(nps), pipeWall(nps, '80'))\n  if (cap) k.disc('pipeBore', pipeOD(nps) / 2 - pipeWall(nps, '80') + 0.001, [base[0] + dir[0] * 0.002, base[1] + dir[1] * 0.002, base[2] + dir[2] * 0.002], dir, 24)\n}\n\n/** Ring (annulus) between radii rIn and rOut, length h centred on pos along axis: no faces cross the bore. */\nexport function annulus(k: Kit, mat: MatKey, rIn: number, rOut: number, h: number, pos: V3, axis: V3 | 'x' | 'y' | 'z', seg = 32) {\n  const a = typeof axis === 'string' ? ({ x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] } as Record<string, V3>)[axis] : axis\n  const p0: V3 = [pos[0] - (a[0] * h) / 2, pos[1] - (a[1] * h) / 2, pos[2] - (a[2] * h) / 2]\n  k.add(mat, latheCrisp([[rIn, 0], [rOut, 0], [rOut, h], [rIn, h], [rIn, 0]], seg), mat4(p0, a))\n}\n\n/** Bolted flange pair with a ring gasket (open bore) and studs, centred on pos. */\nexport function joint(k: Kit, nps: number, rating: Rating, pos: V3, axis: V3 | 'x' | 'y' | 'z') {\n  const a = typeof axis === 'string' ? ({ x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1] } as Record<string, V3>)[axis] : axis\n  const f = flangeDims(nps, rating)\n  const g = 0.0045\n  const at = (d: number): V3 => [pos[0] + a[0] * d, pos[1] + a[1] * d, pos[2] + a[2] * d]\n  flange(k, nps, rating, at(-g / 2), a)\n  flange(k, nps, rating, at(g / 2), [-a[0], -a[1], -a[2]])\n  annulus(k, 'gasket', pipeOD(nps) / 2 - pipeWall(nps) * 0.5, f.raisedFace / 2 + 0.006, g, pos, a, 28)\n  const span = 2 * (f.thickness + f.raisedFaceHeight) + g\n  const studL = span + f.boltDia * 3\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, at(-studL / 2), a, f.boltDia, studL, [f.boltDia * 0.95, studL - f.boltDia * 0.95])\n}\n\n/**\n * Hollow run pipe a -> b with a set-on branch at c towards bDir. The run wall has a stepped-circle hole the size of the branch\n * bore, and the branch walls are saddle-cut to sit on the run's outer surface, so both bores read through with no gaps.\n */\nexport function teePipe(k: Kit, a: V3, b: V3, c: V3, bDir: V3, od: number, wall: number, bod: number, bwall: number, bLen: number, mat: MatKey) {\n  const A = new THREE.Vector3(...a)\n  const d = new THREE.Vector3(...b).sub(A)\n  const L = d.length()\n  d.normalize()\n  const ro = od / 2\n  const ri = ro - wall\n  const rbo = Math.min(bod / 2, ro)\n  const rbi = Math.max(rbo - bwall, rbo * 0.5)\n  const tc = new THREE.Vector3(...c).sub(A).dot(d)\n  const q = orientQuat(d.toArray() as V3)\n  const lb = new THREE.Vector3(...bDir).applyQuaternion(q.clone().invert())\n  const phiB = Math.atan2(lb.x, lb.z)\n  const m = mat4(a, d.toArray() as V3)\n  const seg = 40\n  const ringSeg = (y0: number, y1: number, gap: number) => {\n    if (y1 - y0 < 1e-5) return\n    const ph0 = phiB + gap\n    const phL = Math.PI * 2 - 2 * gap\n    k.add(mat, latheCrisp([[ro, y0], [ro, y1]], seg, ph0, phL), m.clone())\n    k.add('pipeBore', latheCrisp([[ri, y1], [ri, y0]], seg, ph0, phL), m.clone())\n  }\n  const t0 = Math.max(0.001, tc - rbi)\n  const t1 = Math.min(L - 0.001, tc + rbi)\n  k.add(mat, latheCrisp([[ri, 0], [ro, 0], [ro, t0]], seg), m.clone())\n  k.add('pipeBore', latheCrisp([[ri, t0], [ri, 0]], seg), m.clone())\n  k.add(mat, latheCrisp([[ro, t1], [ro, L], [ri, L]], seg), m.clone())\n  k.add('pipeBore', latheCrisp([[ri, L], [ri, t1]], seg), m.clone())\n  const N = 12\n  for (let j = 0; j < N; j++) {\n    const ya = t0 + ((t1 - t0) * j) / N\n    const yb = t0 + ((t1 - t0) * (j + 1)) / N\n    const w = Math.sqrt(Math.max(0, rbi * rbi - Math.max((ya - tc) ** 2, (yb - tc) ** 2)))\n    ringSeg(ya, yb, w > 0 ? Math.asin(Math.min(0.99, w / ro)) : 0)\n  }\n  // saddle-cut branch: outer and inner walls start on the run's outer surface\n  const e1 = d.clone()\n  const e2 = new THREE.Vector3(...bDir).normalize()\n  const e3 = new THREE.Vector3().crossVectors(e1, e2).normalize()\n  const C = A.clone().addScaledVector(d, tc)\n  const wallGeo = (r: number, inward: boolean) => {\n    const pos: number[] = []\n    const nor: number[] = []\n    const idx: number[] = []\n    for (let i = 0; i <= seg; i++) {\n      const ph = (i / seg) * Math.PI * 2\n      const cs = Math.cos(ph)\n      const sn = Math.sin(ph)\n      const n = e1.clone().multiplyScalar(cs).addScaledVector(e3, sn).multiplyScalar(inward ? -1 : 1)\n      const hb = Math.sqrt(Math.max(ro * ro - (r * sn) ** 2, 0)) - 0.0015\n      for (const h of [hb, bLen]) {\n        const p = C.clone().addScaledVector(e1, r * cs).addScaledVector(e3, r * sn).addScaledVector(e2, h)\n        pos.push(p.x, p.y, p.z)\n        nor.push(n.x, n.y, n.z)\n      }\n    }\n    for (let i = 0; i < seg; i++) {\n      const p0 = i * 2\n      if (inward) idx.push(p0, p0 + 2, p0 + 1, p0 + 1, p0 + 2, p0 + 3)\n      else idx.push(p0, p0 + 1, p0 + 2, p0 + 1, p0 + 3, p0 + 2)\n    }\n    const g = new THREE.BufferGeometry()\n    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n    g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n    g.setIndex(idx)\n    return g\n  }\n  k.add(mat, wallGeo(rbo, false))\n  k.add('pipeBore', wallGeo(rbi, true))\n  annulus(k, mat, rbi, rbo, 0.002, C.clone().addScaledVector(e2, bLen - 0.001).toArray() as V3, e2.toArray() as V3, seg)\n  return C.clone().addScaledVector(e2, bLen).toArray() as V3\n}\n\n// ------------------------------------------------------------------ face-frame assemblies (x along face, y up, z outward)\n\n/** Louvre panel centred at (cx, cy). */\nexport function louvre(k: Kit, w: number, h: number, cx: number, cy: number, frame: MatKey = SKID) {\n  k.box(screen(), [w, h, 0.01], [cx, cy, 0.005])\n  const fw = 0.05\n  for (const s of [-1, 1]) {\n    k.box(frame, [w + 2 * fw, fw, 0.12], [cx, cy + s * (h / 2 + fw / 2), 0.06])\n    k.box(frame, [fw, h, 0.12], [cx + s * (w / 2 + fw / 2), cy, 0.06])\n  }\n  const n = Math.max(3, Math.floor(h / 0.09))\n  for (let i = 0; i < n; i++) k.box(frame, [w, 0.008, 0.12], [cx, cy - h / 2 + ((i + 0.5) * h) / n, 0.06], eul(0.75, 0, 0))\n}\n\n/** Rows of weather hoods over a dark bird screen (filter-house inlet face). */\nexport function hoods(k: Kit, w: number, h: number, cx: number, cy: number, mat: MatKey) {\n  const g = new THREE.PlaneGeometry(w, h)\n  uvScale(g, w / 0.12, h / 0.12)\n  k.add(meshGuardMaterial('#3a3f44'), g, mat4([cx, cy, 0.005]))\n  const rows = Math.max(2, Math.floor(h / 0.42))\n  const pitch = h / rows\n  const a = 0.55\n  const lp = 0.34\n  const dz = Math.cos(a) * lp\n  const dy = Math.sin(a) * lp\n  for (let i = 0; i < rows; i++) {\n    const yt = cy - h / 2 + (i + 1) * pitch\n    k.box(mat, [w, 0.012, lp], [cx, yt - dy / 2, dz / 2], eul(a, 0, 0))\n    k.box(mat, [w, 0.05, 0.012], [cx, yt - dy - 0.02, dz])\n    for (const s of [-1, 1]) k.box(mat, [0.012, dy, dz], [cx + s * (w / 2), yt - dy / 2, dz / 2])\n  }\n  const nm = Math.max(1, Math.round(w / 1.4))\n  for (let j = 1; j < nm; j++) k.box(mat, [0.012, h, dz], [cx - w / 2 + (j * w) / nm, cy, dz / 2])\n}\n\n/** Personnel door (single or double leaf), sill at y = 0: frame, leaves (swung open by `open` rad), handles, vision panels, signs. */\nexport function door(k: Kit, w: number, h: number, cx: number, double: boolean, leaf: MatKey, frame: MatKey = SKID, open = 0) {\n  k.box(frame, [w + 0.14, 0.07, 0.05], [cx, h + 0.035, 0.025])\n  for (const s of [-1, 1]) k.box(frame, [0.07, h, 0.05], [cx + s * (w / 2 + 0.035), h / 2, 0.025])\n  k.box(frame, [w, 0.03, 0.12], [cx, 0.015, 0.06])\n  const leaves = double ? 2 : 1\n  const lw = w / leaves\n  for (let i = 0; i < leaves; i++) {\n    const lx = cx - w / 2 + lw * (i + 0.5)\n    const latch = double ? (i === 0 ? 1 : -1) : 1\n    const hingeX = lx - latch * (lw / 2)\n    for (const yy of [0.2, h / 2, h - 0.2]) k.cyl(frame, 0.018, 0.12, [hingeX - latch * 0.01, yy, 0.06], 'y', 8)\n    const m = new THREE.Matrix4().makeTranslation(hingeX, 0, 0.06).multiply(new THREE.Matrix4().makeRotationY(-latch * open)).multiply(new THREE.Matrix4().makeTranslation(-hingeX, 0, -0.06))\n    k.with(m, () => {\n      k.box(leaf, [lw - 0.012, h - 0.012, 0.05], [lx, h / 2, 0.03])\n      for (const yy of [0.35, 0.7]) k.box(leaf, [lw - 0.18, 0.035, 0.012], [lx, h * yy, 0.06])\n      const hx = lx + latch * (lw / 2 - 0.1)\n      k.box('stainless', [0.04, 0.14, 0.02], [hx, 1.05, 0.065])\n      k.box('stainless', [0.14, 0.022, 0.024], [hx - latch * 0.06, 1.08, 0.088])\n      k.box('stainless', [0.03, 0.18, 0.02], [hx, 1.05, -0.005])\n      for (const yy of [0.2, h / 2, h - 0.2]) k.box(leaf, [0.1, 0.06, 0.012], [hingeX + latch * 0.05, yy, 0.058])\n      k.box('glass', [0.22, 0.3, 0.052], [lx, Math.min(1.6, h - 0.35), 0.03])\n      k.plane(signMaterial('noentry'), 0.36, 0.18, [lx, Math.min(1.32, h - 0.65), 0.0565])\n    })\n  }\n}\n\nexport function gasDetector(k: Kit, p: V3) {\n  k.rbox('stainless', [0.12, 0.14, 0.08], 0.01, p)\n  k.cyl('actuatorYellow', 0.035, 0.11, [p[0], p[1] - 0.12, p[2]], 'y', 14)\n  k.cyl('castIron', 0.042, 0.03, [p[0], p[1] - 0.19, p[2]], 'y', 14)\n}\n\nexport function beacon(k: Kit, p: V3) {\n  k.box(SKID, [0.12, 0.04, 0.12], p)\n  k.cyl('actuatorRed', 0.045, 0.08, [p[0], p[1] + 0.06, p[2]], 'y', 14)\n  k.dome('glass', 0.045, [p[0], p[1] + 0.1, p[2]], 'y', Math.PI / 2, 1.2, 14)\n}\n\n// ------------------------------------------------------------------ in-line valves on a pipe along X\n\nexport function ballValve(k: Kit, x: number, y: number, z: number, nps: number, rating: Rating) {\n  const od = pipeOD(nps)\n  const L = Math.max(0.24, od * 3.2)\n  k.sphere('valveBody', od * 1.0, [x, y, z], undefined, 18)\n  annulus(k, 'valveBody', od / 2, od * 0.75, L * 0.8, [x, y, z], 'x', 24)\n  joint(k, nps, rating, [x - L / 2, y, z], 'x')\n  joint(k, nps, rating, [x + L / 2, y, z], 'x')\n  const ya = y + od\n  k.cyl('valveBody', od * 0.35, 0.1, [x, ya + 0.05, z], 'y', 12)\n  k.rbox('actuatorYellow', [0.16, 0.14, 0.14], 0.02, [x, ya + 0.17, z])\n  k.cyl('actuatorYellow', 0.065, 0.32, [x + 0.2, ya + 0.17, z], 'x', 16)\n  k.rbox('stainless', [0.08, 0.07, 0.06], 0.01, [x - 0.04, ya + 0.28, z])\n  return L\n}\n\n/** Globe control valve on a pipe along X; `through` draws the body bore when no pipe runs through it. */\nexport function controlValve(k: Kit, x: number, y: number, z: number, nps: number, rating: Rating, act: MatKey = 'actuatorBlue', through = false) {\n  const od = pipeOD(nps)\n  const L = Math.max(0.3, od * 3.6)\n  k.sphere('valveBody', od * 1.2, [x, y, z], [1.25, 1, 1], 18)\n  annulus(k, 'valveBody', od / 2, od * 0.7, L * 0.8, [x, y, z], 'x', 24)\n  joint(k, nps, rating, [x - L / 2, y, z], 'x')\n  joint(k, nps, rating, [x + L / 2, y, z], 'x')\n  if (through) k.pipe('valveBody', [x - L / 2, y, z], [x + L / 2, y, z], od, pipeWall(nps))\n  const yb = y + od * 1.15\n  k.cyl('valveBody', od * 0.5, 0.12, [x, yb + 0.06, z], 'y', 14)\n  for (const s of [-1, 1]) k.rod('valveBody', [x, yb + 0.12, z + s * 0.05], [x, yb + 0.33, z + s * 0.05], 0.012, 6)\n  const r = clamp(0.1 + od, 0.15, 0.32)\n  const yd = yb + 0.33 + r * 0.25\n  k.cyl(act, r, 0.05, [x, yd, z], 'y', 24)\n  k.dome(act, r, [x, yd + 0.025, z], 'y', Math.PI / 2, 0.35, 20)\n  k.dome(act, r, [x, yd - 0.025, z], '-y', Math.PI / 2, 0.25, 20)\n  k.rbox('stainless', [0.1, 0.12, 0.07], 0.01, [x + r * 0.8, yd - 0.15, z])\n  return L\n}\n\n// ------------------------------------------------------------------ base frame\n\n/** Deep fabricated base frame: four longitudinal I-beams, cross members, drip-pan deck (top at y = hs), upstand, padeyes, drain, earth boss. */\nexport function baseFrame(k: Kit, SL: number, SW: number, hs: number, o: { upstand?: boolean; drain?: boolean; holes?: { x: number; z: number; w: number }[] } = {}) {\n  const holes = o.holes ?? []\n  const beam = (x0: number, x1: number, z: number) => {\n    const L = x1 - x0\n    const xm = (x0 + x1) / 2\n    k.box(SKID, [L, 0.03, 0.3], [xm, 0.015, z])\n    k.box(SKID, [L, 0.03, 0.3], [xm, hs - 0.027, z])\n    k.box(SKID, [L, hs - 0.072, 0.018], [xm, hs / 2 - 0.006, z])\n  }\n  for (const z of [-SW / 2 + 0.15, -SW * 0.2, SW * 0.2, SW / 2 - 0.15]) beam(-SL / 2, SL / 2, z)\n  const nCross = Math.max(3, Math.round(SL / 1.6))\n  for (let i = 0; i <= nCross; i++) {\n    const x = -SL / 2 + 0.01 + (i * (SL - 0.02)) / nCross\n    if (holes.some((h) => Math.abs(x - h.x) < h.w / 2 + 0.03)) continue\n    k.box(SKID, [0.018, hs - 0.07, SW - 0.3], [x, hs / 2 - 0.006, 0])\n  }\n  // drip-pan deck, cut around deck openings (nozzles passing through the skid) with trimmers and coamings\n  let xa = -SL / 2\n  for (const h of [...holes].sort((a, b) => a.x - b.x)) {\n    const h0 = h.x - h.w / 2\n    const h1 = h.x + h.w / 2\n    if (h0 > xa) k.box('deckPlate', [h0 - xa, 0.012, SW], [(xa + h0) / 2, hs - 0.006, 0])\n    for (const [z0, z1] of [[-SW / 2, h.z - h.w / 2], [h.z + h.w / 2, SW / 2]]) if (z1 > z0) k.box('deckPlate', [h.w, 0.012, z1 - z0], [h.x, hs - 0.006, (z0 + z1) / 2])\n    for (const s of [-1, 1]) {\n      k.box(SKID, [0.018, hs - 0.07, SW - 0.3], [h.x + s * (h.w / 2 + 0.01), hs / 2 - 0.006, 0])\n      k.box(SKID, [h.w + 0.03, 0.08, 0.012], [h.x, hs + 0.03, h.z + s * (h.w / 2 + 0.006)])\n      k.box(SKID, [0.012, 0.08, h.w], [h.x + s * (h.w / 2 + 0.006), hs + 0.03, h.z])\n    }\n    xa = Math.max(xa, h1)\n  }\n  if (SL / 2 > xa) k.box('deckPlate', [SL / 2 - xa, 0.012, SW], [(xa + SL / 2) / 2, hs - 0.006, 0])\n  if (o.upstand !== false)\n    for (const s of [-1, 1]) {\n      k.box(SKID, [SL, 0.1, 0.012], [0, hs + 0.02, s * (SW / 2 - 0.006)])\n      k.box(SKID, [0.012, 0.1, SW], [s * (SL / 2 - 0.006), hs + 0.02, 0])\n    }\n  if (o.drain !== false) lineRun(k, [[-SL / 2 + 0.35, hs - 0.02, SW / 2 - 0.35], [-SL / 2 + 0.35, 0.12, SW / 2 - 0.35], [-SL / 2 - 0.08, 0.12, SW / 2 - 0.35]], 2, SKID)\n  if (o.drain !== false) k.cyl('flangeSteel', 0.075, 0.02, [-SL / 2 - 0.08, 0.12, SW / 2 - 0.35], 'x', 20)\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      const px = sx * (SL / 2 - Math.min(1.1, SL * 0.12))\n      k.box(SKID, [0.34, 0.26, 0.04], [px, hs * 0.5, sz * (SW / 2 + 0.02)])\n      k.torus('nut', 0.07, 0.02, [px, hs * 0.5 + 0.03, sz * (SW / 2 + 0.045)], 'z', 6, 18)\n    }\n  k.box('brass', [0.12, 0.08, 0.02], [SL / 2 - 0.5, hs * 0.5, SW / 2 + 0.01])\n}\n\n// ------------------------------------------------------------------ rotating auxiliaries\n\n/** Vertical TEFC motor on a pump head, flange at y. */\nexport function verticalMotorPump(k: Kit, x: number, y: number, z: number, r = 0.13) {\n  k.cyl(OIL, r * 0.75, r * 1.2, [x, y + r * 0.6, z], 'y', 20)\n  k.cyl(OIL, r * 0.9, 0.03, [x, y + r * 1.2, z], 'y', 20)\n  const yb = y + r * 1.22\n  k.cyl('motorPaint', r, r * 2.9, [x, yb + r * 1.45, z], 'y', 24)\n  for (let f = 0; f < 12; f++) {\n    const a = (f / 12) * Math.PI * 2\n    k.box('motorPaint', [0.01, r * 2.5, 0.025], [x + Math.cos(a) * (r + 0.006), yb + r * 1.45, z + Math.sin(a) * (r + 0.006)], new THREE.Quaternion().setFromAxisAngle(UP, -a))\n  }\n  k.cyl('motorPaint', r * 1.12, r * 0.75, [x, yb + r * 3.25, z], 'y', 24, r * 0.9)\n  k.disc(fanGuardMaterial(), r * 0.9, [x, yb + r * 3.63, z], 'y', 24)\n  k.rbox('motorPaint', [r * 1.1, r * 0.9, r * 0.8], 0.015, [x, yb + r * 1.6, z + r + r * 0.35])\n}\n\nexport interface LubeOpts {\n  x: number\n  z: number\n  /** reservoir length along X and depth along Z, m */\n  L: number\n  D: number\n  hs: number\n  /** oil cooler: fin-fan on a stand above the tank, or twin vertical shell-and-tube */\n  cooler?: 'finfan' | 'shell'\n  /** underside of the fin-fan stand top */\n  coolerY?: number\n  /** side carrying the level glass and instruments (+1 = +Z) */\n  front?: 1 | -1\n}\n\n/** API 614 lube-oil console: reservoir, twin motor pumps, duplex filters with transfer valve, accumulator, heater, coolers. */\nexport function lubeConsole(k: Kit, o: LubeOpts): RotorSpec[] {\n  const { x, z, L, D, hs } = o\n  const f = o.front ?? 1\n  const tH = clamp(0.55 + L * 0.1, 0.65, 0.95)\n  const yt = hs + tH\n  const rotors: RotorSpec[] = []\n  k.rbox(OIL, [L, tH, D], 0.03, [x, hs + tH / 2, z])\n  k.box(OIL, [L - 0.1, 0.03, D - 0.1], [x, yt + 0.015, z])\n  const pr = clamp(D * 0.2, 0.09, 0.15)\n  const xf = x + L * 0.2\n  for (const [i, px] of [x - L * 0.34, x - L * 0.1].entries()) {\n    verticalMotorPump(k, px, yt + 0.03, z, pr)\n    const yy = yt + 0.25 + i * 0.07\n    lineRun(k, [[px + pr * 0.75, yt + 0.12, z], [px + pr * 0.75 + 0.08, yt + 0.12, z], [px + pr * 0.75 + 0.08, yy, z], [xf, yy, z]], 1.5, OIL)\n  }\n  for (const s of [-1, 1]) {\n    const fz = z + s * clamp(D * 0.18, 0.09, 0.2)\n    k.cyl(OIL, 0.085, 0.5, [xf, yt + 0.3, fz], 'y', 20)\n    k.dome(OIL, 0.085, [xf, yt + 0.55, fz], 'y', Math.PI / 2, 0.5, 16)\n    k.cyl(OIL, 0.1, 0.03, [xf, yt + 0.48, fz], 'y', 20)\n  }\n  k.box('valveBody', [0.12, 0.12, 0.2], [xf, yt + 0.1, z])\n  k.rod('handwheel', [xf, yt + 0.16, z], [xf, yt + 0.32, z], 0.012, 6)\n  k.box('handwheel', [0.22, 0.02, 0.02], [xf, yt + 0.32, z])\n  gauge(k, [xf + 0.14, yt + 0.42, z], 'x', 0.08, 'bar', 10)\n  // accumulator, fill breather, heater terminal box, level glass\n  k.cyl(OIL, 0.09, 0.45, [x + L * 0.38, yt + 0.25, z - f * D * 0.2], 'y', 18)\n  k.dome(OIL, 0.09, [x + L * 0.38, yt + 0.475, z - f * D * 0.2], 'y', Math.PI / 2, 1, 14)\n  k.cyl('brass', 0.04, 0.12, [x - L * 0.45, yt + 0.06, z - f * D * 0.25], 'y', 12)\n  k.rbox('stainless', [0.25, 0.25, 0.12], 0.02, [x - L / 2 - 0.06, hs + tH * 0.55, z], undefined)\n  k.box('stainless', [0.05, tH * 0.7, 0.03], [x + L * 0.4, hs + tH / 2, z + f * (D / 2 + 0.015)])\n  k.box('glass', [0.025, tH * 0.6, 0.006], [x + L * 0.4, hs + tH / 2, z + f * (D / 2 + 0.033)])\n  if ((o.cooler ?? 'finfan') === 'shell') {\n    for (const s of [-1, 1]) {\n      const cz = z + s * D * 0.28\n      const cx = x + L * 0.42\n      k.cyl(OIL, 0.1, 0.8, [cx, yt + 0.45, cz], 'y', 20)\n      for (const e of [-1, 1]) k.cyl(OIL, 0.125, 0.04, [cx, yt + 0.45 + e * 0.38, cz], 'y', 20)\n      k.dome(OIL, 0.1, [cx, yt + 0.87, cz], 'y', Math.PI / 2, 0.5, 14)\n      lineRun(k, [[cx + 0.1, yt + 0.75, cz], [cx + 0.2, yt + 0.75, cz], [cx + 0.2, yt + 0.95, cz]], 1, 'pipePaint')\n    }\n    return rotors\n  }\n  const yc0 = Math.max(o.coolerY ?? yt + 1.0, yt + 0.9)\n  const cL = L * 0.92\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) k.box('structSteel', [0.08, yc0 - hs, 0.08], [x + sx * (cL / 2 - 0.04), (yc0 + hs) / 2, z + sz * (D / 2 - 0.04)])\n  k.box('castIron', [cL - 0.24, 0.16, D], [x, yc0 + 0.08, z])\n  for (const s of [-1, 1]) k.box(OIL, [0.12, 0.24, D + 0.04], [x + s * (cL / 2 - 0.06), yc0 + 0.1, z])\n  k.box(OIL, [cL, 0.3, D], [x, yc0 + 0.35, z])\n  const nf = cL > 2.2 ? 2 : 1\n  const fr = Math.min(D / 2 - 0.04, cL / (2 * nf) - 0.06)\n  const fan = fanRotor('stainless', fr * 0.93, [0, 0, 0], 'y', 0, { blades: 4, chord: fr * 0.4, pitch: 0.32, hub: fr * 0.2, hubLen: 0.06 })\n  for (let i = 0; i < nf; i++) {\n    const fx = x - cL / 2 + ((i + 0.5) * cL) / nf\n    k.cyl(OIL, fr + 0.02, 0.14, [fx, yc0 + 0.57, z], 'y', 32, undefined, true)\n    k.disc(fanGuardMaterial(), fr, [fx, yc0 + 0.64, z], 'y', 32)\n    k.cyl('motorPaint', fr * 0.3, 0.22, [fx, yc0 + 0.76, z], 'y', 16)\n    k.cyl('motorPaint', 0.025, 0.1, [fx, yc0 + 0.6, z], 'y', 10)\n    rotors.push({ ...fan, pos: [fx, yc0 + 0.57, z], rpm: 190 })\n  }\n  lineRun(k, [[x + L * 0.42, yt, z], [x + L * 0.42, yc0 + 0.1, z], [x + cL / 2 - 0.06, yc0 + 0.1, z]], 2, OIL)\n  lineRun(k, [[x - cL / 2 + 0.06, yc0 + 0.1, z - D * 0.25], [x - L * 0.45, yc0 + 0.1, z - D * 0.25], [x - L * 0.45, yt, z - D * 0.25]], 2, OIL)\n  return rotors\n}\n\n/** Parallel-shaft gearbox between x0 and x1: slow shaft at ySlow (-X side), fast shaft at yFast (+X side), base on y = hs. */\nexport function gearbox(k: Kit, o: { x0: number; x1: number; ySlow: number; yFast: number; hs: number; W: number; mat?: MatKey }) {\n  const { x0, x1, ySlow, yFast, hs, W } = o\n  const M = o.mat ?? OIL\n  const L = x1 - x0\n  const gx = (x0 + x1) / 2\n  const yMid = (ySlow + yFast) / 2\n  const top = Math.max(ySlow, yFast) + 0.3 + W * 0.12\n  k.rbox(M, [L, yMid - hs, W], 0.04, [gx, (yMid + hs) / 2, 0])\n  k.rbox(M, [L * 0.94, top - yMid, W * 0.86], Math.min(0.12, L * 0.1), [gx, (yMid + top) / 2, 0])\n  k.box(M, [L + 0.08, 0.06, W + 0.08], [gx, yMid, 0])\n  const nb = Math.max(4, Math.round(L / 0.16))\n  for (let i = 0; i < nb; i++)\n    for (const s of [-1, 1]) {\n      const bx = x0 + 0.06 + (i * (L - 0.12)) / (nb - 1)\n      k.cyl('boltXylan', 0.012, 0.12, [bx, yMid, s * (W / 2 + 0.02)], 'y', 6)\n      k.hex('nut', 0.04, 0.02, [bx, yMid + 0.04, s * (W / 2 + 0.02)])\n    }\n  for (const s of [-1, 1]) for (const fx of [-0.3, 0, 0.3]) k.box(M, [0.03, yMid - hs - 0.06, 0.08], [gx + fx * L, (yMid + hs) / 2, s * (W / 2 + 0.04)])\n  // feet on the base\n  k.box(M, [L + 0.2, 0.05, W + 0.2], [gx, hs + 0.025, 0])\n  k.box(M, [L * 0.45, 0.04, W * 0.4], [gx, top + 0.02, 0])\n  k.around(8, Math.min(L, W) * 0.16, [gx, top + 0.045, 0], 'y', (p) => k.hex('nut', 0.03, 0.015, p))\n  k.cyl('brass', 0.03, 0.12, [gx + L * 0.3, top + 0.06, W * 0.2], 'y', 10)\n  k.cyl('brass', 0.05, 0.015, [gx, yMid - 0.25, W / 2 + 0.01], 'z', 16)\n  k.disc('glass', 0.04, [gx, yMid - 0.25, W / 2 + 0.018], 'z', 16)\n  k.cyl(M, 0.2, 0.1, [x1 + 0.05, yFast, 0], 'x', 28)\n  k.cyl(M, 0.24, 0.1, [x0 - 0.05, ySlow, 0], 'x', 28)\n  // vibration probes on the bearing caps\n  for (const xx of [x0 + 0.12, x1 - 0.12]) k.rod('stainless', [xx, top, -W * 0.25], [xx, top + 0.1, -W * 0.25], 0.012, 8)\n  k.plane(signMaterial('ler', 'GEARBOX'), 0.4, 0.1, [gx, yMid + 0.12, W * 0.43 + 0.001])\n}\n\n/** Roof extract fan in a cowl over a damper plate; returns the turning impeller. */\nexport function roofFan(k: Kit, x: number, z: number, H: number, R: number, mat: MatKey): RotorSpec {\n  k.box(mat, [R * 2 + 0.2, 0.25, R * 2 + 0.2], [x, H + 0.125, z])\n  k.cyl(mat, R, 0.7, [x, H + 0.6, z], 'y', 32, undefined, true)\n  k.disc('castIron', R, [x, H + 0.5, z], 'y', 32)\n  k.cyl(mat, R + 0.03, 0.05, [x, H + 0.95, z], 'y', 32, undefined, true)\n  k.disc(fanGuardMaterial(), R, [x, H + 0.976, z], 'y', 32)\n  k.cyl('motorPaint', R * 0.32, 0.3, [x, H + 0.8, z], 'y', 16)\n  k.rbox('stainless', [0.18, 0.2, 0.12], 0.01, [x + R + 0.14, H + 0.35, z])\n  return fanRotor('stainless', R * 0.94, [x, H + 0.62, z], 'y', 170, { blades: 6, chord: R * 0.36, pitch: 0.42, hub: R * 0.3, hubLen: 0.1 })\n}\n\n/** Large induction motor (box frame, CACA cooler on top, NDE fan hood, sleeve-bearing pedestals, terminal boxes). Frame mB0..mB1 on y = hs, shaft at ym ending at xEnd. */\nexport function largeMotor(k: Kit, o: { mB0: number; mB1: number; xEnd: number; ym: number; hs: number; mW: number; rpm: number }): RotorSpec {\n  const { mB0, mB1, xEnd, ym, hs, mW } = o\n  const MOTOR: MatKey = 'motorPaint'\n  const mx = (mB0 + mB1) / 2\n  const mTop = ym + mW * 0.5\n  k.rbox(MOTOR, [mB1 - mB0, mTop - hs - 0.12, mW], 0.06, [mx, (mTop + hs + 0.12) / 2, 0])\n  for (let i = 0; i <= 5; i++) for (const sz of [-1, 1]) k.box(MOTOR, [0.05, mTop - hs - 0.2, 0.06], [mB0 + 0.1 + (i * (mB1 - mB0 - 0.2)) / 5, (mTop + hs) / 2, sz * (mW / 2 + 0.02)])\n  k.box(SKID, [mB1 - mB0 + 0.2, 0.12, mW + 0.2], [mx, hs + 0.06, 0])\n  const cH = clamp(0.4 + mW * 0.18, 0.5, 0.85)\n  k.rbox(MOTOR, [(mB1 - mB0) * 0.92, cH, mW * 0.92], 0.05, [mx, mTop + cH / 2, 0])\n  for (const sz of [-1, 1])\n    for (let i = 0; i < 9; i++) k.box(MOTOR, [(mB1 - mB0) * 0.7, 0.02, 0.04], [mx, mTop + cH * 0.15 + i * (cH * 0.75) / 9, sz * (mW * 0.46 + 0.01)], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), sz * 0.6))\n  const yHood = mTop + cH * 0.5\n  const hr = Math.min(mW * 0.32, cH * 0.6)\n  k.cyl(MOTOR, hr, 0.4, [mB0 - 0.15, yHood, 0], 'x', 32, undefined, true)\n  k.cyl(MOTOR, hr + 0.02, 0.04, [mB0 - 0.33, yHood, 0], 'x', 32, undefined, true)\n  k.disc('rubberBlack', hr, [mB0 - 0.2, yHood, 0], '-x', 32)\n  k.disc(fanGuardMaterial(), hr, [mB0 - 0.352, yHood, 0], '-x', 32)\n  k.cyl('stainless', 0.04, 0.12, [mB0 - 0.24, yHood, 0], 'x', 12)\n  const fan = fanRotor('flangeSteel', hr * 0.9, [mB0 - 0.29, yHood, 0], 'x', o.rpm, { blades: 7, chord: hr * 0.28, pitch: 0.3, hub: hr * 0.22, hubLen: 0.05 })\n  for (const sx of [mB0 + 0.2, mB1 - 0.2]) for (const sz of [-1, 1]) k.torus('nut', 0.06, 0.018, [sx, mTop + cH + 0.04, sz * mW * 0.4], 'x', 6, 16)\n  for (const xbp of [mB1 + 0.22, mB0 - 0.22]) {\n    k.rbox(MOTOR, [0.4, ym - hs + 0.25, 0.7], 0.04, [xbp, (ym + 0.25 + hs) / 2, 0])\n    k.cyl(MOTOR, 0.26, 0.42, [xbp, ym, 0], 'x', 32)\n    k.cyl('brass', 0.045, 0.02, [xbp, ym - 0.1, 0.36], 'z', 16)\n    k.disc('glass', 0.035, [xbp, ym - 0.1, 0.371], 'z', 16)\n    lineRun(k, [[xbp, ym + 0.26, 0], [xbp, ym + 0.45, 0], [xbp, ym + 0.45, -mW / 2 - 0.1], [xbp, hs + 0.15, -mW / 2 - 0.1]], 1, OIL)\n  }\n  k.cyl('stainless', 0.11, xEnd - mB1 - 0.4, [(mB1 + 0.4 + xEnd) / 2, ym, 0], 'x', 20)\n  const tz = mW / 2\n  const ty = (hs + mTop) / 2\n  k.rbox(MOTOR, [1.1, 0.8, 0.4], 0.04, [mx + 0.3, ty + 0.05, tz + 0.2])\n  k.rbox(MOTOR, [0.5, 0.5, 0.3], 0.04, [mx - 0.8, ty, tz + 0.15])\n  k.plane(signMaterial('hv'), 0.4, 0.2, [mx + 0.3, ty + 0.25, tz + 0.401])\n  for (let i = 0; i < 3; i++) {\n    const cxg = mx + i * 0.3\n    k.hex('brass', 0.09, 0.06, [cxg, ty - 0.38, tz + 0.2], 'y')\n    k.spline('cable', [[cxg, ty - 0.42, tz + 0.2], [cxg, hs + 0.25, tz + 0.23], [cxg + 0.1, hs + 0.02, tz + 0.13]], 0.035, 8, 16)\n  }\n  k.box('nameplate', [0.5, 0.3, 0.01], [mx - 0.2, mTop - 0.25, -mW / 2 - 0.006])\n  return fan\n}\n"
    },
    {
      "path": "rotating/_compressors-gt.ts",
      "type": "registry:file",
      "target": "components/deck-twin/rotating/_compressors-gt.ts",
      "content": "// Gas turbine package parts: acoustic enclosure, inlet plenum and filter house, GT core, exhaust collector and stack, fuel train, CO₂ cabinet.\nimport * as THREE from 'three'\nimport { Kit, eul, mat4, uvScale, type MatKey, type V3 } from '../lib/kit'\nimport { flange, gauge, grating, handrail, ladder, member, ringPlatform } from '../lib/parts'\nimport { paint } from '../lib/materials'\nimport { meshGuardMaterial, signMaterial } from '../lib/textures'\nimport { pipeOD, type Rating } from '../lib/standards'\nimport { SKID, OIL, annulus, ballValve, beacon, clamp, controlValve, door, faceQ, gasDetector, hoods, lineRun, runToFlange } from './_compressors-kit'\n\nexport function gtMats() {\n  return {\n    GT: paint('#a4a9ad', { metalness: 0.72, roughness: 0.33, clearcoat: 0 }),\n    HOT: paint('#7b6c5f', { metalness: 0.6, roughness: 0.55, clearcoat: 0 }),\n    GEN: paint('#3d5d79', { metalness: 0.35, roughness: 0.45 }),\n    STACK: paint('#8c9194', { metalness: 0.55, roughness: 0.5, clearcoat: 0 }),\n    SOOT: paint('#262321', { metalness: 0.2, roughness: 0.95, clearcoat: 0 }),\n    SOOT2: paint('#4b4744', { metalness: 0.35, roughness: 0.8, clearcoat: 0 }),\n  }\n}\nexport type GtMats = ReturnType<typeof gtMats>\n\nexport const CLAD: MatKey = 'cladding'\nexport const TRIM: MatKey = 'equipmentPaint'\n\n// ------------------------------------------------------------------ enclosure\n\nexport interface EnclosureOpts {\n  ex0: number\n  ex1: number\n  z0: number\n  z1: number\n  hs: number\n  H: number\n  /** door centres along X on the +Z and -Z walls */\n  doorsPz: number[]\n  doorsNz: number[]\n  /** -X end door width (0 = none); double leaf above 1.3 m */\n  endNx: number\n  /** +X end door centre (wall coordinate = -z) and width, or null */\n  endPx: [number, number] | null\n  /** leave out the +Z wall to show the machinery */\n  cutaway?: boolean\n  /** swing the personnel doors open */\n  open?: boolean\n  /** internal maintenance monorail along X: [x0, x1] or null */\n  monorail?: [number, number] | null\n  t?: number\n}\n\n/** Acoustic enclosure: insulated wall panels cut around doors, cover battens, girts, sills, roof with seam cappings and padeyes. */\nexport function enclosure(k: Kit, o: EnclosureOpts) {\n  const { ex0, ex1, z0, z1, hs, H } = o\n  const t = o.t ?? 0.08\n  const wallH = H - hs - 0.1\n  const yw = hs + wallH / 2\n  const EL = ex1 - ex0\n  const encW = z1 - z0\n  const exm = (ex0 + ex1) / 2\n  const zm = (z0 + z1) / 2\n  const dH = Math.min(2.1, wallH - 0.4)\n  const yHead = hs + dH\n  const holes = {\n    pz: o.doorsPz.map((x) => [x, 0.9] as [number, number]),\n    nz: o.doorsNz.map((x) => [x, 0.9] as [number, number]),\n    nx: o.endNx > 0 ? ([[zm, o.endNx]] as [number, number][]) : [],\n    px: o.endPx ? ([[zm - o.endPx[0], o.endPx[1]]] as [number, number][]) : [],\n  }\n  const solid = (a0: number, a1: number, hl: [number, number][], pad = 0) => {\n    const out: [number, number][] = []\n    let a = a0\n    for (const [c, w] of [...hl].sort((p, q) => p[0] - q[0])) {\n      if (c - w / 2 - pad > a) out.push([a, c - w / 2 - pad])\n      a = Math.max(a, c + w / 2 + pad)\n    }\n    if (a1 > a) out.push([a, a1])\n    return out\n  }\n  const inHole = (v: number, hl: [number, number][], pad: number) => hl.some(([c, w]) => Math.abs(v - c) < w / 2 + pad)\n  const cut = !!o.cutaway\n  const panelZ = (z: number, hl: [number, number][]) => {\n    for (const [a, b] of solid(ex0, ex1, hl)) k.box(CLAD, [b - a, wallH, t], [(a + b) / 2, yw, z])\n    for (const [c, w] of hl) k.box(CLAD, [w, hs + wallH - yHead, t], [c, (yHead + hs + wallH) / 2, z])\n  }\n  const panelX = (x: number, hl: [number, number][]) => {\n    for (const [a, b] of solid(z0 + t, z1 - t, hl)) k.box(CLAD, [t, wallH, b - a], [x, yw, (a + b) / 2])\n    for (const [c, w] of hl) k.box(CLAD, [t, hs + wallH - yHead, w], [x, (yHead + hs + wallH) / 2, c])\n  }\n  panelZ(z0 + t / 2, holes.nz)\n  if (!cut) panelZ(z1 - t / 2, holes.pz)\n  panelX(ex0 + t / 2, holes.nx)\n  panelX(ex1 - t / 2, holes.px)\n  k.box(CLAD, [EL + 0.12, 0.1, encW + 0.12], [exm, H - 0.05, zm])\n  const zWalls = (cut ? [[z0, -1, holes.nz]] : [[z0, -1, holes.nz], [z1, 1, holes.pz]]) as [number, number, [number, number][]][]\n  const nb = Math.max(4, Math.round(EL / 1.2))\n  for (let i = 0; i <= nb; i++) {\n    const x = ex0 + (i * EL) / nb\n    for (const [z, s, hl] of zWalls) if (!inHole(x, hl, 0.12)) k.box(TRIM, [0.06, wallH, 0.018], [x, yw, z + s * 0.009])\n    k.box(TRIM, [0.05, 0.03, encW + 0.1], [x, H + 0.015, zm])\n  }\n  const ne = Math.max(2, Math.round(encW / 1.2))\n  for (let i = 1; i < ne; i++)\n    for (const [x, s, hl] of [[ex0, -1, holes.nx], [ex1, 1, holes.px]] as const) {\n      const z = z0 + (i * encW) / ne\n      if (!inHole(z, hl, 0.12)) k.box(TRIM, [0.018, wallH, 0.06], [x + s * 0.009, yw, z])\n    }\n  const yGirt = hs + wallH * 0.52\n  const girtCut = yGirt - 0.035 < yHead + 0.08\n  for (const [z, s, hl] of zWalls) {\n    for (const [a, b] of girtCut ? solid(ex0, ex1, hl, 0.1) : [[ex0, ex1] as [number, number]]) k.box(TRIM, [b - a, 0.07, 0.03], [(a + b) / 2, yGirt, z + s * 0.024])\n    for (const [a, b] of solid(ex0 - 0.02, ex1 + 0.02, hl, 0.07)) k.box(SKID, [b - a, 0.14, 0.05], [(a + b) / 2, hs + 0.07, z + s * 0.02])\n    k.box(TRIM, [EL + 0.12, 0.14, 0.04], [exm, H - 0.07, z + s * 0.08])\n  }\n  for (const [x, s, hl] of [[ex0, -1, holes.nx], [ex1, 1, holes.px]] as const) {\n    for (const [a, b] of solid(z0 - 0.02, z1 + 0.02, hl, 0.07)) k.box(SKID, [0.05, 0.14, b - a], [x + s * 0.02, hs + 0.07, (a + b) / 2])\n    k.box(TRIM, [0.04, 0.14, encW + 0.2], [x + s * 0.08, H - 0.07, zm])\n  }\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.box(TRIM, [0.1, wallH, 0.1], [sx > 0 ? ex1 - 0.04 : ex0 + 0.04, yw, sz > 0 ? z1 - 0.04 : z0 + 0.04])\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      const p: V3 = [sx > 0 ? ex1 - 0.35 : ex0 + 0.35, H + 0.12, zm + sz * (encW / 2 - 0.3)]\n      k.box('structSteel', [0.22, 0.2, 0.03], p)\n      k.torus('nut', 0.05, 0.015, [p[0], p[1] + 0.03, p[2] + 0.02], 'z', 6, 14)\n    }\n  // doors with CO₂ warning beacons and manual call points\n  const op = o.open ? 1.65 : 0\n  if (!cut)\n    for (const x of o.doorsPz) {\n      k.at([x, hs, z1], faceQ('z'), () => door(k, 0.9, dH, 0, false, TRIM, SKID, op))\n      k.rbox('actuatorRed', [0.12, 0.12, 0.05], 0.01, [x - 0.75, hs + 1.4, z1 + 0.03])\n      beacon(k, [x + 0.7, hs + dH + 0.15, z1 + 0.08])\n    }\n  for (const x of o.doorsNz) {\n    k.at([x, hs, z0], faceQ('-z'), () => door(k, 0.9, dH, 0, false, TRIM, SKID, op))\n    k.rbox('actuatorRed', [0.12, 0.12, 0.05], 0.01, [x + 0.75, hs + 1.4, z0 - 0.03])\n    beacon(k, [x - 0.7, hs + dH + 0.15, z0 - 0.08])\n  }\n  if (o.endNx > 0) k.at([ex0, hs, zm], faceQ('-x'), () => door(k, o.endNx, dH, 0, o.endNx >= 1.3, TRIM, SKID, op))\n  if (o.endPx) k.at([ex1, hs, zm], faceQ('x'), () => door(k, o.endPx![1], dH, o.endPx![0], false, TRIM, SKID, op))\n  // internal fire & gas: IR flame detectors in the upper corners, heat detectors on the ceiling\n  for (const [x, z] of [[ex0 + 0.3, z0 + t + 0.15], [ex1 - 0.3, z1 - t - 0.15], [exm, z0 + t + 0.15]] as [number, number][]) {\n    k.rbox('stainless', [0.12, 0.12, 0.1], 0.015, [x, H - 0.35, z])\n    k.disc('glass', 0.035, [x + (x < exm ? 0.061 : -0.061), H - 0.35, z], x < exm ? 'x' : '-x', 14)\n  }\n  for (let x = ex0 + 1.5; x < ex1 - 0.5; x += 3) k.cyl('plasticGrey', 0.05, 0.05, [x, H - 0.125, zm + encW * 0.25], 'y', 12)\n  // maintenance monorail with trolley and chain block under the roof\n  if (o.monorail) {\n    const [m0, m1] = o.monorail\n    const yb = H - 0.1 - 0.15\n    member(k, [m0, yb, zm], [m1, yb, zm], 'IPE 300', 'safetyYellow', [0, 1, 0])\n    for (let x = m0 + 0.2; x < m1; x += 1.5) k.box('structSteel', [0.12, 0.06, 0.2], [x, H - 0.13, zm])\n    const tx = m0 + (m1 - m0) * 0.3\n    k.rbox('safetyYellow', [0.3, 0.12, 0.26], 0.02, [tx, yb - 0.21, zm])\n    k.rbox('actuatorRed', [0.22, 0.25, 0.18], 0.03, [tx, yb - 0.4, zm])\n    k.rod('nut', [tx, yb - 0.52, zm], [tx, yb - 0.9, zm], 0.008, 6)\n    k.torus('nut', 0.04, 0.01, [tx, yb - 0.94, zm], 'z', 6, 12)\n    k.plane(signMaterial('ler', 'SWL 5 t'), 0.3, 0.08, [tx + 0.5, yb, zm + 0.005])\n  }\n  return { dH, wallH }\n}\n\n// ------------------------------------------------------------------ inlet plenum, GT core, exhaust collector\n\n/** Air-inlet plenum with bell-mouth, nose cone, struts and FOD screen, between x = a and x = b (air leaves at +X). */\nexport function inletPlenum(k: Kit, o: { a: number; b: number; ys: number; R: number; hs: number; top: number; W: number; M: GtMats }) {\n  const { a, b, ys, R, hs, top, W, M } = o\n  const L = b - a\n  const xp = (a + b) / 2\n  k.box(TRIM, [0.05, top - hs, W], [a + 0.025, (top + hs) / 2, 0])\n  for (const s of [-1, 1]) k.box(TRIM, [L, top - hs, 0.05], [xp, (top + hs) / 2, s * (W / 2 - 0.025)])\n  const sh = new THREE.Shape()\n  sh.moveTo(-W / 2, hs)\n  sh.lineTo(W / 2, hs)\n  sh.lineTo(W / 2, top)\n  sh.lineTo(-W / 2, top)\n  sh.closePath()\n  const hole = new THREE.Path()\n  hole.absarc(0, ys, 0.84 * R, 0, Math.PI * 2, true)\n  sh.holes.push(hole)\n  k.extrude(TRIM, sh, 0.05, new THREE.Matrix4().compose(new THREE.Vector3(b - 0.05, 0, 0), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2), new THREE.Vector3(1, 1, 1)), 32)\n  const bL = Math.min(L * 0.6, 0.9 * R)\n  const ri = 0.8 * R\n  const rl = Math.max(ri * 1.1, Math.min(1.3 * R, W / 2 - 0.1, top - ys - 0.1))\n  k.lathe(M.GT, [\n    [ri, bL],\n    [ri * 1.03, bL * 0.6],\n    [(ri + rl) / 2, bL * 0.25],\n    [rl, bL * 0.06],\n    [rl + 0.03, 0],\n    [rl + 0.07, 0.02],\n    [rl + 0.05, bL * 0.12],\n    [(ri + rl) / 2 + 0.05, bL * 0.3],\n    [ri * 1.03 + 0.04, bL * 0.62],\n    [ri + 0.04, bL],\n  ], [b - bL, ys, 0], 'x', 48)\n  k.dome(M.GT, 0.35 * R, [b - bL * 0.2, ys, 0], '-x', Math.PI / 2, 1.6, 28)\n  k.cyl(M.GT, 0.35 * R, bL * 0.25, [b - bL * 0.1, ys, 0], 'x', 28)\n  for (let i = 0; i < 6; i++) {\n    const t = (i / 6) * Math.PI * 2\n    k.box(M.GT, [bL * 0.3, ri * 0.66, 0.02], [b - bL * 0.15, ys + Math.sin(t) * ri * 0.66, Math.cos(t) * ri * 0.66], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -t + Math.PI / 2))\n  }\n  k.cyl('stainless', 0.08, L, [xp, ys, 0], 'x', 16)\n  const fodR = rl + 0.08\n  const fodL = Math.max(0.15, L - bL - 0.1)\n  const g = new THREE.CylinderGeometry(fodR, fodR, fodL, 40, 1, true)\n  uvScale(g, (2 * Math.PI * fodR) / 0.25, fodL / 0.25)\n  k.add(meshGuardMaterial('#b4b9bd'), g, mat4([a + 0.05 + fodL / 2, ys, 0], 'x'))\n}\n\n/** Gas turbine core from xc0 to xc0 + Lc on shaft height ys: compressor casing with VSV rings, combustor with dual-fuel manifolds, turbine, supports. */\nexport function gtCore(k: Kit, o: { xc0: number; Lc: number; ys: number; R: number; hs: number; MW: number; xBleed: number; M: GtMats }) {\n  const { xc0, Lc, ys, R, hs, MW, M } = o\n  const prof = (u: number) => {\n    if (u <= 0.4) return R * (0.8 - (0.2 * u) / 0.4)\n    if (u <= 0.43) return R * (0.6 + (0.14 * (u - 0.4)) / 0.03)\n    if (u <= 0.56) return R * 0.74\n    if (u <= 0.6) return R * (0.74 - (0.1 * (u - 0.56)) / 0.04)\n    if (u <= 0.68) return R * (0.64 + (0.04 * (u - 0.6)) / 0.08)\n    if (u <= 0.72) return R * (0.68 + (0.12 * (u - 0.68)) / 0.04)\n    if (u <= 0.97) return R * (0.8 + (0.12 * (u - 0.72)) / 0.25)\n    return R * 0.92\n  }\n  const xu = (u: number) => xc0 + u * Lc\n  const lathe = (mat: MatKey, u0: number, u1: number, steps: number[]) => {\n    const pts: [number, number][] = [[0, 0]]\n    for (const u of [u0, ...steps.filter((s) => s > u0 && s < u1), u1]) pts.push([prof(u), (u - u0) * Lc])\n    pts.push([0, (u1 - u0) * Lc])\n    k.lathe(mat, pts, [xu(u0), ys, 0], 'x', 48)\n  }\n  lathe(M.GT, 0, 0.4, [0.2])\n  lathe(M.HOT, 0.4, 1, [0.4, 0.43, 0.56, 0.6, 0.68, 0.72, 0.97])\n  // casing split flanges with bolting\n  for (const [u, mat, nb] of [[0.005, M.GT, 24], [0.2, M.GT, 20], [0.4, M.HOT, 20], [0.56, M.HOT, 20], [0.68, M.HOT, 22], [0.72, M.HOT, 26], [0.995, M.HOT, 26]] as [number, MatKey, number][]) {\n    const r = prof(Math.min(u, 0.999)) + (u < 0.01 ? 0.1 * R : 0.04)\n    k.cyl(mat, r, 0.05, [xu(u), ys, 0], 'x', 48)\n    k.boltCircle('boltXylan', 'nut', nb, r - 0.02, [xu(u) - 0.045, ys, 0], 'x', 0.016, 0.09, [0.078])\n  }\n  // variable stator vane rings, levers and actuator\n  for (const u of [0.05, 0.09, 0.13]) {\n    k.torus('stainless', prof(u) + 0.06, 0.012, [xu(u), ys, 0], 'x', 6, 48)\n    k.around(20, prof(u) + 0.02, [xu(u) - 0.025, ys, 0], 'x', (p) => k.box('stainless', [0.06, 0.012, 0.012], p))\n  }\n  k.cyl('actuatorYellow', 0.04, Lc * 0.12, [xu(0.12), ys + 0.1 * R, prof(0.1) + 0.12], 'x', 12)\n  k.rod('stainless', [xu(0.05), ys + 0.1 * R, prof(0.05) + 0.07], [xu(0.13), ys + 0.1 * R, prof(0.05) + 0.12], 0.012, 6)\n  // accessory gearbox and starter under the compressor\n  k.rbox(M.GT, [Lc * 0.15, 0.25 * R, 0.6 * R], 0.03, [xu(0.12), ys - 0.94 * R, 0])\n  k.cyl(M.GT, 0.12 * R, Lc * 0.08, [xu(0.12) - Lc * 0.1, ys - 0.9 * R, 0], 'x', 16)\n  k.rod(M.GT, [xu(0.12), ys - 0.82 * R, 0], [xu(0.12), ys - 0.72 * R, 0], 0.08 * R, 12)\n  // compressor bleed lines with bleed valves to the exhaust\n  for (const s of [-1, 1]) {\n    const a = s * 0.6\n    const rb = prof(0.3)\n    const zb = Math.sin(a) * rb\n    const yb = ys + Math.cos(a) * rb\n    const yt = ys + 0.95 * R + (s > 0 ? 0 : 0.12)\n    const bn = R > 0.7 ? 4 : 3\n    lineRun(k, [[xu(0.3), yb, zb], [xu(0.3), yt, zb], [o.xBleed, yt, zb]], bn, M.GT)\n    k.cyl(M.GT, pipeOD(bn) * 0.8, 0.06, [xu(0.3), yb + 0.02, zb], 'y', 16)\n    const xv = xu(0.62)\n    k.sphere('valveBody', pipeOD(bn) * 1.05, [xv, yt, zb], undefined, 14)\n    k.rbox('actuatorYellow', [0.12, 0.14, 0.12], 0.02, [xv, yt + pipeOD(bn) + 0.08, zb])\n  }\n  // combustor: gas and liquid fuel manifold rings, pigtails to the burners, igniters\n  const xcomb = xc0 + 0.48 * Lc\n  const rcomb = prof(0.5)\n  const rr = rcomb + 0.16\n  const xL = xcomb + Math.max(0.12, 0.05 * Lc)\n  k.torus('stainless', rr, pipeOD(2) / 2, [xcomb, ys, 0], 'x', 10, 64)\n  k.torus('stainless', rr - 0.02, pipeOD(1) / 2, [xL, ys, 0], 'x', 8, 64)\n  const nN = clamp(Math.round(8 + MW * 0.4), 10, 24)\n  for (let i = 0; i < nN; i++) {\n    const a = ((i + 0.5) / nN) * Math.PI * 2\n    const c = Math.cos(a)\n    const sn = Math.sin(a)\n    const xn = (xcomb + xL) / 2\n    k.cyl(M.HOT, 0.035, 0.08, [xn, ys + c * (rcomb + 0.03), sn * (rcomb + 0.03)], [0, c, sn], 10)\n    const head: V3 = [xn, ys + c * (rcomb + 0.075), sn * (rcomb + 0.075)]\n    const g1: V3 = [xcomb, ys + c * (rr - 0.05), sn * (rr - 0.05)]\n    const g2: V3 = [xL, ys + c * (rr - 0.06), sn * (rr - 0.06)]\n    k.rod('tubing', [xcomb, ys + c * rr, sn * rr], g1, 0.01, 5)\n    k.rod('tubing', g1, head, 0.01, 5)\n    k.rod('tubing', [xL, ys + c * (rr - 0.02), sn * (rr - 0.02)], g2, 0.008, 5)\n    k.rod('tubing', g2, head, 0.008, 5)\n  }\n  for (const s of [-1, 1]) {\n    const p: V3 = [xu(0.52), ys - 0.5 * R, s * 0.55 * R]\n    k.rod('stainless', p, [p[0], p[1] - 0.15, p[2] * 1.25], 0.02, 8)\n    k.spline('cable', [[p[0], p[1] - 0.15, p[2] * 1.25], [p[0] + 0.2, hs + 0.3, p[2] * 1.3], [p[0] + 0.5, hs + 0.05, p[2] * 1.3]], 0.012, 6, 16)\n  }\n  // EGT thermocouple harness\n  k.torus('tubing', prof(0.75) + 0.06, 0.008, [xu(0.75), ys, 0], 'x', 6, 48)\n  k.around(12, prof(0.75) + 0.03, [xu(0.75), ys, 0], 'x', (p) => k.cyl('stainless', 0.012, 0.07, p, 'x', 6))\n  // trunnion supports on a sub-base\n  for (const s of [-1, 1]) {\n    k.box(SKID, [0.14, ys - 0.62 * R - hs - 0.08, 0.14], [xu(0.06), (ys - 0.62 * R + hs + 0.08) / 2, s * 0.45 * R])\n    k.box(SKID, [0.16, ys - 0.7 * R - hs - 0.08, 0.16], [xu(0.8), (ys - 0.7 * R + hs + 0.08) / 2, s * 0.55 * R])\n  }\n  k.box(SKID, [0.25, 0.04, 1.2 * R], [xu(0.06), ys - 0.62 * R, 0])\n  k.box(SKID, [0.28, 0.04, 1.4 * R], [xu(0.8), ys - 0.7 * R, 0])\n  k.box(SKID, [Lc * 0.9, 0.08, 1.4 * R], [xu(0.45), hs + 0.04, 0])\n  return { prof, xu, xcomb, xL, rr }\n}\n\n/** Insulated exhaust collector around x = xst with the vertical duct up through the roof to y = H + 0.3. */\nexport function exhaustCollector(k: Kit, o: { xst: number; ys: number; R: number; cHL: number; cW: number; Rd: number; hs: number; H: number; rExit: number; M: GtMats }) {\n  const { xst, ys, R, cHL, cW, Rd, hs, H, M } = o\n  const colTop = ys + 1.3 * R\n  k.rbox('insulation', [2 * cHL, 2.6 * R, 2 * cW], 0.12, [xst, ys, 0])\n  for (const s of [-1, 1]) for (let i = 0; i < 3; i++) k.box('insulation', [0.03, 2.6 * R, 0.03], [xst - cHL * 0.6 + i * cHL * 0.6, ys, s * (cW + 0.012)])\n  k.cyl('insulation', Rd, H + 0.3 - colTop, [xst, (H + 0.3 + colTop) / 2, 0], 'y', 48)\n  for (let y = colTop + 0.6; y < H - 0.2; y += 0.8) k.cyl('insulation', Rd + 0.012, 0.03, [xst, y, 0], 'y', 48)\n  k.cyl(M.HOT, o.rExit, 0.5 * cHL, [xst - cHL - 0.25 * cHL + 0.5 * cHL, ys, 0], 'x', 40, o.rExit * 1.05)\n  for (const s of [-1, 1]) k.box(SKID, [0.2, ys - 1.3 * R - hs, 2 * cW * 0.8], [xst + s * cHL * 0.6, (ys - 1.3 * R + hs) / 2, 0])\n  return colTop\n}\n\n// ------------------------------------------------------------------ roof: filter house, exhaust stack\n\nexport interface FilterHouseOpts {\n  xp: number\n  /** inlet duct length (X) and width (Z) */\n  dL: number\n  dW: number\n  H: number\n  dHt: number\n  FL: number\n  FW: number\n  FH: number\n  /** filter house centre x when offset from the duct (default xp) */\n  xh?: number\n  /** access platform and ladder at the house door (needs ~1 m clear at +X) */\n  platform?: boolean\n  /** half width of the roof the house stands on (keeps posts on the roof) */\n  roofHalfW?: number\n}\n\n/** Inlet silencer duct and multi-stage filter house on legs: weather hoods on three faces, access door and dP gauges on the fourth. */\nexport function filterHouse(k: Kit, o: FilterHouseOpts) {\n  const { xp, dL, dW, H, dHt, FL, FW, FH } = o\n  const xh = o.xh ?? xp\n  const fy0 = H + dHt\n  const fy1 = fy0 + FH\n  k.box(TRIM, [dL, dHt, dW], [xp, H + dHt / 2, 0])\n  for (const s of [-1, 1]) k.box(TRIM, [dL + 0.08, 0.08, dW + 0.08], [xp, H + dHt / 2 + s * dHt * 0.3, 0])\n  k.box(TRIM, [FL, 0.08, FW], [xh, fy0 + 0.04, 0])\n  k.box(TRIM, [FL + 0.1, 0.1, FW + 0.1], [xh, fy1 - 0.05, 0])\n  for (const s of [-1, 1]) {\n    k.box(TRIM, [FL, FH, 0.04], [xh, (fy0 + fy1) / 2, s * (FW / 2 - 0.02)])\n    k.box(TRIM, [0.04, FH, FW], [xh + s * (FL / 2 - 0.02), (fy0 + fy1) / 2, 0])\n  }\n  // seams on the roof of the house\n  for (let i = 1; i < Math.round(FL / 1.2); i++) k.box(TRIM, [0.05, 0.03, FW + 0.1], [xh - FL / 2 + (i * FL) / Math.round(FL / 1.2), fy1 + 0.015, 0])\n  // inlet faces: weather hoods over a bird screen, then vane separator and coalescer / pre-filter stage, then the final filter cartridges\n  const media = paint('#ddd6c3', { metalness: 0, roughness: 0.9, clearcoat: 0 })\n  const vane: MatKey = 'galvanised'\n  const stages = (w: number, h: number) => {\n    for (let x = -w / 2 + 0.03; x < w / 2; x += 0.06) k.box(vane, [0.006, h, 0.1], [x, h / 2 + 0.17, -0.09], eul(0, 0.5, 0))\n    const n = Math.max(2, Math.floor(w / 0.62))\n    const m = Math.max(2, Math.floor(h / 0.62))\n    const cw = w / n\n    const ch = h / m\n    for (let i = 0; i <= n; i++) k.box('internalsSteel', [0.04, h, 0.05], [-w / 2 + i * cw, h / 2 + 0.17, -0.3])\n    for (let j = 0; j <= m; j++) k.box('internalsSteel', [w, 0.04, 0.05], [0, 0.17 + j * ch, -0.3])\n    for (let i = 0; i < n; i++)\n      for (let j = 0; j < m; j++) {\n        const cx = -w / 2 + (i + 0.5) * cw\n        const cy = 0.17 + (j + 0.5) * ch\n        k.box(media, [cw - 0.06, ch - 0.06, 0.04], [cx, cy, -0.3])\n        // final-stage cartridge pair behind each cell\n        for (const s of [-1, 1]) k.cyl(media, Math.min(cw, ch) * 0.17, 0.5, [cx + s * cw * 0.22, cy, -0.6], 'z', 16)\n      }\n  }\n  const faces: [V3, 'z' | '-z' | '-x', number][] = [[[xh, fy0, FW / 2], 'z', FL - 0.25], [[xh, fy0, -FW / 2], '-z', FL - 0.25], [[xh - FL / 2, fy0, 0], '-x', FW - 0.25]]\n  for (const [o, f, w] of faces)\n    k.at(o, faceQ(f), () => {\n      hoods(k, w, FH - 0.35, 0, FH / 2, TRIM)\n      stages(Math.min(w, (f === '-x' ? FW : FL) - 1.4), FH - 0.45)\n      // anti-icing hot-air manifold along the foot of the hoods, with nozzles up into the inlet\n      k.rod('stainless', [-w / 2, 0.12, 0.3], [w / 2, 0.12, 0.3], pipeOD(3) / 2, 12)\n      for (let x = -w / 2 + 0.3; x < w / 2; x += 0.5) k.rod('stainless', [x, 0.12, 0.3], [x, 0.24, 0.24], 0.012, 6)\n      for (const sx of [-1, 1]) k.box('structSteel', [0.04, 0.12, 0.3], [sx * (w / 2 - 0.05), 0.06, 0.15])\n    })\n  k.at([xh + FL / 2, fy0, 0], faceQ('x'), () => {\n    const fdH = Math.min(1.8, FH - 0.3)\n    door(k, 0.7, fdH, FW / 2 - 0.6, false, TRIM)\n    k.rbox('stainless', [0.5, 0.6, 0.12], 0.02, [-FW / 2 + 0.6, FH * 0.55, 0.06])\n    for (let i = 0; i < 3; i++) gauge(k, [-FW / 2 + 0.45 + i * 0.15, FH * 0.55 + 0.12, 0.15], 'z', 0.09, 'bar', 10)\n    k.plane(signMaterial('ler', 'AIR INLET FILTER'), Math.min(1.2, FW * 0.4), 0.22, [0, FH - 0.3, 0.005])\n  })\n  gasDetector(k, [xh + FL / 2 + 0.12, fy0 + 0.4, -FW / 2 + 0.3])\n  // access platform at the house door, ladder down to the roof\n  if (o.platform !== false) {\n    const pd = 0.9\n    const pw = 1.4\n    const rh = o.roofHalfW ?? FW / 2\n    const pz = Math.max(-(FW / 2 - 0.6), -rh + 0.1 + pw / 2)\n    const px = xh + FL / 2 + pd / 2 + 0.02\n    grating(k, [px, fy0, pz], pd, pw)\n    handrail(k, [[px - pd / 2, fy0, pz - pw / 2], [px + pd / 2, fy0, pz - pw / 2], [px + pd / 2, fy0, pz + pw / 2 - 0.6]])\n    handrail(k, [[px - pd / 2, fy0, pz + pw / 2], [px + pd / 2 - 0.55, fy0, pz + pw / 2]])\n    for (const [dx, dz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) k.box('structSteel', [0.08, fy0 - H - 0.03, 0.08], [px + dx * (pd / 2 - 0.04), (fy0 - 0.03 + H) / 2, pz + dz * (pw / 2 - 0.04)])\n    ladder(k, [px + pd / 2 - 0.3, H, pz + pw / 2 + 0.05], fy0, [0, 0, 1], { cage: false })\n  }\n  // anti-icing supply riser from the roof (compressor bleed air) to the manifolds\n  const az = Math.min(FW / 2, o.roofHalfW ?? FW / 2) - 0.25\n  k.rod('stainless', [xh + FL / 2 - 0.3, H, az], [xh + FL / 2 - 0.3, fy0, az], pipeOD(3) / 2, 12)\n  k.cyl('flangeSteel', pipeOD(3) * 0.9, 0.03, [xh + FL / 2 - 0.3, fy0 - 0.015, az], 'y', 16)\n  k.sphere('valveBody', pipeOD(3) * 0.9, [xh + FL / 2 - 0.3, H + dHt * 0.45, az], undefined, 14)\n  k.rbox('actuatorYellow', [0.16, 0.14, 0.14], 0.02, [xh + FL / 2 - 0.3, H + dHt * 0.45, az - 0.16])\n  const lz = Math.min(FW / 2 - 0.1, dW / 2 + 0.25, (o.roofHalfW ?? FW / 2) - 0.2)\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      const lx = xh + sx * (FL / 2 - 0.12)\n      k.box('structSteel', [0.12, dHt, 0.12], [lx, H + dHt / 2, sz * lz])\n      k.box('structSteel', [0.24, 0.02, 0.24], [lx, H + 0.01, sz * lz])\n      k.rod('structSteel', [lx, H + 0.1, sz * lz], [lx - sx * Math.min(0.5, FL * 0.15), fy0 - 0.02, sz * lz], 0.03, 8)\n    }\n}\n\n/** Exhaust stack on the roof at xst: fabric expansion joint, silencer, flanged stack sections, sampling platform with caged ladder, rain cap, aviation light. */\nexport function exhaustStack(k: Kit, o: { xst: number; H: number; Rd: number; Rs: number; Rsil: number; stackH: number; legR: number; M: GtMats }) {\n  const { xst, H, Rd, Rs, Rsil, M } = o\n  const stackH = Math.max(o.stackH, 3)\n  const yE0 = H + 0.25\n  const yE1 = H + 0.65\n  const cone1 = Math.max(0.3, (Rsil - Rd) * 0.8)\n  const cone2 = Math.max(0.3, (Rsil - Rs) * 0.9)\n  let silH = clamp(stackH * 0.3, 1.2, 5)\n  silH = Math.max(0.8, Math.min(silH, stackH - 0.65 - cone1 - cone2 - 1.0))\n  const ySil0 = yE1 + cone1\n  const ySil1 = ySil0 + silH\n  const yS0 = ySil1 + cone2\n  const yTop = Math.max(H + stackH, yS0 + 2.4)\n  const sootH = clamp(0.06 * stackH, 0.35, 1.0)\n  k.cyl(M.STACK, Rd, yE0 - H, [xst, (H + yE0) / 2, 0], 'y', 48)\n  for (const y of [yE0, yE1]) k.cyl(M.STACK, Rd + 0.08, 0.05, [xst, y, 0], 'y', 48)\n  k.lathe('rubberBlack', [[Rd + 0.01, yE0], [Rd + 0.07, yE0 + 0.12], [Rd + 0.1, (yE0 + yE1) / 2], [Rd + 0.07, yE1 - 0.12], [Rd + 0.01, yE1]], [xst, 0, 0], 'y', 48)\n  k.lathe(M.STACK, [[0, yE1], [Rd, yE1], [Rsil, ySil0], [Rsil, ySil1], [Rs, yS0], [Rs, yTop - sootH * 2.2]], [xst, 0, 0], 'y', 48)\n  k.lathe(M.SOOT2, [[Rs, yTop - sootH * 2.2], [Rs, yTop - sootH]], [xst, 0, 0], 'y', 48)\n  k.lathe(M.SOOT, [[Rs, yTop - sootH], [Rs, yTop], [Rs + 0.05, yTop], [Rs + 0.05, yTop + 0.04], [Rs - 0.012, yTop + 0.04], [Rs - 0.012, yTop - 2.0]], [xst, 0, 0], 'y', 48)\n  k.disc(M.SOOT, Rs, [xst, yTop - 1.9, 0], 'y', 40)\n  const flg = (y: number, r: number, n: number) => {\n    k.cyl(M.STACK, r + 0.07, 0.06, [xst, y, 0], 'y', 48)\n    k.boltCircle('boltXylan', 'nut', n, r + 0.04, [xst, y - 0.06, 0], 'y', 0.018, 0.12, [0.108])\n  }\n  flg(ySil0, Rsil, 28)\n  flg(ySil1, Rsil, 28)\n  flg(yS0, Rs, 20)\n  const nSec = Math.max(1, Math.round((yTop - yS0) / 3.2))\n  for (let i = 1; i < nSec; i++) flg(yS0 + (i * (yTop - yS0)) / nSec, Rs, 20)\n  for (let i = 1; i < 4; i++) k.cyl(M.STACK, Rsil + 0.025, 0.08, [xst, ySil0 + (i * silH) / 4, 0], 'y', 48)\n  // silencer support legs on the roof\n  const lr = o.legR\n  for (const a of [Math.PI / 4, (3 * Math.PI) / 4, (5 * Math.PI) / 4, (7 * Math.PI) / 4]) {\n    const p: V3 = [xst + Math.cos(a) * lr, H, Math.sin(a) * lr]\n    k.box('structSteel', [0.16, ySil0 - H, 0.16], [p[0], (H + ySil0) / 2, p[2]])\n    k.box('structSteel', [0.3, 0.03, 0.3], [p[0], H + 0.015, p[2]])\n  }\n  k.torus('structSteel', lr, 0.05, [xst, ySil0 - 0.08, 0], 'y', 6, 40)\n  // sampling platform, gap towards -X for the ladder\n  const pw = Math.max(0.85, Rsil + 0.55 - Rs)\n  const yP = clamp(ySil1 + 1.2, yS0 + 0.3, yTop - 1.6)\n  const gap = 0.5\n  k.at([xst, 0, 0], undefined, () => ringPlatform(k, yP, Rs + 0.02, pw, -Math.PI + gap, Math.PI - gap))\n  ladder(k, [xst - Rsil - 0.3, H, 0], yP, [-1, 0, 0], { cage: true })\n  // ladder stand-offs to the silencer\n  for (let y = H + 1.2; y < yP - 0.2; y += 1.8) k.rod('structSteel', [xst - Rsil - 0.3, y, 0], [xst - Rsil + 0.01, y, 0], 0.025, 8)\n  for (const a of [Math.PI / 2, -Math.PI / 2, 0]) {\n    const d: V3 = [Math.cos(a), 0, Math.sin(a)]\n    const ysp = Math.min(yP + 1.2, yTop - 0.4)\n    k.rod(M.STACK, [xst + d[0] * (Rs - 0.02), ysp, d[2] * (Rs - 0.02)], [xst + d[0] * (Rs + 0.16), ysp, d[2] * (Rs + 0.16)], pipeOD(4) / 2, 16)\n    flange(k, 4, 150, [xst + d[0] * (Rs + 0.24), ysp, d[2] * (Rs + 0.24)], d, true)\n    k.cyl('flangeSteel', 0.115, 0.025, [xst + d[0] * (Rs + 0.253), ysp, d[2] * (Rs + 0.253)], d, 24)\n  }\n  const capY = yTop + 0.45 * Rs\n  k.cyl(M.SOOT, Rs * 1.15, 0.35 * Rs, [xst, capY + 0.175 * Rs, 0], 'y', 40, 0.06)\n  for (let i = 0; i < 4; i++) {\n    const a = (i / 4) * Math.PI * 2 + Math.PI / 4\n    k.rod(M.SOOT, [xst + Math.cos(a) * (Rs + 0.03), yTop - 0.3, Math.sin(a) * (Rs + 0.03)], [xst + Math.cos(a) * Rs * 1.05, capY, Math.sin(a) * Rs * 1.05], 0.03, 6)\n  }\n  k.box('structSteel', [0.06, 0.06, 0.35], [xst, yTop - 0.6, Rs + 0.17])\n  k.cyl('actuatorRed', 0.06, 0.12, [xst, yTop - 0.51, Rs + 0.3], 'y', 14)\n  k.dome('glass', 0.06, [xst, yTop - 0.45, Rs + 0.3], 'y', Math.PI / 2, 1.3, 14)\n  return yTop\n}\n\n// ------------------------------------------------------------------ fuel, fire suppression\n\nexport interface FuelTrainOpts {\n  /** gas inlet flange x, liquid inlet flange x (null = gas only) */\n  xGas: number\n  xLiq: number | null\n  y: number\n  /** flange face z (on the -Z side) */\n  faceZ: number\n  gasNps: number\n  rating: Rating\n  /** wall penetration x, enclosure -Z wall, apron centre z, apron width */\n  xpen: number\n  z0: number\n  zA: number\n  m: number\n  hs: number\n}\n\n/** Fuel gas conditioning (coalescing filter, double block-and-bleed ESDVs, metering valve) and liquid fuel duplex filter, on the -Z apron. */\nexport function fuelTrain(k: Kit, o: FuelTrainOpts) {\n  const { xGas: x1, y: yG, faceZ, gasNps, rating, xpen, z0, zA, m, hs } = o\n  const yL = Math.max(yG + 1.0, 1.85)\n  k.box(SKID, [xpen + 0.25 - Math.min(x1, o.xLiq ?? x1) + 0.35, 0.1, m - 0.08], [(xpen + 0.25 + Math.min(x1, o.xLiq ?? x1) - 0.35) / 2, hs + 0.05, zA])\n  runToFlange(k, [[xpen, yG, z0 + 0.01], [xpen, yG, zA], [x1, yG, zA]], [x1, yG, faceZ], [0, 0, -1], gasNps, rating)\n  const gs = xpen - x1\n  const rf = clamp(pipeOD(gasNps) * 1.6, 0.12, m / 2 - 0.05)\n  const xf = x1 + gs * 0.2\n  const fy0 = hs + 0.18\n  const fy1 = yG + 0.75\n  k.cyl('equipmentPaint', rf, fy1 - fy0, [xf, (fy0 + fy1) / 2, zA], 'y', 28)\n  k.dome('equipmentPaint', rf, [xf, fy1, zA], 'y', Math.PI / 2, 0.5, 24)\n  k.cyl('flangeSteel', rf + 0.04, 0.05, [xf, fy1 - 0.1, zA], 'y', 28)\n  k.around(12, rf + 0.02, [xf, fy1 - 0.07, zA], 'y', (p) => k.hex('nut', 0.025, 0.015, p))\n  for (let i = 0; i < 3; i++) {\n    const a = (i / 3) * Math.PI * 2\n    k.rod(SKID, [xf + Math.cos(a) * rf * 0.8, hs + 0.1, zA + Math.sin(a) * rf * 0.8], [xf + Math.cos(a) * rf * 0.8, fy0 + 0.05, zA + Math.sin(a) * rf * 0.8], 0.025, 6)\n  }\n  gauge(k, [xf, yG + 0.35, zA - rf - 0.04], '-z', 0.09, 'bar', 40)\n  ballValve(k, x1 + gs * 0.42, yG, zA, gasNps, rating)\n  const xvn = x1 + gs * 0.56\n  annulus(k, 'pipePaint', pipeOD(1) / 2, pipeOD(1) * 0.75, 0.03, [xvn, yG + pipeOD(gasNps) / 2 + 0.015, zA], 'y', 14)\n  k.rod('pipePaint', [xvn, yG + pipeOD(gasNps) / 2 + 0.001, zA], [xvn, yG + 0.45, zA], pipeOD(1) / 2, 10)\n  k.sphere('valveBody', 0.045, [xvn, yG + 0.25, zA], undefined, 12)\n  k.rbox('actuatorYellow', [0.1, 0.1, 0.1], 0.015, [xvn, yG + 0.25, zA - 0.11])\n  k.dome('pipePaint', pipeOD(1) / 2, [xvn, yG + 0.45, zA], 'y', Math.PI / 2, 0.5, 10)\n  ballValve(k, x1 + gs * 0.68, yG, zA, gasNps, rating)\n  controlValve(k, x1 + gs * 0.86, yG, zA, gasNps, rating)\n  for (const xs of [x1 + gs * 0.3, x1 + gs * 0.78]) {\n    k.rod('structSteel', [xs, hs + 0.1, zA], [xs, yG - pipeOD(gasNps) / 2 - 0.02, zA], 0.03, 8)\n    k.box('structSteel', [0.12, 0.02, 0.14], [xs, yG - pipeOD(gasNps) / 2 - 0.01, zA])\n  }\n  k.box(TRIM, [0.3, 0.3, 0.02], [xpen, yG, z0 - 0.005])\n  if (o.xLiq !== null) {\n    const x2 = o.xLiq\n    const xr = Math.max(x2 + 0.4, x1 - 0.28)\n    runToFlange(k, [[xpen, yL, z0 + 0.01], [xpen, yL, zA], [xr, yL, zA], [xr, yG, zA], [x2, yG, zA]], [x2, yG, faceZ], [0, 0, -1], 2, 150)\n    const ls = xr - x2\n    const xdf = x2 + ls * 0.35\n    for (const s of [-1, 1]) {\n      k.cyl(OIL, 0.065, yG + 0.12 - (hs + 0.12), [xdf + s * 0.09, (yG + 0.12 + hs + 0.12) / 2, zA - 0.12], 'y', 18)\n      k.cyl(OIL, 0.075, 0.03, [xdf + s * 0.09, yG + 0.12, zA - 0.12], 'y', 18)\n      k.rod(OIL, [xdf + s * 0.09, yG, zA - 0.12], [xdf + s * 0.09, yG, zA], 0.025, 8)\n    }\n    k.rod('handwheel', [xdf, yG + 0.06, zA - 0.12], [xdf, yG + 0.24, zA - 0.12], 0.012, 6)\n    k.box('handwheel', [0.22, 0.02, 0.02], [xdf, yG + 0.24, zA - 0.12])\n    ballValve(k, x2 + ls * 0.75, yG, zA, 2, 150)\n    k.rod('structSteel', [xr, hs + 0.1, zA + 0.1], [xr, yL, zA + 0.1], 0.025, 8)\n    k.box(TRIM, [0.3, 0.3, 0.02], [xpen, yL, z0 - 0.005])\n  }\n  const gx0 = Math.min(x1, o.xLiq ?? x1) - 0.2\n  k.rod('structSteel', [gx0, hs + 0.1, zA - 0.15], [gx0, 2.4, zA - 0.15], 0.025, 8)\n  gasDetector(k, [gx0, 2.3, zA - 0.08])\n  return { yL }\n}\n\n/** CO₂ fire-suppression cylinder cabinet against the -Z wall, discharge header to y = yHdr inside. */\nexport function co2Cabinet(k: Kit, o: { x0: number; L: number; z0: number; m: number; hs: number; H: number; yHdr: number }) {\n  const { x0, L, z0, m, hs, H } = o\n  const cd = m - 0.12\n  const cz = z0 - cd / 2 - 0.02\n  const ch = Math.min(2.0, H - hs - 0.4)\n  const cx = x0 + L / 2\n  k.box('actuatorRed', [L, ch, 0.03], [cx, hs + ch / 2, z0 - 0.035])\n  for (const s of [-1, 1]) k.box('actuatorRed', [0.03, ch, cd], [cx + s * (L / 2 - 0.015), hs + ch / 2, cz])\n  k.box('actuatorRed', [L + 0.06, 0.04, cd + 0.08], [cx, hs + ch + 0.02, cz])\n  k.box(SKID, [L, 0.06, cd], [cx, hs + 0.03, cz])\n  const nbt = Math.max(2, Math.floor((L - 0.1) / 0.28))\n  const bR = Math.min(0.12, cd / 2 - 0.04)\n  const bH = ch - 0.55\n  for (let i = 0; i < nbt; i++) {\n    const bx = cx - L / 2 + 0.05 + (i + 0.5) * ((L - 0.1) / nbt)\n    k.cyl('actuatorRed', bR, bH, [bx, hs + 0.06 + bH / 2, cz], 'y', 20)\n    k.dome('actuatorRed', bR, [bx, hs + 0.06 + bH, cz], 'y', Math.PI / 2, 0.6, 16)\n    k.cyl('brass', 0.035, 0.12, [bx, hs + bH + 0.18, cz], 'y', 10)\n    k.tube('tubing', [[bx, hs + bH + 0.24, cz], [bx, hs + bH + 0.32, cz], [bx, hs + bH + 0.38, z0 - 0.08]], 0.01, 6, 0.04)\n  }\n  k.box('structSteel', [L - 0.06, 0.04, 0.05], [cx, hs + bH * 0.6, cz - bR - 0.03])\n  lineRun(k, [[cx - L / 2 + 0.08, hs + bH + 0.38, z0 - 0.08], [cx, hs + bH + 0.38, z0 - 0.08], [cx, o.yHdr, z0 - 0.08], [cx, o.yHdr, z0 + 0.02]], 1.5, 'stainless')\n  k.box('actuatorRed', [L, 0.3, 0.02], [cx, hs + ch - 0.2, z0 - cd - 0.015])\n  k.plane(signMaterial('ler', 'CO2 FIRE SUPPRESSION'), Math.min(0.9, L - 0.1), 0.25, [cx, hs + ch - 0.2, z0 - cd - 0.03], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI))\n  k.rbox('safetyGreen', [0.2, 0.26, 0.1], 0.02, [cx + L / 2 + 0.25, 1.4, z0 - 0.05])\n}\n"
    }
  ],
  "categories": [
    "rotating"
  ],
  "meta": {
    "export": "CentrifugalCompressor",
    "anchor": "base",
    "standards": [
      "API 617",
      "API 614",
      "API 692",
      "API 671",
      "API 613",
      "API 616",
      "IEC 60034",
      "ASME B16.5"
    ]
  }
}