{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "teg-regeneration",
  "type": "registry:component",
  "title": "TEG regeneration package",
  "description": "Glycol regeneration skid: fire-tube reboiler with bolted bundle flange, natural-draft burner, flame arrestor, fuel train, stripping-gas rotameter and guided exhaust stack, mounted on a portal frame over the surge drum; still column with finned reflux condenser and vent; flash drum with PSV and level control valve; sock and carbon filters; lean/rich exchanger; vertical in-line glycol pumps with check valves; top platform with caged ladder and a BMS panel. Pumps, vessel sizes and stack height are props.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "packages/teg-regeneration.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/packages/teg-regeneration.tsx",
      "content": "// TEG regeneration package: fire-tube reboiler over a surge drum, still column with reflux condenser, flash drum, sock / carbon filters, lean/rich exchanger, glycol pumps, burner and stack. API 12GDU, ASME VIII-1.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { paint } from '../lib/materials'\nimport { flange, flangedJoint, gauge, grating, handrail, ladder, member, motorSize, nozzle, pipeRun, saddle, skid, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { SECTIONS, flange as flangeDims, headDepth, pipeOD, saddle as saddleDims, shellThickness, type Rating } from '../lib/standards'\nimport { textMaterial } from '../lib/textures'\nimport { addv, blFlange, branchMouth, mateFlange, padeyeShackles, spoolJoint, earthBoss, gaugeTap, localPanel, cableTray, pipeSupport, shellLabel, smallPsv, smallValve, tempElement, transmitter, vVessel, vesselLegs, valve, valveSpan } from './_skid'\n\nexport interface TegRegenerationProps {\n  /** reboiler inside diameter, m */\n  reboilerDiameter?: number\n  /** reboiler tangent length, m */\n  reboilerLength?: number\n  /** still column inside diameter, m */\n  stillDiameter?: number\n  /** exhaust stack top above the skid base, m */\n  stackHeight?: number\n  /** glycol circulation pumps (duty / standby) */\n  pumps?: number\n  /** pump motor, kW */\n  kw?: number\n  glycolNps?: number\n  /** lean glycol discharge class (contactor pressure) */\n  leanRating?: Rating\n  tag?: string\n}\n\nexport const defaults = {\n  reboilerDiameter: 1.0,\n  reboilerLength: 4.0,\n  stillDiameter: 0.36,\n  stackHeight: 7.5,\n  pumps: 2,\n  kw: 7.5,\n  glycolNps: 2,\n  leanRating: 600 as Rating,\n  tag: 'C-5350',\n}\n\ntype P = typeof defaults\n\nconst SKID = 'HEB 200'\nconst LP: Rating = 150\nconst STACK: MatKey = paint('#8f969a', { metalness: 0.55, roughness: 0.55 })\nconst INSUL: MatKey = 'insulation'\n\nfunction layout(p: P) {\n  const D = p.reboilerDiameter\n  const TT = p.reboilerLength\n  const wall = shellThickness(3.5, D)\n  const Ro = D / 2 + wall\n  const hd = headDepth('2:1-ellipsoidal', D) + wall\n  const Ds = D * 0.8\n  const walls = shellThickness(3.5, Ds)\n  const Ros = Ds / 2 + walls\n  const hds = headDepth('2:1-ellipsoidal', Ds) + walls\n  const Rof = 0.3 + shellThickness(10, 0.6)\n  const N = Math.max(1, Math.min(3, Math.round(p.pumps)))\n  const y0 = SECTIONS[SKID].d + 0.008\n  const W = 1.75 + 2 * Ro\n  const b = -W / 2\n  const zr = b + 0.4 + Ro\n  const zp = zr + Ro + 0.75\n  const L0 = 1.5 + TT + hd + 0.55 + 2 * Rof + 0.75\n  const a = -L0 / 2\n  const xr0 = a + 1.5\n  const xr1 = xr0 + TT\n  const xf = xr1 + hd + 0.55 + Rof\n  const ycs = y0 + 0.35 + Ros\n  const ybt = ycs + Ros + 0.18\n  const ycr = ybt + 0.25 + Ro\n  const xc = xr1 - 0.55\n  const Dc = p.stillDiameter\n  const Rc = Dc / 2 + 0.006\n  const yj = ycr + Ro + 0.2\n  const ypl = ycr + Ro + 0.15\n  const Hc = 2.4\n  const Hf = 0.6\n  const yfin0 = yj + Hc\n  const yColTop = yfin0 + Hf + Dc / 4 + 0.006\n  const fv = flangeDims(3, LP)\n  const yVent = yColTop + 0.25\n  const ziRich = b + 0.22\n  const xpl = xr0 + 0.45\n  const zlh = zp + 0.38\n  return { D, TT, wall, Ro, hd, Ds, walls, Ros, hds, Rof, N, y0, W, L: L0, a, b, zr, zp, xr0, xr1, xf, ycs, ybt, ycr, xc, Dc, Rc, yj, ypl, Hc, Hf, yfin0, yColTop, yVent, fv, ziRich, xpl, zlh, yRich: y0 + 1.0, yLean: y0 + 1.25, zFuel: zr + Ro + 0.3, yFuel: y0 + 0.75, xLeanOut: xpl + 0.85 * (N - 1) + 0.55 }\n}\n\nexport function ports(props: TegRegenerationProps = {}): Port[] {\n  const p = { ...defaults, ...props }\n  const g = layout(p)\n  return [\n    port('rich-in', 'Rich glycol in', [g.L / 2, g.yRich, g.ziRich], [1, 0, 0], p.glycolNps, LP),\n    port('lean-out', 'Lean glycol out', [g.xLeanOut, g.yLean, g.W / 2], [0, 0, 1], p.glycolNps, p.leanRating),\n    port('vent', 'Still vent', [g.xc, g.yVent, g.zr], [0, 1, 0], 3, LP, 'vent'),\n    port('fuel', 'Fuel gas', [-g.L / 2, g.yFuel, g.zFuel], [-1, 0, 0], 1, LP, 'utility'),\n  ]\n}\n\nexport function TegRegeneration(props: TegRegenerationProps & GroupProps) {\n  const { reboilerDiameter, reboilerLength, stillDiameter, stackHeight, pumps, kw, glycolNps, leanRating, tag, ...group } = { ...defaults, ...props }\n  const p = { reboilerDiameter, reboilerLength, stillDiameter, stackHeight, pumps, kw, glycolNps, leanRating, tag }\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 TegRegeneration\n\nfunction hVessel(k: Kit, x0: number, x1: number, yc: number, z: number, Ro: number, hd: number, mat: MatKey, flatStart = false) {\n  const prof: [number, number][] = []\n  if (flatStart) prof.push([0, x0], [Ro, x0])\n  else for (let i = 0; i <= 12; i++) {\n    const t = (i / 12) * (Math.PI / 2)\n    prof.push([Ro * Math.sin(t), x0 - hd * Math.cos(t)])\n  }\n  for (let i = 0; i <= 12; i++) {\n    const t = (i / 12) * (Math.PI / 2)\n    prof.push([Ro * Math.cos(t), x1 + hd * Math.sin(t)])\n  }\n  k.lathe(mat, prof, [0, yc, z], 'x', 48)\n  for (const x of [x0, x1]) k.torus(mat, Ro + 0.001, 0.005, [x, yc, z], 'x', 6, 48)\n}\n\nfunction build(k: Kit, p: P) {\n  const g = layout(p)\n  const { L, W, y0, zr, zp, xr0, xr1, Ro, Ros, ycr, ycs, ybt, xc, xf, a } = g\n  const gn = p.glycolNps\n  const od = pipeOD(gn)\n  skid(k, L, W, SKID)\n  padeyeShackles(k, L, W, y0)\n  tagPlate(k, p.tag, [a + 1.0, SECTIONS[SKID].d * 0.5, W / 2 + 0.002], 'z', 0.3)\n  earthBoss(k, [-a - 0.5, SECTIONS[SKID].d * 0.5, W / 2], [0, 0, 1])\n\n  // ---------------------------------------------------------------- surge drum (deck) and reboiler (on a portal frame above)\n  hVessel(k, xr0, xr1, ycs, zr, Ros, g.hds, 'equipmentPaint')\n  const sds = saddleDims(2 * Ros, g.TT)\n  const xsd = [xr0 + sds.positionFromTangent + 0.2, xr1 - sds.positionFromTangent - 0.2]\n  k.at([0, ycs, zr], undefined, () => {\n    saddle(k, xsd[0], Ros, y0 - ycs)\n    saddle(k, xsd[1], Ros, y0 - ycs, { sliding: true })\n  })\n  k.plane(textMaterial('GLYCOL SURGE DRUM', '#f2f2ee', '#111', 512, 64), 0.9, 0.11, [(xr0 + xr1) / 2 - 0.5, ycs, zr + Ros + 0.004])\n  k.plane(textMaterial('GLYCOL REBOILER', '#f2f2ee', '#111', 512, 64), 0.9, 0.11, [(xr0 + xr1) / 2 - 0.5, ycr - 0.15, zr + Ro + 0.005])\n  // reboiler shell: flat bolted bundle flange at -X, 2:1 head at +X\n  hVessel(k, xr0, xr1, ycr, zr, Ro, g.hd, INSUL, true)\n  k.cyl('equipmentPaint', Ro + 0.07, 0.06, [xr0 + 0.03, ycr, zr], 'x', 48)\n  k.cyl('equipmentPaint', Ro + 0.07, 0.05, [xr0 - 0.025, ycr, zr], 'x', 48)\n  k.around(28, Ro + 0.04, [xr0 - 0.06, ycr, zr], 'x', (q) => {\n    k.cyl('boltXylan', 0.011, 0.17, addv(q, [0.075, 0, 0]), 'x', 6)\n    k.hex('nut', 0.036, 0.018, addv(q, [0.0, 0, 0]), 'x')\n  })\n  for (let x = xr0 + 0.6; x < xr1; x += 0.6) k.torus(INSUL, Ro + 0.002, 0.004, [x, ycr, zr], 'x', 6, 48)\n  // portal frame carrying the reboiler saddles over the surge drum\n  const sdr = saddleDims(2 * Ro, g.TT)\n  const xrs = [xr0 + sdr.positionFromTangent + 0.35, xr1 - sdr.positionFromTangent - 0.35]\n  const zc = Ro * 0.9 + 0.12\n  for (const x of xrs) {\n    for (const s of [-1, 1]) {\n      member(k, [x, y0, zr + s * zc], [x, ybt - 0.16, zr + s * zc], 'HEB 160', 'structSteel', [1, 0, 0])\n      k.box('structSteel', [0.26, 0.016, 0.26], [x, y0 + 0.008, zr + s * zc])\n    }\n    member(k, [x, ybt - 0.08, zr - zc - 0.08], [x, ybt - 0.08, zr + zc + 0.08], 'HEB 160', 'structSteel', [0, 1, 0])\n  }\n  for (const s of [-1, 1]) member(k, [xrs[0], ybt - 0.4, zr + s * zc], [xrs[1], ybt - 0.4, zr + s * zc], 'UPN 160', 'structSteel', [0, 1, 0])\n  k.at([0, ycr, zr], undefined, () => {\n    saddle(k, xrs[0], Ro, ybt - ycr)\n    saddle(k, xrs[1], Ro, ybt - ycr, { sliding: true })\n  })\n  // downcomer (reboiler overflow into the surge drum) and surge vent / blanket gas\n  k.pipe('pipePaint', [xr1 - 0.2, ycr - Ro + 0.02, zr], [xr1 - 0.2, ycs + Ros - 0.02, zr], pipeOD(3), 0.0055)\n  flangedJoint(k, 3, LP, [xr1 - 0.2, (ycr - Ro + ycs + Ros) / 2, zr], 'y')\n  // reboiler instruments: temperature control, level glass, thermowells\n  tempElement(k, [xr0 + 0.8, ycr + Ro * 0.5, zr + Ro * 0.87], [0, 0.5, 0.87], 0)\n  tempElement(k, [xr1 - 1.2, ycr + Ro * 0.5, zr + Ro * 0.87], [0, 0.5, 0.87], 0)\n  k.rbox('aluminium', [0.25, 0.3, 0.14], 0.015, [xr0 + 1.4, ycr, zr + Ro + 0.2])\n  k.box('structSteel', [0.04, 0.04, 0.2], [xr0 + 1.4, ycr, zr + Ro + 0.08])\n  const lgx = xr1 - 1.0\n  for (const [yc, R] of [[ycr, Ro], [ycs, Ros]] as const) {\n    k.cyl('stainless', 0.03, R * 1.1, [lgx, yc, zr + R + 0.14], 'y', 14)\n    k.box('glass', [0.025, R * 0.9, 0.02], [lgx, yc, zr + R + 0.175])\n    for (const s of [-1, 1]) k.rod('stainless', [lgx, yc + s * R * 0.45, zr + R * 0.85], [lgx, yc + s * R * 0.45, zr + R + 0.14], 0.012, 8)\n  }\n  gaugeTap(k, [xr0 + 2.2, ycs + Ros, zr], [0, 1, 0], 4, 0.08)\n\n  // ---------------------------------------------------------------- fire tube, burner and exhaust stack\n  const yLow = ycr - Ro * 0.38\n  const yHi = ycr + Ro * 0.38\n  const rFt = Math.min(0.22, Ro * 0.3)\n  k.cyl('equipmentPaintDark', rFt + 0.05, 0.55, [xr0 - 0.33, yLow, zr], 'x', 28)\n  k.cyl('equipmentPaintDark', rFt + 0.1, 0.05, [xr0 - 0.08, yLow, zr], 'x', 28)\n  k.around(8, rFt + 0.08, [xr0 - 0.1, yLow, zr], 'x', (q) => k.hex('nut', 0.03, 0.02, q, 'x'))\n  // flame arrestor housing on the burner air intake\n  k.cyl(STACK, rFt + 0.16, 0.38, [xr0 - 0.82, yLow, zr], 'x', 28)\n  for (const s of [-1, 1]) k.cyl('flangeSteel', rFt + 0.2, 0.04, [xr0 - 0.82 + s * 0.2, yLow, zr], 'x', 28)\n  k.disc('rubberBlack', rFt + 0.12, [xr0 - 1.035, yLow, zr], '-x', 28)\n  k.cyl('actuatorYellow', 0.04, 0.12, [xr0 - 0.45, yLow - rFt - 0.1, zr], 'y', 12) // pilot assembly\n  k.rbox('stainless', [0.12, 0.08, 0.08], 0.01, [xr0 - 0.3, yLow + rFt + 0.08, zr]) // flame scanner\n  // fire-tube return leg to the stack\n  const xs = xr0 - 0.75\n  const rs = 0.18 + g.D * 0.05\n  pipeRun(k, [[xr0 - 0.05, yHi, zr], [xs, yHi, zr], [xs, yHi + 0.9, zr]], 12, { mat: STACK })\n  k.cyl(STACK, rs, p.stackHeight - (yHi + 0.9), [xs, (p.stackHeight + yHi + 0.9) / 2, zr], 'y', 28)\n  k.cyl(STACK, rs + 0.03, 0.04, [xs, yHi + 0.92, zr], 'y', 28)\n  for (let y = yHi + 2; y < p.stackHeight - 0.3; y += 1.8) k.cyl(STACK, rs + 0.02, 0.03, [xs, y, zr], 'y', 28)\n  k.cyl(STACK, rs + 0.06, 0.12, [xs, p.stackHeight + 0.18, zr], 'y', 28, rs * 0.2)\n  for (let i = 0; i < 4; i++) {\n    const an = (i / 4) * Math.PI * 2 + Math.PI / 4\n    k.rod(STACK, [xs + Math.cos(an) * rs, p.stackHeight - 0.02, zr + Math.sin(an) * rs], [xs + Math.cos(an) * rs, p.stackHeight + 0.14, zr + Math.sin(an) * rs], 0.012, 6)\n  }\n  k.cyl('stainless', 0.025, 0.12, [xs + rs + 0.06, p.stackHeight - 1.2, zr], 'x', 10)\n  // stack support: braced post pair from the deck\n  for (const s of [-1, 1]) {\n    member(k, [xs, y0, zr + s * (rs + 0.15)], [xs, yHi + 2.6, zr + s * (rs + 0.15)], 'HEB 100', 'structSteel', [0, 0, 1])\n    k.box('structSteel', [0.2, 0.016, 0.2], [xs, y0 + 0.008, zr + s * (rs + 0.15)])\n  }\n  for (const y of [yHi + 1.0, yHi + 2.5]) {\n    member(k, [xs, y, zr - rs - 0.2], [xs, y, zr + rs + 0.2], 'UPN 100', 'structSteel', [1, 0, 0])\n    k.torus('structSteel', rs + 0.015, 0.012, [xs, y, zr], 'y', 6, 28)\n  }\n  member(k, [xs, yHi + 2.5, zr - rs - 0.15], [xr0 + 0.4, ybt + 0.8, zr - Ro - 0.02], 'L70x70x7', 'structSteel')\n\n  // fuel gas train: BL → ball valve → filter → regulator → shutdown valve → burner\n  const zF = g.zFuel\n  const fw = blFlange(k, [a, g.yFuel, zF], [-1, 0, 0], 1, LP)\n  const xb = xr0 - 0.45\n  pipeRun(k, [fw, [xb, g.yFuel, zF], [xb, yLow - rFt - 0.3, zF], [xb, yLow - rFt - 0.3, zr], [xb, yLow - rFt - 0.04, zr]], 1)\n  const xm = (a + xb) / 2\n  smallValve(k, 'ball', [a + 0.3, g.yFuel, zF], [1, 0, 0], 0.016)\n  k.cyl('aluminium', 0.05, 0.2, [xm - 0.1, g.yFuel - 0.08, zF], 'y', 16)\n  k.cyl('actuatorBlue', 0.045, 0.05, [xm + 0.12, g.yFuel + 0.1, zF], 'y', 18)\n  k.cyl('actuatorBlue', 0.07, 0.04, [xm + 0.12, g.yFuel + 0.16, zF], 'y', 20)\n  k.rbox('valveBody', [0.08, 0.07, 0.07], 0.01, [xm + 0.12, g.yFuel, zF])\n  k.rbox('actuatorRed', [0.08, 0.12, 0.08], 0.01, [xb, g.yFuel + 0.45, zF])\n  gaugeTap(k, [xm + 0.32, g.yFuel + pipeOD(1) / 2, zF], [0, 1, 0], 10, 0.07)\n  pipeSupport(k, [a + 0.6, g.yFuel, zF], pipeOD(1), y0, 'x')\n  // stripping gas from the fuel train into the reboiler, through a needle valve and rotameter\n  const ysg = yLow - rFt - 0.5\n  const xsg = xr0 + 0.55\n  const zsg = zr + Ro * 0.95\n  k.tube('tubing', [[xb, ysg, zF], [xsg, ysg, zF], [xsg, ycr - Ro * 0.3, zF], [xsg, ycr - Ro * 0.3, zsg]], 0.0095, 8, 0.04)\n  smallValve(k, 'needle', [(xb + xsg) / 2, ysg, zF], [1, 0, 0], 0.008)\n  const yrm = (ysg + ycr - Ro * 0.3) / 2\n  k.cyl('glass', 0.025, 0.22, [xsg, yrm, zF], 'y', 14)\n  for (const s of [-1, 1]) k.cyl('stainless', 0.03, 0.03, [xsg, yrm + s * 0.12, zF], 'y', 14)\n  k.sphere('actuatorRed', 0.012, [xsg, yrm + 0.03, zF], undefined, 8)\n\n  // ---------------------------------------------------------------- still column with reflux condenser\n  const Rc = g.Rc\n  nozzle(k, { base: [xc, ycr + Ro - 0.02, zr], dir: [0, 1, 0], nps: 12, rating: LP, standout: g.yj - 0.00225 - (ycr + Ro - 0.02), mat: INSUL, studs: false })\n  const fc = flangeDims(12, LP)\n  mateFlange(k, [xc, g.yj - 0.00225, zr], [0, 1, 0], 12, LP)\n  const cy0 = g.yj + fc.hubLength + fc.raisedFaceHeight\n  k.cyl('equipmentPaint', Math.max(Rc, pipeOD(12) / 2), g.yfin0 - cy0, [xc, (g.yfin0 + cy0) / 2, zr], 'y', 32)\n  for (let i = 0; i < 14; i++) k.cyl('aluminium', Rc + 0.14, 0.006, [xc, g.yfin0 + 0.03 + i * (g.Hf - 0.06) / 13, zr], 'y', 32)\n  k.cyl('equipmentPaint', Rc, g.Hf, [xc, g.yfin0 + g.Hf / 2, zr], 'y', 32)\n  k.dome('equipmentPaint', Rc, [xc, g.yfin0 + g.Hf, zr], 'y', Math.PI / 2, 0.5, 32)\n  for (const s of [-1, 1]) k.box('structSteel', [0.02, g.Hf, 0.05], [xc + s * (Rc + 0.15), g.yfin0 + g.Hf / 2, zr])\n  // vent nozzle (port), coil connections, feed nozzle, packing support / manways\n  const vw = addv([xc, g.yVent, zr], [0, -1, 0], g.fv.hubLength + g.fv.raisedFaceHeight)\n  flange(k, 3, LP, [xc, g.yVent, zr], [0, 1, 0], true)\n  k.pipe('equipmentPaint', [xc, g.yColTop - 0.03, zr], vw, pipeOD(3), 0.0055)\n  k.disc('pipeBore', pipeOD(3) / 2 - 0.004, [xc, g.yColTop - 0.025, zr], 'y', 20)\n  const yci = g.yfin0 + g.Hf - 0.06\n  const yco = g.yfin0 + 0.08\n  for (const y of [yci, yco]) k.pipe('stainless', [xc + Rc - 0.01, y, zr], [xc + Rc + 0.2, y, zr], od, 0.004)\n  const yfeed = g.ypl + 1.45\n  nozzle(k, { base: [xc, yfeed, zr + Rc], dir: [0, 0, 1], nps: gn, rating: LP, standout: 0.18, mat: 'equipmentPaint', studs: false })\n  for (const y of [g.yj + 0.6, g.yj + 1.9]) nozzle(k, { base: [xc, y, zr - Rc], dir: [0, 0, -1], nps: 8, rating: LP, standout: 0.15, mat: 'equipmentPaint', blind: true })\n  shellLabel(k, 'STILL', xc, g.yj + 1.2, zr, Rc, 0.08, 1.4)\n  for (const y of [g.yj + 0.9, g.yj + 1.8]) k.torus('structSteel', Rc + 0.02, 0.012, [xc, y, zr], 'y', 6, 28)\n\n  // reboiler top platform with caged ladder (access to the still and level instruments)\n  const px0 = xr0 + 0.4\n  const px1 = xr1 + 0.15\n  const pw = 1.25\n  grating(k, [(px0 + px1) / 2, g.ypl + 0.05, zr], px1 - px0, pw)\n  for (const s of [-1, 1]) member(k, [px0, g.ypl, zr + s * (pw / 2 - 0.04)], [px1, g.ypl, zr + s * (pw / 2 - 0.04)], 'UPN 100', 'galvanised', [0, 1, 0])\n  for (const x of [px0 + 0.1, (px0 + px1) / 2, px1 - 0.1]) {\n    member(k, [x, g.ypl - 0.02, zr - pw / 2], [x, g.ypl - 0.02, zr + pw / 2], 'UPN 100', 'galvanised', [0, 1, 0])\n    for (const s of [-1, 1]) member(k, [x, g.ypl - 0.08, zr + s * pw * 0.35], [x, ycr + Ro * 0.6, zr + s * Ro * 0.6], 'L50x50x5', 'galvanised')\n  }\n  const lx = xr0 + 1.6\n  handrail(k, [[lx + 0.35, g.ypl + 0.05, zr - pw / 2], [px1, g.ypl + 0.05, zr - pw / 2], [px1, g.ypl + 0.05, zr + pw / 2], [px0, g.ypl + 0.05, zr + pw / 2], [px0, g.ypl + 0.05, zr - pw / 2], [lx - 0.35, g.ypl + 0.05, zr - pw / 2]])\n  ladder(k, [lx, y0, zr - pw / 2 - 0.02], g.ypl + 0.05, [0, 0, -1])\n\n  // ---------------------------------------------------------------- flash drum, filters, lean/rich exchanger\n  const F = vVessel(k, xf, zr, y0 + 0.75, 0.6, 1.5, g.Rof - 0.3, 'equipmentPaint', 32)\n  vesselLegs(k, xf, zr, g.Rof, y0 + 0.75 + 0.1, y0, 4, 'equipmentPaint')\n  shellLabel(k, 'FLASH DRUM', xf, y0 + 1.3, zr, g.Rof, 0.07, 1.4)\n  const fp = smallPsv(k, [xf - 0.15, F.yTop - 0.03, zr], [-1, 0, 0], 0.025)\n  k.tube('pipePaint', [fp, addv(fp, [-0.1, 0, 0]), addv(fp, [-0.1, 0.25, 0])], 0.02, 8, 0.04)\n  k.rbox('actuatorBlue', [0.12, 0.2, 0.1], 0.015, [xf + g.Rof + 0.12, y0 + 1.45, zr])\n  for (const y of [y0 + 1.2, y0 + 1.75]) k.rod('stainless', [xf + g.Rof - 0.01, y, zr], [xf + g.Rof + 0.08, y, zr], 0.012, 8)\n  const yF0 = y0 + 0.45\n  const filt = (x: number, label: string) => {\n    const f = vVessel(k, x, zp, yF0 + 0.1, 0.3, 1.0, 0.006, 'equipmentPaint', 24)\n    vesselLegs(k, x, zp, 0.156, yF0 + 0.18, y0, 3, 'equipmentPaint', Math.PI / 2)\n    k.cyl('equipmentPaint', 0.2, 0.04, [x, f.yTop - 0.04, zp], 'y', 24)\n    k.around(8, 0.185, [x, f.yTop - 0.015, zp], 'y', (q) => k.hex('nut', 0.022, 0.012, q))\n    shellLabel(k, label, x, yF0 + 0.7, zp, 0.156, 0.05, 1.6)\n    k.rod('stainless', [x + 0.15, yF0 + 0.3, zp], [x + 0.15, yF0 + 0.95, zp], 0.006, 6)\n    gauge(k, [x + 0.15, yF0 + 1.0, zp + 0.01], 'z', 0.07, 'bar', 4)\n    return f\n  }\n  const xF1 = xf - 0.3\n  const xF2 = xf + 0.32\n  const F1 = filt(xF1, 'SOCK')\n  const F2 = filt(xF2, 'CARBON')\n  const xe1 = xr1 + 0.1\n  const xe0 = Math.max(xe1 - 1.5, g.xpl + 0.85 * (g.N - 1) + 0.5)\n  const ye = y0 + 0.5\n  const Re = 0.17\n  k.cyl('equipmentPaint', Re, xe1 - xe0, [(xe0 + xe1) / 2, ye, zp], 'x', 32)\n  k.dome('equipmentPaint', Re, [xe0, ye, zp], '-x', Math.PI / 2, 0.5, 32)\n  k.cyl('equipmentPaint', Re + 0.04, 0.05, [xe1 + 0.025, ye, zp], 'x', 32)\n  k.cyl('equipmentPaint', Re, 0.25, [xe1 + 0.175, ye, zp], 'x', 32)\n  k.cyl('equipmentPaint', Re + 0.04, 0.04, [xe1 + 0.32, ye, zp], 'x', 32)\n  for (const s of [xe1 + 0.025, xe1 + 0.32]) k.around(12, Re + 0.025, [s, ye, zp], 'x', (q) => k.hex('nut', 0.022, 0.03, q, 'x'))\n  for (const x of [xe0 + 0.25, xe1 - 0.25]) {\n    k.box('equipmentPaint', [0.12, ye - Re - y0, 0.3], [x, (ye - Re + y0) / 2, zp])\n    k.box('equipmentPaint', [0.2, 0.014, 0.4], [x, y0 + 0.007, zp])\n  }\n  k.plane(textMaterial('LEAN / RICH EXCHANGER', '#f2f2ee', '#111', 512, 64), 0.6, 0.075, [(xe0 + xe1) / 2, ye, zp + Re + 0.002])\n\n  // rich glycol: BL → reflux coil → flash drum → LV → sock → carbon → exchanger tubes → still feed\n  const zi = g.ziRich\n  const rW = blFlange(k, [L / 2, g.yRich, zi], [1, 0, 0], gn, LP)\n  pipeRun(k, [rW, [xc + 0.5, g.yRich, zi], [xc + 0.5, yci, zi], [xc + 0.5, yci, zr], [xc + Rc + 0.2, yci, zr]], gn, { mat: 'stainless', schedule: '40S' })\n  pipeRun(k, [[xc + Rc + 0.2, yco, zr], [xf, yco, zr], [xf, F.yTop - 0.02, zr]], gn, { mat: 'stainless', schedule: '40S' })\n  pipeSupport(k, [xr1 + 0.4, g.yRich, zi], od, y0, 'x')\n  const Sl = valveSpan('globe', 1.5, LP)\n  const zLv = (zr + zp) / 2\n  pipeRun(k, [[xf, F.yBot + 0.02, zr], [xf, y0 + 0.22, zr], [xf, y0 + 0.22, zLv - Sl / 2]], 1.5)\n  valve(k, 'globe', [xf, y0 + 0.22, zLv], [0, 0, 1], 1.5, LP, [0, 1, 0], 'valveBody', 1, 'diaphragm')\n  pipeRun(k, [[xf, y0 + 0.22, zLv + Sl / 2], [xf, y0 + 0.22, zp - 0.3], [xF1, y0 + 0.22, zp - 0.3], [xF1, y0 + 0.22, zp], [xF1, F1.yBot + 0.02, zp]], 1.5)\n  pipeRun(k, [[xF1, F1.yTop - 0.02, zp], [xF1, F1.yTop + 0.2, zp], [xF2, F1.yTop + 0.2, zp], [xF2, F2.yTop - 0.02, zp]], 1.5)\n  pipeRun(k, [[xF2, F2.yBot + 0.02, zp], [xF2, y0 + 0.3, zp], [xF2, y0 + 0.3, zp + 0.36], [xe1 + 0.18, y0 + 0.3, zp + 0.36], [xe1 + 0.18, y0 + 0.3, zp], [xe1 + 0.18, ye - Re + 0.02, zp]], 1.5)\n  pipeRun(k, [[xe1 + 0.18, ye + Re - 0.02, zp], [xe1 + 0.18, yfeed, zp], [xc, yfeed, zp], [xc, yfeed, zr + Rc + 0.18 + 0.0045 + flangeDims(gn, LP).hubLength + flangeDims(gn, LP).raisedFaceHeight]], gn)\n  mateFlange(k, [xc, yfeed, zr + Rc + 0.18], [0, 0, 1], gn, LP)\n  for (const x of [xe1 + 0.18]) pipeSupport(k, [x, g.ypl + 0.6, zp + 0.0], od, ye + Re + 0.3, 'z')\n\n  // lean glycol: surge → exchanger shell → pump suction header → pumps → lean header → BL\n  pipeRun(k, [[xr1 - 0.7, ycs - Ros + 0.02, zr], [xr1 - 0.7, y0 + 0.2, zr], [xr1 - 0.7, y0 + 0.2, zp - Re - 0.1], [xe1 - 0.3, y0 + 0.2, zp - Re - 0.1], [xe1 - 0.3, y0 + 0.2, zp], [xe1 - 0.3, ye - Re + 0.02, zp]], gn)\n  const zh = zp - 0.42\n  const ypn = y0 + 0.42\n  const xLast = g.xpl + 0.85 * (g.N - 1)\n  pipeRun(k, [[xe0 + 0.3, ye - Re + 0.02, zp], [xe0 + 0.3, ypn, zp], [xe0 + 0.3, ypn, zh], [g.xpl - 0.25, ypn, zh]], gn)\n  k.dome('pipePaint', od / 2, [g.xpl - 0.25, ypn, zh], '-x', Math.PI / 2, 0.5, 20)\n  const ms = motorSize(p.kw, 4)\n  const Lp = 0.5\n  const fl = flangeDims(gn, p.leanRating)\n  const fj = fl.hubLength + fl.raisedFaceHeight + 0.00225\n  for (let i = 0; i < g.N; i++) {\n    const x = g.xpl + 0.85 * i\n    pipeRun(k, [[x, ypn, zh + od / 2 - 0.004], [x, ypn, zp - Lp / 2 - fj]], gn)\n    branchMouth(k, [x, ypn, zh + od / 2 - 0.0035], [0, 0, 1], gn)\n    smallValve(k, 'ball', [x, ypn, zh + 0.12], [0, 0, 1], 0.02)\n    inlinePump(k, x, ypn, zp, Lp, ms.R, ms.L, gn, p.leanRating, y0)\n    const Sc = valveSpan('check', gn, p.leanRating)\n    pipeRun(k, [[x, ypn, zp + Lp / 2 + fj], [x, ypn, zp + Lp / 2 + 0.2], [x, ypn + 0.28, zp + Lp / 2 + 0.2]], gn, { schedule: '80' })\n    valve(k, 'check', [x, ypn + 0.28 + Sc / 2, zp + Lp / 2 + 0.2], [0, 1, 0], gn, p.leanRating, [0, 0, 1])\n    pipeRun(k, [[x, ypn + 0.28 + Sc, zp + Lp / 2 + 0.2], [x, g.yLean, zp + Lp / 2 + 0.2], [x, g.yLean, g.zlh - od / 2 + 0.004]], gn, { schedule: '80' })\n    branchMouth(k, [x, g.yLean, g.zlh - od / 2 + 0.0035], [0, 0, -1], gn)\n    gaugeTap(k, [x + od / 2, ypn + 0.28 + Sc + 0.1, zp + Lp / 2 + 0.2], [1, 0, 0], 100, 0.08)\n  }\n  const lw = blFlange(k, [g.xLeanOut, g.yLean, W / 2], [0, 0, 1], gn, p.leanRating)\n  pipeRun(k, [[g.xpl - 0.05, g.yLean, g.zlh], [g.xLeanOut, g.yLean, g.zlh], lw], gn, { schedule: '80' })\n  k.dome('pipePaint', od / 2, [g.xpl - 0.05, g.yLean, g.zlh], '-x', Math.PI / 2, 0.5, 20)\n  transmitter(k, [xLast + 0.3, g.yLean + od / 2, g.zlh], [0, 1, 0], 0.06)\n  pipeSupport(k, [(g.xpl + xLast) / 2 + 0.42, g.yLean, g.zlh], od, y0, 'x')\n\n  // ---------------------------------------------------------------- BMS panel and cable tray\n  const gl = localPanel(k, [a + 0.45, y0, W / 2 - 0.4], [0, 0, 1], { w: 0.6, h: 0.75, top: 1.9, title: 'BMS / TEG LCP', buttons: 4 })\n  cableTray(k, [[a + 0.45, gl[1] - 0.04, gl[2]], [a + 0.45, y0 + 0.2, gl[2]], [a + 0.45, y0 + 0.2, W / 2 - 0.12], [xLast + 0.3, y0 + 0.2, W / 2 - 0.12]], [1, 0, 0], 0.15, 3)\n}\n\n/** Vertical in-line glycol pump: casing with suction (−Z) / discharge (+Z) flanges, lantern, vertical TEFC motor, pedestal. */\nfunction inlinePump(k: Kit, x: number, y: number, z: number, Lp: number, Rm: number, Lm: number, nps: number, rating: Rating, deck: number) {\n  const C: MatKey = 'machineryGreen'\n  const f = flangeDims(nps, rating)\n  const fl = f.hubLength + f.raisedFaceHeight\n  k.cyl(C, 0.16, 0.26, [x, y, z], 'y', 32)\n  k.cyl(C, 0.18, 0.04, [x, y + 0.15, z], 'y', 32)\n  k.around(8, 0.165, [x, y + 0.175, z], 'y', (q) => k.hex('nut', 0.024, 0.015, q))\n  for (const s of [-1, 1]) {\n    const face: V3 = [x, y, z + (s * Lp) / 2 - s * 0.00225]\n    k.pipe(C, [x, y, z + s * 0.1], addv(face, [0, 0, s], -fl), pipeOD(nps) * 1.05, 0.008)\n    spoolJoint(k, [x, y, z + (s * Lp) / 2], [0, 0, s], nps, rating)\n  }\n  k.cyl(C, 0.1, 0.22, [x, y + 0.28, z], 'y', 24)\n  for (let i = 0; i < 4; i++) k.box('rubberBlack', [0.06, 0.12, 0.004], [x + Math.cos((i * Math.PI) / 2) * 0.1, y + 0.28, z + Math.sin((i * Math.PI) / 2) * 0.1], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -(i * Math.PI) / 2 + Math.PI / 2))\n  k.cyl(C, Rm + 0.03, 0.04, [x, y + 0.41, z], 'y', 32)\n  const ym = y + 0.43\n  for (let i = 0; i < 20; i++) {\n    const an = (i / 20) * Math.PI * 2\n    k.box('motorPaint', [0.012, Lm * 0.8, Rm * 0.18], [x + Math.cos(an) * Rm * 1.02, ym + Lm * 0.45, z + Math.sin(an) * Rm * 1.02], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -an))\n  }\n  k.cyl('motorPaint', Rm, Lm * 0.9, [x, ym + Lm * 0.45, z], 'y', 32)\n  k.cyl('motorPaint', Rm * 1.12, Lm * 0.3, [x, ym + Lm * 0.9 + Lm * 0.15, z], 'y', 32, Rm * 1.05)\n  k.disc('rubberBlack', Rm * 0.9, [x, ym + Lm * 1.2 + 0.001, z], 'y', 28)\n  k.rbox('motorPaint', [Rm * 0.9, Rm * 0.7, Rm * 0.6], 0.015, [x + Rm + Rm * 0.25, ym + Lm * 0.6, z])\n  k.box('nameplate', [Rm * 0.5, Rm * 0.35, 0.003], [x, ym + Lm * 0.45, z + Rm * 1.02 + 0.005])\n  k.box(C, [0.3, y - 0.13 - deck, 0.24], [x, (y - 0.13 + deck) / 2, z])\n  k.box(C, [0.42, 0.02, 0.36], [x, deck + 0.01, z])\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.hex('nut', 0.03, 0.02, [x + sx * 0.17, deck + 0.03, z + sz * 0.14])\n}\n"
    },
    {
      "path": "packages/_skid.ts",
      "type": "registry:file",
      "target": "components/deck-twin/packages/_skid.ts",
      "content": "// Shared package-skid parts: battery-limit flanges, in-kit valves, instruments, local panels, cable tray, supports, vessels, drip pans.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { flange, flangedJoint, gasket, gauge, member } from '../lib/parts'\nimport { faceToFace, flange as flangeDims, handwheelDia, headDepth, pipeOD, pipeWall, type Rating } from '../lib/standards'\nimport { lcdMaterial, textMaterial } from '../lib/textures'\n\nconst V = (v: V3) => new THREE.Vector3(...v)\nconst arr = (v: THREE.Vector3) => v.toArray() as V3\nexport const addv = (a: V3, d: V3, s = 1): V3 => [a[0] + d[0] * s, a[1] + d[1] * s, a[2] + d[2] * s]\n\n/** Local frame with +X along `dir` and +Y as close to `up` as possible, origin at `pos`. */\nexport function frame(pos: V3, dir: V3, up: V3 = [0, 1, 0]) {\n  const x = V(dir).normalize()\n  let u = V(up).normalize()\n  if (Math.abs(u.dot(x)) > 0.99) u = Math.abs(x.y) > 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0)\n  const z = new THREE.Vector3().crossVectors(x, u).normalize()\n  const y = new THREE.Vector3().crossVectors(z, x).normalize()\n  return new THREE.Matrix4().makeBasis(x, y, z).setPosition(V(pos))\n}\n\n/** Battery-limit weld-neck flange with studs, face at `face` pointing along `dir`. Returns the weld end for the neck pipe. */\nexport function blFlange(k: Kit, face: V3, dir: V3, nps: number, rating: Rating, studs = true): V3 {\n  const L = flange(k, nps, rating, face, dir, studs)\n  liner(k, nps, face, dir, L)\n  return addv(face, dir, -L)\n}\n\n/** Bore lining through a weld-neck flange (face to weld end) so a flange reads hollow when looked into. */\nexport function liner(k: Kit, nps: number, face: V3, dir: V3, len: number) {\n  const ro = pipeOD(nps) / 2\n  const ri = ro - pipeWall(nps, 'STD')\n  k.lathe('machined', [[ro * 0.88, 0], [ri, 0]], face, dir, 24)\n  k.lathe('pipeBore', [[ri, 0], [ri, -len]], face, dir, 24)\n}\n\nexport type ValveKind = 'ball' | 'gate' | 'globe' | 'check'\n\n/** Overall length of an in-kit flanged valve including both mating pipe flanges (weld end to weld end). */\nexport function valveSpan(kind: ValveKind, nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  const fam = kind === 'ball' ? 'ball-full' : kind === 'check' ? 'swing-check' : kind\n  return faceToFace(fam, nps, rating) + 2 * (0.0045 + f.hubLength + f.raisedFaceHeight)\n}\n\n/**\n * Flanged valve centred at `pos` on a line along `dir`, operator towards `up`. Both mating pipe flanges, gaskets\n * and studs are included, so the line pipe stops at pos ± dir·valveSpan/2.\n */\nexport function valve(k: Kit, kind: ValveKind, pos: V3, dir: V3, nps: number, rating: Rating, up: V3 = [0, 1, 0], body: MatKey = 'valveBody', open = 1, op: 'manual' | 'mov' | 'diaphragm' = 'manual') {\n  const fam = kind === 'ball' ? 'ball-full' : kind === 'check' ? 'swing-check' : kind\n  const L = faceToFace(fam, nps, rating)\n  const f = flangeDims(nps, rating)\n  const D = pipeOD(nps)\n  const fl = f.thickness + f.raisedFaceHeight\n  k.with(frame(pos, dir, up), () => {\n    spoolJoint(k, [L / 2 + 0.00225, 0, 0], [1, 0, 0], nps, rating)\n    spoolJoint(k, [-L / 2 - 0.00225, 0, 0], [-1, 0, 0], nps, rating)\n    const inner = L / 2 - fl\n    const ri = D / 2 - pipeWall(nps, 'STD')\n    k.pipe(body, [-inner - 0.002, 0, 0], [inner + 0.002, 0, 0], D * 1.24, D * 0.62 - ri + 0.003, 28)\n    if (kind === 'ball') {\n      k.sphere(body, D * 0.82, [0, 0, 0], [1.05, 1, 1], 28)\n      for (const s of [-1, 1]) k.cyl(body, D * 0.78, 0.012, [s * D * 0.55, 0, 0], 'x', 28)\n      k.around(8, D * 0.7, [D * 0.55, 0, 0], 'x', (p) => k.hex('nut', 0.016, 0.01, addv(p, [0.008, 0, 0])))\n      const yTop = D * 0.95\n      k.cyl(body, D * 0.3, yTop - D * 0.6, [0, (yTop + D * 0.6) / 2, 0], 'y', 16)\n      k.cyl(body, D * 0.36, 0.012, [0, yTop, 0], 'y', 16)\n      if (op === 'mov') movActuator(k, yTop, D, body)\n      else if (nps <= 4) {\n        const lever = Math.max(0.15, handwheelDia('ball-full', nps, rating))\n        const a = (1 - open) * (Math.PI / 2)\n        k.cyl('nut', D * 0.12, 0.04, [0, yTop + 0.02, 0], 'y', 10)\n        k.box('actuatorRed', [lever, 0.012, 0.03], [(Math.cos(a) * lever) / 2 - Math.cos(a) * 0.02, yTop + 0.045, (-Math.sin(a) * lever) / 2], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a))\n      } else {\n        const gb = Math.max(0.12, D * 0.7)\n        k.rbox(body, [gb, gb * 0.6, gb * 0.8], gb * 0.08, [0, yTop + gb * 0.3, 0])\n        k.cyl(body, gb * 0.15, gb * 0.5, [0, yTop + gb * 0.3, gb * 0.6], 'z', 12)\n        k.handwheel('handwheel', Math.max(0.3, gb * 1.8), [0, yTop + gb * 0.3, gb * 0.85], 'z', 5)\n      }\n    } else if (kind === 'check') {\n      k.sphere(body, D * 0.78, [0, 0, 0], [1.25, 1, 0.95], 24)\n      const yc = D * 0.72\n      k.cyl(body, D * 0.62, 0.04, [0, yc, 0], 'y', 24)\n      k.cyl(body, D * 0.66, 0.025, [0, yc + 0.032, 0], 'y', 24)\n      k.around(8, D * 0.58, [0, yc + 0.045, 0], 'y', (p) => k.hex('nut', 0.018, 0.012, p))\n      k.box('stainless', [D * 0.5, 0.002, D * 0.25], [0, 0, D * 0.79])\n    } else {\n      // gate / globe: bolted bonnet, yoke, rising stem, handwheel\n      if (kind === 'globe') k.sphere(body, D * 0.85, [0, 0, 0], [1.2, 1, 0.95], 24)\n      else k.add(body, new THREE.CylinderGeometry(1, 1, D * 1.5, 28), new THREE.Matrix4().compose(new THREE.Vector3(0, D * 0.15, 0), new THREE.Quaternion(), new THREE.Vector3(D * 0.62, 1, D * 0.72)))\n      const yb = kind === 'globe' ? D * 0.8 : D * 0.9\n      k.cyl(body, D * 0.62, 0.03, [0, yb, 0], 'y', 24)\n      k.around(8, D * 0.55, [0, yb + 0.02, 0], 'y', (p) => k.hex('nut', 0.018, 0.012, p))\n      const hB = Math.max(0.08, D * 0.9)\n      k.cyl(body, D * 0.3, hB, [0, yb + hB / 2, 0], 'y', 16, D * 0.22)\n      const yoke = Math.max(0.12, D * 1.3)\n      const y0 = yb + hB\n      for (const s of [-1, 1]) k.box(body, [Math.max(0.012, D * 0.12), yoke, Math.max(0.012, D * 0.1)], [s * D * 0.28, y0 + yoke / 2, 0])\n      k.box(body, [D * 0.7, Math.max(0.016, D * 0.12), D * 0.2], [0, y0 + yoke, 0])\n      if (op === 'diaphragm') {\n        // spring-opposed diaphragm actuator with positioner\n        const Ra = THREE.MathUtils.clamp(D * 1.6, 0.14, 0.3)\n        const ya = y0 + yoke + 0.02\n        k.cyl('stainless', Math.max(0.006, D * 0.05), yoke * 0.8, [0, y0 + yoke * 0.5, 0], 'y', 10)\n        k.lathe('actuatorBlue', [[0.02, 0], [Ra * 0.9, 0], [Ra, Ra * 0.12], [Ra, Ra * 0.18], [Ra * 0.9, Ra * 0.2]], [0, ya, 0], 'y', 32)\n        k.lathe('actuatorBlue', [[Ra * 0.9, Ra * 0.2], [Ra, Ra * 0.22], [Ra, Ra * 0.28], [Ra * 0.7, Ra * 0.55], [0.02, Ra * 0.6]], [0, ya, 0], 'y', 32)\n        k.around(12, Ra * 0.95, [0, ya + Ra * 0.2, 0], 'y', (p) => k.hex('nut', 0.014, 0.012, p))\n        k.rbox('aluminium', [0.1, 0.12, 0.06], 0.01, [D * 0.35 + 0.05, y0 + yoke * 0.5, 0.04])\n        k.tube('tubing', [[D * 0.35 + 0.05, y0 + yoke * 0.5 + 0.06, 0.04], [D * 0.35 + 0.05, ya + Ra * 0.4, 0.04], [0.02, ya + Ra * 0.62, 0.04]], 0.004, 6, 0.02)\n      } else {\n        const stemTop = y0 + yoke + 0.04 + open * D * 0.6\n        k.cyl('stainless', Math.max(0.006, D * 0.05), stemTop - y0, [0, (stemTop + y0) / 2, 0], 'y', 10)\n        k.handwheel('handwheel', Math.max(0.16, handwheelDia(kind, nps, rating)), [0, y0 + yoke + 0.04, 0], 'y', 4)\n      }\n    }\n    k.box('stainless', [D * 0.45, D * 0.2, 0.002], [0, -D * 0.2, D * 0.63])\n  })\n  return valveSpan(kind, nps, rating)\n}\n\n/** Electric multi-turn actuator on a gearbox (valve-local frame, stem +Y from `y`). */\nfunction movActuator(k: Kit, y: number, D: number, body: MatKey) {\n  const s = THREE.MathUtils.clamp(D * 1.8, 0.45, 1.2)\n  k.cyl(body, D * 0.36, 0.06 * s, [0, y + 0.03 * s, 0], 'y', 20)\n  k.rbox(body, [0.24 * s, 0.2 * s, 0.26 * s], 0.02 * s, [0, y + 0.16 * s, 0])\n  k.rbox('actuatorBlue', [0.2 * s, 0.26 * s, 0.22 * s], 0.025 * s, [0, y + 0.39 * s, 0])\n  k.cyl('actuatorBlue', 0.08 * s, 0.28 * s, [0, y + 0.37 * s, 0.24 * s], 'z', 20)\n  k.cyl('actuatorBlue', 0.085 * s, 0.03 * s, [0, y + 0.37 * s, 0.38 * s], 'z', 20)\n  k.rbox('actuatorBlue', [0.16 * s, 0.1 * s, 0.14 * s], 0.015 * s, [0, y + 0.57 * s, 0])\n  k.disc('glass', 0.035 * s, [0, y + 0.39 * s, 0.111 * s], 'z', 18)\n  k.handwheel('handwheel', 0.3 * s, [-0.16 * s, y + 0.39 * s, 0], '-x', 4)\n  for (const dx of [-0.05, 0.05]) k.cyl('brass', 0.012 * s, 0.04 * s, [dx * s, y + 0.25 * s, -0.12 * s], 'z', 8)\n}\n\n/** Small-bore instrument / tubing valve (needle or ball) on a line along `dir`, handle towards `up`. Body length ≈ 8 r. */\nexport function smallValve(k: Kit, kind: 'needle' | 'ball', pos: V3, dir: V3, r: number, up: V3 = [0, 1, 0], mat: MatKey = 'stainless') {\n  const b = Math.max(0.012, r * 2.2)\n  k.with(frame(pos, dir, up), () => {\n    k.box(mat, [b * 2.6, b * 1.4, b * 1.4], [0, 0, 0])\n    for (const s of [-1, 1]) k.hex(mat, b * 1.25, b * 0.8, [s * b * 1.6, 0, 0], 'x')\n    if (kind === 'needle') {\n      k.cyl(mat, b * 0.45, b * 1.6, [0, b * 1.4, 0], 'y', 10)\n      k.hex(mat, b * 1.1, b * 0.5, [0, b * 1.1, 0], 'y')\n      k.box('handwheel', [b * 2.2, b * 0.35, b * 0.4], [0, b * 2.25, 0])\n    } else {\n      k.cyl(mat, b * 0.35, b * 0.8, [0, b * 1.0, 0], 'y', 10)\n      k.box('actuatorRed', [b * 4, b * 0.25, b * 0.6], [b * 1.6, b * 1.45, 0])\n    }\n  })\n}\n\n/** Small conventional relief valve: inlet at `inlet` going up (+Y), outlet towards `out`. Returns the outlet connection point. */\nexport function smallPsv(k: Kit, inlet: V3, out: V3, size = 0.025, body: MatKey = 'stainless', bonnet: MatKey = 'actuatorRed') {\n  const s = size\n  const o = V(out).normalize()\n  const c = addv(inlet, [0, 1, 0], s * 2.2)\n  k.hex(body, s * 1.6, s * 0.9, addv(inlet, [0, 1, 0], s * 0.45))\n  k.cyl(body, s * 1.0, s * 2.4, addv(c, [0, -1, 0], s * 0.3), 'y', 16)\n  k.sphere(body, s * 1.15, c, undefined, 14)\n  const oc = addv(c, arr(o), s * 1.4)\n  k.rod(body, c, oc, s * 0.7, 12)\n  k.hex(body, s * 1.6, s * 0.9, addv(c, arr(o), s * 1.9), arr(o))\n  k.cyl(bonnet, s * 0.75, s * 3.2, addv(c, [0, 1, 0], s * 2.4), 'y', 14, s * 0.55)\n  k.cyl(bonnet, s * 0.6, s * 0.9, addv(c, [0, 1, 0], s * 4.4), 'y', 12, s * 0.4)\n  k.box('nut', [s * 2.2, s * 0.2, s * 0.35], addv(c, [0, 1, 0], s * 4.2))\n  k.box('stainless', [s * 0.03 + 0.012, s * 1.2, 0.0015], addv(addv(c, [0, 1, 0], s * 2.4), [0, 0, 1], s * 0.8))\n  return addv(c, arr(o), s * 2.35)\n}\n\n/** Pressure transmitter (coplanar, 2-valve manifold) on an impulse stub from `tap` along `n`; dual-compartment head with LCD. */\nexport function transmitter(k: Kit, tap: V3, n: V3, standoff = 0.1, housing: MatKey = 'actuatorBlue') {\n  const d = V(n).normalize()\n  const m = addv(tap, arr(d), standoff)\n  k.rod('tubing', tap, m, 0.007, 8)\n  k.with(frame(m, arr(d)), () => {\n    k.box('stainless', [0.05, 0.05, 0.07], [0.025, 0, 0])\n    for (const s of [-1, 1]) k.cyl('handwheel', 0.006, 0.04, [0.025, 0, s * 0.055], 'z', 6)\n    k.box('stainless', [0.03, 0.06, 0.06], [0.065, 0.01, 0])\n    k.cyl('stainless', 0.03, 0.07, [0.07, 0.075, 0], 'y', 16)\n    k.cyl(housing, 0.042, 0.1, [0.07, 0.15, 0], 'x', 20)\n    for (const s of [-1, 1]) k.cyl(housing, 0.046, 0.022, [0.07 + s * 0.06, 0.15, 0], 'x', 20)\n    k.disc(lcdMaterial(), 0.03, [0.132, 0.15, 0], 'x', 20)\n    k.disc('glass', 0.034, [0.133, 0.15, 0], 'x', 20)\n    k.cyl('brass', 0.011, 0.03, [0.07, 0.15, 0.055], 'z', 8)\n  })\n}\n\n/** Thermowell with transmitter head, inserted at `at` on a pipe, head out along `n`. */\nexport function tempElement(k: Kit, at: V3, n: V3, od: number) {\n  const d = V(n).normalize()\n  const base = addv(at, arr(d), od / 2)\n  k.with(frame(base, arr(d)), () => {\n    k.cyl('stainless', 0.02, 0.04, [0.02, 0, 0], 'x', 12)\n    k.hex('stainless', 0.032, 0.02, [0.05, 0, 0], 'x')\n    k.cyl('stainless', 0.008, 0.06, [0.09, 0, 0], 'x', 8)\n    k.cyl('aluminium', 0.04, 0.06, [0.15, 0, 0], 'x', 18)\n    k.cyl('aluminium', 0.043, 0.015, [0.185, 0, 0], 'x', 18)\n    k.cyl('brass', 0.009, 0.025, [0.15, -0.045, 0], 'y', 8)\n  })\n}\n\n/** Instrument tapping with root valve and pressure gauge, from `tap` along `n`. */\nexport function gaugeTap(k: Kit, tap: V3, n: V3, max = 100, d = 0.1, unit: 'bar' | '°C' = 'bar') {\n  const v = V(n).normalize()\n  const a = addv(tap, arr(v), 0.05)\n  k.rod('tubing', tap, addv(tap, arr(v), 0.12), 0.007, 8)\n  smallValve(k, 'needle', a, arr(v), 0.006, Math.abs(v.y) > 0.9 ? [0, 0, 1] : [0, 1, 0])\n  const g = addv(tap, arr(v), 0.12 + d * 0.16)\n  const face: V3 = Math.abs(v.y) > 0.9 ? [0, 0, 1] : arr(v)\n  if (Math.abs(v.y) > 0.9) {\n    k.rod('tubing', addv(tap, arr(v), 0.12), addv(addv(tap, arr(v), 0.12), [0, 0, 1], 0.001), 0.007, 8)\n    gauge(k, addv(tap, arr(v), 0.12 + d / 2), face, d, unit, max)\n  } else gauge(k, g, face, d, unit, max)\n}\n\n/** Ladder cable tray along a polyline. Horizontal runs lie flat; vertical runs use `side` (horizontal) as the width direction. */\nexport function cableTray(k: Kit, pts: V3[], side: V3, w = 0.15, cables = 3) {\n  const sv = V(side).normalize()\n  const UP = new THREE.Vector3(0, 1, 0)\n  const sideOf = (d: THREE.Vector3) => (Math.abs(d.y) > 0.9 ? sv.clone() : new THREE.Vector3().crossVectors(UP, d).normalize())\n  const segs: { a: THREE.Vector3; d: THREE.Vector3; L: number; s: THREE.Vector3; n: THREE.Vector3 }[] = []\n  for (let i = 0; i < pts.length - 1; i++) {\n    const a = V(pts[i])\n    const d = V(pts[i + 1]).sub(a)\n    const L = d.length()\n    if (L < 1e-4) continue\n    d.normalize()\n    const s = sideOf(d)\n    let n = new THREE.Vector3().crossVectors(s, d).normalize()\n    if (Math.abs(d.y) < 0.9 && n.y < 0) n = n.negate()\n    segs.push({ a, d, L, s, n })\n  }\n  for (const { a, d, L, s, n } of segs) {\n    const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(d, n, new THREE.Vector3().crossVectors(d, n)))\n    const mid = a.clone().addScaledVector(d, L / 2)\n    for (const sg of [-1, 1]) k.box('cableTray', [L + 0.01, 0.05, 0.003], arr(mid.clone().addScaledVector(s, (sg * w) / 2).addScaledVector(n, 0.02)), q)\n    const nr = Math.max(1, Math.floor(L / 0.25))\n    for (let j = 0; j <= nr; j++) k.box('cableTray', [0.02, 0.006, w], arr(a.clone().addScaledVector(d, (j / nr) * L).addScaledVector(n, 0.003)), q)\n  }\n  for (let c = 0; c < cables; c++) {\n    const off = ((c + 0.5) / cables - 0.5) * w * 0.7\n    const P: V3[] = []\n    segs.forEach(({ a, d, L, s, n }, i) => {\n      if (i === 0) P.push(arr(a.clone().addScaledVector(s, off).addScaledVector(n, 0.014)))\n      P.push(arr(a.clone().addScaledVector(d, L).addScaledVector(s, off).addScaledVector(n, 0.014)))\n    })\n    k.tube('cable', P, 0.008, 6, 0.08)\n  }\n}\n\n/**\n * Local control panel on two posts, door facing `n` (±X or ±Z), standing on the deck at `base`.\n * Returns the cable-gland point under the enclosure (for a tray run).\n */\nexport function localPanel(k: Kit, base: V3, n: V3, opts: { w?: number; h?: number; top?: number; title?: string; screen?: boolean; buttons?: number } = {}) {\n  const w = opts.w ?? 0.6\n  const h = opts.h ?? 0.7\n  const top = opts.top ?? 1.75\n  const dep = 0.25\n  const nn = V(n).normalize()\n  const side = new THREE.Vector3(0, 1, 0).cross(nn).normalize()\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), nn)\n  const at = (x: number, y: number, z: number) => arr(V(base).addScaledVector(side, x).add(new THREE.Vector3(0, y, 0)).addScaledVector(nn, z))\n  for (const s of [-1, 1]) {\n    member(k, at(s * (w / 2 - 0.05), 0, -0.05), at(s * (w / 2 - 0.05), top + 0.15, -0.05), 'UPN 80', 'galvanised', arr(nn))\n    k.box('galvanised', [0.16, 0.012, 0.16], at(s * (w / 2 - 0.05), 0.006, -0.05), q)\n  }\n  for (const y of [0.35, top - h - 0.05]) member(k, at(-w / 2, y, -0.03), at(w / 2, y, -0.03), 'L50x50x5', 'galvanised', [0, 1, 0])\n  const yc = top - h / 2\n  k.rbox('stainless', [w, h, dep], 0.015, at(0, yc, dep / 2), q)\n  k.rbox('stainless', [w - 0.03, h - 0.03, 0.012], 0.006, at(0, yc, dep + 0.006), q)\n  for (const s of [-0.3, 0.3]) k.box('nut', [0.02, 0.06, 0.012], at(w / 2 - 0.04, yc + s * h, dep + 0.016), q)\n  k.box('stainless', [w + 0.12, 0.01, dep + 0.2], at(0, top + 0.1, dep / 2 + 0.03), new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), nn).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0.12)))\n  const fz = dep + 0.013\n  if (opts.screen !== false) {\n    k.box('rubberBlack', [w * 0.5, h * 0.3, 0.006], at(0, yc + h * 0.18, fz), q)\n    k.plane(lcdMaterial(), w * 0.45, h * 0.26, at(0, yc + h * 0.18, fz + 0.0035), q)\n  }\n  const nb = opts.buttons ?? 3\n  const mats: MatKey[] = ['safetyGreen', 'actuatorRed', 'actuatorYellow', 'lampLens', 'safetyGreen', 'actuatorRed']\n  for (let i = 0; i < nb; i++) k.cyl(mats[i % mats.length], 0.016, 0.02, at(-w * 0.3 + (i * w * 0.6) / Math.max(1, nb - 1), yc - h * 0.18, fz + 0.01), arr(nn), 14)\n  k.cyl('actuatorYellow', 0.04, 0.004, at(w * 0.3, yc - h * 0.36, fz), arr(nn), 20)\n  k.dome('actuatorRed', 0.025, at(w * 0.3, yc - h * 0.36, fz + 0.01), arr(nn), Math.PI / 2, 0.7, 16)\n  if (opts.title) k.plane(textMaterial(opts.title, '#f2f2ee', '#111', 512, 96), w * 0.7, w * 0.13, at(0, yc + h * 0.42, fz + 0.001), q)\n  for (let i = 0; i < 4; i++) k.cyl('brass', 0.012, 0.035, at(-w * 0.3 + i * w * 0.2, top - h - 0.015, dep / 2), 'y', 8)\n  return at(0, top - h - 0.04, dep / 2)\n}\n\n/** Pipe stanchion from the deck to under a pipe centred at `at`, with saddle plate and U-bolt. Pipe runs along `along`. */\nexport function pipeSupport(k: Kit, at: V3, od: number, deckY: number, along: 'x' | 'z', mat: MatKey = 'galvanised') {\n  const top = at[1] - od / 2 - 0.006\n  if (top - deckY < 0.05) return\n  const big = od > 0.12\n  const sec = big ? 'HEB 100' : 'L50x50x5'\n  member(k, [at[0], deckY, at[2]], [at[0], top - 0.01, at[2]], sec, mat, along === 'x' ? [1, 0, 0] : [0, 0, 1])\n  const w = Math.max(0.1, od + 0.06)\n  k.box(mat, along === 'x' ? [0.1, 0.012, w] : [w, 0.012, 0.1], [at[0], top - 0.004, at[2]])\n  k.box(mat, [0.16, 0.012, 0.16], [at[0], deckY + 0.006, at[2]])\n  k.torus('boltXylan', od / 2 + 0.006, Math.max(0.004, od * 0.03), [at[0], at[1], at[2]], along === 'x' ? 'x' : 'z', 6, 20, Math.PI)\n  for (const s of [-1, 1]) {\n    const p: V3 = along === 'x' ? [at[0], top + 0.004, at[2] + s * (od / 2 + 0.006)] : [at[0] + s * (od / 2 + 0.006), top + 0.004, at[2]]\n    k.hex('nut', Math.max(0.014, od * 0.12), 0.01, p)\n  }\n}\n\nexport interface VesselDims {\n  Ro: number\n  hd: number\n  /** bottom of the lower head */\n  yBot: number\n  /** top of the upper head */\n  yTop: number\n}\n\n/** Vertical 2:1 ellipsoidal-head vessel, bottom tangent line at `yTL`, with girth seams. */\nexport function vVessel(k: Kit, x: number, z: number, yTL: number, id: number, tt: number, wall: number, mat: MatKey = 'equipmentPaint', seg = 48): VesselDims {\n  const Ro = id / 2 + wall\n  const hd = headDepth('2:1-ellipsoidal', id) + wall\n  const prof: [number, number][] = [[0, -hd]]\n  for (let i = 1; i <= 12; i++) {\n    const t = (i / 12) * (Math.PI / 2)\n    prof.push([Ro * Math.sin(t), -hd * Math.cos(t)])\n  }\n  for (let i = 0; i <= 12; i++) {\n    const t = (i / 12) * (Math.PI / 2)\n    prof.push([Ro * Math.cos(t), tt + hd * Math.sin(t)])\n  }\n  k.lathe(mat, prof, [x, yTL, z], 'y', seg)\n  for (const y of [0, tt]) k.torus(mat, Ro + 0.0008, Math.max(0.003, Ro * 0.008), [x, yTL + y, z], 'y', 6, seg)\n  return { Ro, hd, yBot: yTL - hd, yTop: yTL + tt + hd }\n}\n\n/** Angle or pipe legs welded to a vertical shell, with base plates and anchor nuts. */\nexport function vesselLegs(k: Kit, x: number, z: number, Ro: number, yAttach: number, deckY: number, n = 4, mat: MatKey = 'equipmentPaint', phase = Math.PI / 4) {\n  const lr = Math.max(0.03, Ro * 0.12)\n  for (let i = 0; i < n; i++) {\n    const a = phase + (i / n) * Math.PI * 2\n    const px = x + Math.cos(a) * (Ro + lr * 0.6)\n    const pz = z + Math.sin(a) * (Ro + lr * 0.6)\n    k.cyl(mat, lr, yAttach - deckY, [px, (yAttach + deckY) / 2, pz], 'y', 12)\n    k.box(mat, [0.01, Math.min(0.3, (yAttach - deckY) * 0.3), lr * 2.4], [x + Math.cos(a) * (Ro + 0.005), yAttach - 0.1, z + Math.sin(a) * (Ro + 0.005)], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a))\n    const bp = lr * 3.2\n    k.box(mat, [bp, 0.014, bp], [px, deckY + 0.007, pz])\n    for (const s of [-1, 1]) k.hex('nut', 0.026, 0.016, [px + s * bp * 0.32, deckY + 0.022, pz + s * bp * 0.32])\n  }\n}\n\n/** Bunded drip tray: floor plate, upstand rim and a drain coupling on the -X end. */\nexport function dripPan(k: Kit, cx: number, cz: number, sx: number, sz: number, y: number, rim = 0.06, mat: MatKey = 'stainless') {\n  k.box(mat, [sx, 0.005, sz], [cx, y + 0.0025, cz])\n  for (const s of [-1, 1]) {\n    k.box(mat, [sx, rim, 0.004], [cx, y + rim / 2, cz + s * (sz / 2 - 0.002)])\n    k.box(mat, [0.004, rim, sz], [cx + s * (sx / 2 - 0.002), y + rim / 2, cz])\n  }\n  k.cyl(mat, 0.016, 0.06, [cx - sx / 2 - 0.02, y + 0.02, cz], 'x', 10)\n  k.hex('nut', 0.03, 0.02, [cx - sx / 2 - 0.05, y + 0.02, cz], 'x')\n}\n\n/** Brass earth boss on a steel face. */\nexport function earthBoss(k: Kit, pos: V3, n: V3) {\n  k.cyl('brass', 0.014, 0.025, pos, n, 10)\n  k.hex('brass', 0.024, 0.008, addv(pos, n, 0.012), n)\n}\n\n/** Flanged spool joint inside the skid piping (both flanges, gasket, studs), centred at `pos` along `dir`. */\nexport function spoolJoint(k: Kit, pos: V3, dir: V3, nps: number, rating: Rating) {\n  flangedJoint(k, nps, rating, pos, dir)\n  const f = flangeDims(nps, rating)\n  const L = f.hubLength + f.raisedFaceHeight\n  liner(k, nps, addv(pos, dir, -0.00225), dir, L)\n  liner(k, nps, addv(pos, dir, 0.00225), [-dir[0], -dir[1], -dir[2]], L)\n}\n\n/** Curved painted label band on a vertical shell (axis +Y at x, z), centred on the +Z side, `h` tall over `arc` radians. */\nexport function shellLabel(k: Kit, text: string, x: number, y: number, z: number, Ro: number, h: number, arc = 1.1, bg = '#1f5fa0', fg = '#ffffff') {\n  k.add(textMaterial(text, bg, fg, 512, Math.round((512 * h) / (Ro * arc))), new THREE.CylinderGeometry(Ro + 0.003, Ro + 0.003, h, 24, 1, true, -arc / 2, arc), new THREE.Matrix4().makeTranslation(x, y, z))\n}\n\n/** Open mouth where a branch meets a run or shell: dark bore disc on the surface at `at` facing `dir`, plus the fillet weld. */\nexport function branchMouth(k: Kit, at: V3, dir: V3, nps: number, wall = 0.006) {\n  const r = pipeOD(nps) / 2\n  k.disc('pipeBore', r - wall, addv(at, dir, 0.0012), dir, 24)\n  k.torus('flangeSteel', r + 0.001, Math.max(0.003, r * 0.06), addv(at, dir, 0.002), dir, 6, 24)\n}\n\n/** Bow shackles with screw pins through the four lib `skid()` padeyes (skid of length L, width W, deck top `deckY`). */\nexport function padeyeShackles(k: Kit, L: number, W: number, deckY: number) {\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      const x = sx * (L / 2 - 0.25)\n      const z = sz * (W / 2 - 0.03)\n      const hy = deckY + 0.078\n      k.rod('galvanised', [x, hy, z - 0.05], [x, hy, z + 0.05], 0.011, 10)\n      k.hex('nut', 0.03, 0.016, [x, hy, z - 0.055], 'z')\n      k.cyl('galvanised', 0.02, 0.012, [x, hy, z + 0.055], 'z', 12)\n      k.tube('actuatorRed', [[x, hy, z - 0.042], [x, hy + 0.11, z - 0.042], [x, hy + 0.11, z + 0.042], [x, hy, z + 0.042]], 0.011, 8, 0.04)\n    }\n}\n\n/** Basket strainer on a line along +X centred at `pos` (flanged ends are spool joints at ±len/2), with pot, cover, drain and DP gauge. */\nexport function basketStrainer(k: Kit, pos: V3, nps: number, rating: Rating, len: number, mat: MatKey = 'valveBody') {\n  const D = pipeOD(nps)\n  const f = flangeDims(nps, rating)\n  const fl = f.hubLength + f.raisedFaceHeight\n  const [x, y, z] = pos\n  for (const s of [-1, 1]) spoolJoint(k, [x + (s * len) / 2, y, z], [s, 0, 0], nps, rating)\n  const inner = len / 2 - fl\n  k.pipe(mat, [x - inner - 0.002, y, z], [x + inner + 0.002, y, z], D * 1.1, D * 0.05 + 0.004, 28)\n  const R = D * 0.85\n  k.cyl(mat, R, D * 1.9, [x, y + D * 0.15, z], 'y', 32)\n  k.dome(mat, R, [x, y - D * 0.8, z], '-y', Math.PI / 2, 0.6, 32)\n  const yc = y + D * 1.1\n  k.cyl(mat, R + 0.05, 0.05, [x, yc, z], 'y', 32)\n  k.cyl(mat, R + 0.05, 0.04, [x, yc + 0.045, z], 'y', 32)\n  k.around(12, R + 0.025, [x, yc + 0.07, z], 'y', (p) => k.hex('nut', 0.03, 0.02, p))\n  k.box('handwheel', [R * 1.2, 0.025, 0.03], [x, yc + 0.08, z])\n  k.cyl('stainless', 0.012, 0.08, [x, y - D * 0.8 - R * 0.6 - 0.03, z], 'y', 10)\n  k.hex('nut', 0.03, 0.02, [x, y - D * 0.8 - R * 0.6 - 0.08, z])\n  k.tube('tubing', [[x - R * 0.7, y + D * 0.3, z + R * 0.75], [x - R * 0.7, yc + 0.15, z + R + 0.08], [x + R * 0.7, yc + 0.15, z + R + 0.08], [x + R * 0.7, y + D * 0.3, z + R * 0.75]], 0.005, 6, 0.03)\n  gauge(k, [x, yc + 0.2, z + R + 0.1], 'z', 0.1, 'bar', 2)\n}\n\n/** Spring-loaded back-pressure / thermal relief valve body on a line point, bonnet towards `up`. */\nexport function reliefBody(k: Kit, pos: V3, up: V3, s = 0.02) {\n  k.sphere('valveBody', s * 1.2, pos, undefined, 14)\n  k.cyl('actuatorRed', s * 0.7, s * 3, addv(pos, up, s * 2.4), up, 12, s * 0.5)\n  k.cyl('actuatorRed', s * 0.5, s * 0.8, addv(pos, up, s * 4.2), up, 10)\n}\n\n/**\n * Mating pipe flange bolted to a nozzle face that points along `dir` (nozzle drawn with studs: false): gasket, flange\n * facing back, bore liner and through-studs with two nuts. Returns the weld end of the mating flange.\n */\nexport function mateFlange(k: Kit, face: V3, dir: V3, nps: number, rating: Rating): V3 {\n  const f = flangeDims(nps, rating)\n  const g = 0.0045\n  gasket(k, nps, rating, addv(face, dir, g / 2), dir, g)\n  const mf = addv(face, dir, g)\n  const back: V3 = [-dir[0], -dir[1], -dir[2]]\n  blFlange(k, mf, back, nps, rating, false)\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, addv(addv(face, dir, g / 2), dir, -studL / 2), dir, f.boltDia, studL, [f.boltDia * 0.95, studL - f.boltDia * 0.95])\n  return addv(mf, dir, f.hubLength + f.raisedFaceHeight)\n}\n\n/** Weld-to-weld length of an in-kit wafer butterfly valve between two weld-neck flanges. */\nexport function butterflySpan(nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  return faceToFace('butterfly-wafer', nps, rating) + 2 * (0.003 + f.hubLength + f.raisedFaceHeight)\n}\n\n/** Wafer butterfly valve clamped between two pipe flanges, centred at `pos` on a line along `dir`; lever, gear or pneumatic rack-and-pinion actuator towards `up`. */\nexport function butterflyValve(k: Kit, pos: V3, dir: V3, nps: number, rating: Rating, up: V3 = [0, 1, 0], op: 'lever' | 'gear' | 'pneumatic' = 'pneumatic') {\n  const f = flangeDims(nps, rating)\n  const w = faceToFace('butterfly-wafer', nps, rating)\n  const D = pipeOD(nps)\n  const fl = f.hubLength + f.raisedFaceHeight\n  k.with(frame(pos, dir, up), () => {\n    for (const s of [-1, 1]) {\n      const face: V3 = [s * (w / 2 + 0.003), 0, 0]\n      flange(k, nps, rating, face, [-s, 0, 0], false)\n      liner(k, nps, face, [-s, 0, 0], fl)\n      gasket(k, nps, rating, [s * (w / 2 + 0.0015), 0, 0], 'x', 0.003)\n    }\n    const ri = D / 2 - 0.004\n    k.lathe('valveBody', [[ri, -w / 2], [f.raisedFace / 2 + 0.01, -w / 2], [f.raisedFace / 2 + 0.01, w / 2], [ri, w / 2]], [0, 0, 0], 'x', 32)\n    k.lathe('pipeBore', [[ri, w / 2], [ri, -w / 2]], [0, 0, 0], 'x', 24)\n    k.box('stainless', [0.006, 2 * ri, 2 * ri * 0.15], [0, 0, 0]) // disc edge-on (open)\n    const studL = w + 2 * (f.thickness + f.raisedFaceHeight) + f.boltDia * 3.2\n    k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, [-studL / 2, 0, 0], 'x', f.boltDia, studL, [f.boltDia * 0.95, studL - f.boltDia * 0.95])\n    const yN = f.raisedFace / 2 + 0.01\n    k.cyl('valveBody', Math.max(0.018, D * 0.14), 0.09 + D * 0.2, [0, yN + (0.09 + D * 0.2) / 2, 0], 'y', 16)\n    const yT = yN + 0.09 + D * 0.2\n    k.box('valveBody', [0.1 + D * 0.3, 0.012, 0.1 + D * 0.3], [0, yT, 0])\n    if (op === 'pneumatic') {\n      const s = THREE.MathUtils.clamp(D * 2.2, 0.18, 0.6)\n      k.rbox('actuatorYellow', [s * 0.45, s * 0.4, s * 1.2], s * 0.06, [0, yT + s * 0.2 + 0.006, 0])\n      for (const sz of [-1, 1]) k.cyl('actuatorYellow', s * 0.22, s * 0.03, [0, yT + s * 0.2 + 0.006, sz * s * 0.6], 'z', 18)\n      k.rbox('aluminium', [s * 0.3, s * 0.2, s * 0.3], s * 0.03, [0, yT + s * 0.5, 0]) // limit switch box\n      k.cyl('actuatorRed', s * 0.08, s * 0.05, [0, yT + s * 0.62, 0], 'y', 12)\n    } else if (op === 'gear') {\n      const gb = Math.max(0.1, D * 0.6)\n      k.rbox('valveBody', [gb, gb * 0.6, gb * 0.8], gb * 0.08, [0, yT + gb * 0.3, 0])\n      k.handwheel('handwheel', Math.max(0.25, gb * 1.8), [0, yT + gb * 0.3, gb * 0.7], 'z', 5)\n    } else k.box('actuatorRed', [0.04, 0.02, Math.max(0.2, D * 1.6)], [0, yT + 0.02, Math.max(0.1, D * 0.8) - 0.03])\n  })\n  return w + 2 * (0.003 + fl)\n}\n"
    }
  ],
  "categories": [
    "packages"
  ],
  "meta": {
    "export": "TegRegeneration",
    "anchor": "base",
    "standards": [
      "API 12GDU",
      "ASME BPVC VIII-1",
      "ASME B16.5",
      "ISO 14122"
    ]
  }
}