{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cartridge-filter",
  "type": "registry:component",
  "title": "Cartridge filter / coalescer housing",
  "description": "Vertical cartridge filter or coalescer housing on pipe legs. Davit-hinged top closure: hinged swing-bolt cover, quick-opening locking-band door with pressure-warning lock, or a bolted body flange, all with a davit lifting screw. Inlet below the tube sheet and outlet above it, PDI with isolation valves across the elements, vent and drain, nameplate and earth boss. Also covers basket strainers and filter housings.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "vessels/cartridge-filter.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/vessels/cartridge-filter.tsx",
      "content": "// Vertical cartridge filter / coalescer housing on legs: davit-hinged swing-bolt, quick-opening or bolted top closure, inlet below and outlet above the tube sheet, DP indicator, vent and drain. ASME VIII-1, ASME B16.5.\nimport * as THREE from 'three'\nimport { Kit, mat4, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { liftingLug } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { headDepth, nozzleStandout, shellThickness, type Rating } from '../lib/standards'\nimport { dialMaterial, textMaterial } from '../lib/textures'\nimport { at, deg, holeR, ringSector, type Hole, dirOf, earthBoss, legs, nameplate, projector, shell, shellNozzle, smallValve, smoothLathe, vesselProfile, capNps } from './_vessel'\n\nexport interface CartridgeFilterProps {\n  /** inside diameter, m */\n  diameter?: number\n  /** tangent-to-tangent height, m */\n  height?: number\n  /** design pressure, barg */\n  pressure?: number\n  rating?: Rating\n  inletNps?: number\n  outletNps?: number\n  closure?: 'swing-bolt' | 'quick-opening' | 'bolted'\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  diameter: 0.6,\n  height: 1.6,\n  pressure: 20,\n  rating: 300 as Rating,\n  inletNps: 4,\n  outletNps: 4,\n  closure: 'swing-bolt' as 'swing-bolt' | 'quick-opening' | 'bolted',\n  tag: 'F-200',\n  paint: 'equipmentPaint' as MatKey,\n}\n\ntype P = typeof defaults\n\n/** Nozzles kept in proportion to the shell (typical upper bounds). */\nconst fit = (p: P): P => ({ ...p, inletNps: capNps(p.inletNps, p.diameter * 0.5), outletNps: capNps(p.outletNps, p.diameter * 0.5) })\n\nconst A = { inlet: deg(180), outlet: deg(0), vent: deg(90), davit: deg(135), dp: deg(-90) }\n\nfunction layout(p: P) {\n  const wall = shellThickness(p.pressure, p.diameter)\n  const Ro = p.diameter / 2 + wall\n  const bo = headDepth('2:1-ellipsoidal', p.diameter) + wall\n  const so = (nps: number) => nozzleStandout(nps, p.rating)\n  const e0 = bo + so(1) + 0.45\n  const e1 = e0 + p.height\n  const yIn = e0 + Math.max(0.25, p.height * 0.12) // dirty side below the tube sheet, elements flow inside-out\n  const yOut = e1 - Math.max(0.3, p.height * 0.22)\n  const yVent = e1 - 0.12\n  return { wall, Ro, bo, so, e0, e1, yIn, yOut, yVent }\n}\n\nexport function ports(props: CartridgeFilterProps = {}): Port[] {\n  const p = fit({ ...defaults, ...props } as P)\n  const L = layout(p)\n  return [\n    port('inlet', 'Inlet', at(A.inlet, L.Ro + L.so(p.inletNps), L.yIn), dirOf(A.inlet), p.inletNps, p.rating),\n    port('outlet', 'Outlet', at(A.outlet, L.Ro + L.so(p.outletNps), L.yOut), dirOf(A.outlet), p.outletNps, p.rating),\n    port('vent', 'Vent', at(A.vent, L.Ro + L.so(1), L.yVent), dirOf(A.vent), 1, p.rating, 'vent'),\n    port('drain', 'Drain', [0, L.e0 - L.bo - L.so(1), 0], [0, -1, 0], 1, p.rating, 'drain'),\n  ]\n}\n\nexport function CartridgeFilter(props: CartridgeFilterProps & GroupProps) {\n  const { diameter, height, pressure, rating, inletNps, outletNps, closure, tag, paint, ...group } = { ...defaults, ...props }\n  const p = fit({ diameter, height, pressure, rating, inletNps, outletNps, closure, tag, paint } as P)\n  const data = useKit((k) => build(k, p), Object.values(p))\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default CartridgeFilter\n\nfunction build(k: Kit, p: P) {\n  const L = layout(p)\n  const { Ro, bo, so, e0, e1 } = L\n  const m = p.paint\n  const proj = projector('y', [0, (e0 + e1) / 2, 0], p.height / 2, Ro, bo)\n  const holes: Hole[] = [\n    { p: at(A.inlet, Ro, L.yIn), d: dirOf(A.inlet), r: holeR(p.inletNps) },\n    { p: at(A.outlet, Ro, L.yOut), d: dirOf(A.outlet), r: holeR(p.outletNps) },\n    { p: at(A.vent, Ro, L.yVent), d: dirOf(A.vent), r: holeR(1) },\n    { p: [0, e0 - bo, 0], d: [0, -1, 0], r: holeR(1) },\n  ]\n  shell(k, { Ro, bo, e0, e1, mat: m, top: false, seg: 56, course: 1.8, holes })\n  shellNozzle(k, { base: at(A.inlet, Ro, L.yIn), dir: dirOf(A.inlet), nps: p.inletNps, rating: p.rating, standout: so(p.inletNps), mat: m, proj })\n  shellNozzle(k, { base: at(A.outlet, Ro, L.yOut), dir: dirOf(A.outlet), nps: p.outletNps, rating: p.rating, standout: so(p.outletNps), mat: m, proj })\n  shellNozzle(k, { base: at(A.vent, Ro, L.yVent), dir: dirOf(A.vent), nps: 1, rating: p.rating, standout: so(1), mat: m })\n  shellNozzle(k, { base: [0, e0 - bo, 0], dir: [0, -1, 0], nps: 1, rating: p.rating, standout: so(1), mat: m })\n  legs(k, m, Ro, e0, proj, 4, deg(45))\n  earthBoss(k, m, at(deg(-60), Ro, e0 + 0.15), dirOf(deg(-60)))\n  nameplate(k, m, deg(-40), Ro, e0 + p.height * 0.45, p.tag, 0.6)\n  for (const a of [deg(60), deg(240)]) liftingLug(k, at(a, Ro + 0.005, e1 - 0.18), Math.abs(Math.cos(a)) > 0.7 ? 'z' : 'x', 0.8, m)\n\n  // DP indicator across the elements, on a bracket at the front\n  const g = at(A.dp, Ro + 0.2, (L.yIn + L.yOut) / 2)\n  const qd = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(...dirOf(A.dp)))\n  k.box(m, [0.02, 0.14, 0.18], at(A.dp, Ro + 0.09, g[1]), qd)\n  k.cyl('stainless', 0.075, 0.06, g, dirOf(A.dp), 24)\n  k.add(dialMaterial('bar', 2), new THREE.CircleGeometry(0.066, 24), mat4(at(A.dp, Ro + 0.231, g[1]), qd))\n  k.disc('glass', 0.07, at(A.dp, Ro + 0.234, g[1]), dirOf(A.dp), 24)\n  k.box(textMaterial('PDI', '#f2f2ee', '#111', 128, 64), [0.07, 0.035, 0.004], at(A.dp, Ro + 0.2, g[1] - 0.11), qd)\n  for (const [y, s] of [[L.yIn, 1], [L.yOut, -1]] as const) {\n    const a = A.dp + s * 0.35\n    const t0 = at(a, Ro - 0.005, y)\n    const t1 = at(a, Ro + 0.08, y)\n    k.pipe(m, t0, t1, 0.027, 0.004)\n    smallValve(k, at(a, Ro + 0.12, y), dirOf(a), [0, 1, 0], 0.025)\n    k.tube('tubing', [at(a, Ro + 0.16, y), at(a, Ro + 0.2, y), at(a, Ro + 0.2, g[1] + s * 0.03), at(A.dp, Ro + 0.2, g[1] + s * 0.03)], 0.005, 8, 0.03)\n  }\n\n  closure(k, p, Ro, e1, m)\n}\n\n/** Top closure: forged hub with locking band and dished door (quick-opening), or a body-flange pair with studs and a dished cover; both on a davit with a lifting screw. */\nfunction closure(k: Kit, p: P, Ro: number, e1: number, m: MatKey) {\n  const quick = p.closure === 'quick-opening'\n  const Rh = Ro + (quick ? 0.06 : p.closure === 'swing-bolt' ? 0.05 : 0.11)\n  const hubL = 0.12\n  const yF = e1 + hubL\n  const tD = quick ? 0.06 : 0.07\n  k.lathe(m, [[Ro - 0.02, -0.06], [Ro, -0.06], [Rh, quick ? 0.03 : hubL - 0.07], [Rh, hubL], [Ro - 0.03, hubL], [Ro - 0.02, -0.06]], [0, e1, 0], 'y', 56)\n  k.lathe('flangeSteel', [[Rh, 0], [Rh, tD], [Ro - 0.02, tD], [Ro - 0.02, 0], [Rh, 0]], [0, yF, 0], 'y', 56)\n  const db = 0.2 * p.diameter\n  k.add(m, smoothLathe(vesselProfile(Ro + 0.01, db, 0, 0, 18, false, true), 48), mat4([0, yF + tD, 0]))\n  if (quick) {\n    k.lathe('flangeSteel', [[Rh + 0.03, -0.04], [Rh + 0.03, tD + 0.03], [Rh - 0.005, tD + 0.03], [Rh - 0.005, -0.04], [Rh + 0.03, -0.04]], [0, yF, 0], 'y', 56)\n    const c = at(deg(-90), Rh + 0.06, yF + tD / 2)\n    for (const s of [-1, 1]) k.box('flangeSteel', [0.04, 0.08, 0.1], [c[0] + s * 0.04, c[1], c[2]])\n    k.rod('boltXylan', [c[0] - 0.11, c[1], c[2] + 0.01], [c[0] + 0.11, c[1], c[2] + 0.01], 0.011, 8)\n    for (const s of [-1, 1]) k.hex('nut', 0.036, 0.022, [c[0] + s * 0.075, c[1], c[2] + 0.01], 'x')\n    k.box('brass', [0.05, 0.05, 0.05], [c[0], c[1] + 0.07, c[2]])\n  } else if (p.closure === 'swing-bolt') {\n    // hinged eyebolts on lug pairs under the body flange, swinging up into slotted lugs on the cover flange\n    const nb = Math.max(6, 2 * Math.round((Math.PI * 2 * Rh) / 0.5))\n    for (let i = 0; i < nb; i++) {\n      const a = ((i + 0.5) / nb) * Math.PI * 2\n      const d = dirOf(a)\n      const lat: V3 = [-d[2], 0, d[0]]\n      const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n      const pin = at(a, Rh + 0.035, yF - 0.05)\n      for (const s of [-1, 1]) k.box(m, [0.07, 0.07, 0.012], [pin[0] + lat[0] * s * 0.022, pin[1] + 0.01, pin[2] + lat[2] * s * 0.022], q)\n      k.rod('boltXylan', [pin[0] - lat[0] * 0.03, pin[1], pin[2] - lat[2] * 0.03], [pin[0] + lat[0] * 0.03, pin[1], pin[2] + lat[2] * 0.03], 0.008, 8)\n      k.rod('boltXylan', pin, [pin[0], yF + tD + 0.08, pin[2]], 0.011, 8)\n      k.box('flangeSteel', [0.05, tD, 0.05], at(a, Rh + 0.025, yF + tD / 2), q)\n      k.hex('nut', 0.036, 0.022, [pin[0], yF + tD + 0.015, pin[2]])\n      k.cyl('nut', 0.026, 0.005, [pin[0], yF + tD + 0.002, pin[2]], 'y', 12)\n    }\n    ringSector(k, 'gasket', Ro - 0.03, Rh - 0.004, yF, yF + 0.004, 0, Math.PI * 2, 48)\n  } else {\n    const nb = Math.max(8, 4 * Math.round((Math.PI * 2 * (Ro + 0.06)) / 0.4))\n    k.boltCircle('boltXylan', 'nut', nb, Ro + 0.065, [0, yF - 0.09, 0], 'y', 0.024, 0.06 + tD + 0.11, [0.02, 0.06 + tD + 0.07])\n    ringSector(k, 'gasket', Ro - 0.03, Rh - 0.004, yF, yF + 0.004, 0, Math.PI * 2, 48)\n  }\n  // davit: post in two sleeves on the shell, arm over the cover, lifting screw with handwheel\n  const d = dirOf(A.davit)\n  const zP = Rh + 0.16\n  const P0 = at(A.davit, zP, 0)\n  for (const y of [e1 - 0.35, e1 - 0.05]) {\n    k.box('equipmentPaintDark', [zP - Ro + 0.02, 0.05, 0.08], at(A.davit, (zP + Ro) / 2, y), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), A.davit))\n    k.cyl('equipmentPaintDark', 0.045, 0.07, [P0[0], y, P0[2]], 'y', 16)\n  }\n  const yArm = yF + tD + db + 0.22\n  k.rod('equipmentPaintDark', [P0[0], e1 - 0.42, P0[2]], [P0[0], yArm + 0.04, P0[2]], 0.03, 12)\n  k.rod('equipmentPaintDark', [P0[0], yArm, P0[2]], [0, yArm, 0], 0.028, 12)\n  k.rod('equipmentPaintDark', [P0[0], yArm - 0.3, P0[2]], [P0[0] - d[0] * zP * 0.45, yArm - 0.02, P0[2] - d[2] * zP * 0.45], 0.016, 8)\n  k.rod('stainless', [0, yF + tD + db - 0.01, 0], [0, yArm + 0.14, 0], 0.016, 10)\n  k.cyl('equipmentPaintDark', 0.05, 0.05, [0, yF + tD + db + 0.02, 0], 'y', 16)\n  k.cyl('equipmentPaintDark', 0.04, 0.06, [0, yArm, 0], 'y', 16)\n  k.handwheel('handwheel', 0.22, [0, yArm + 0.12, 0], 'y', 4)\n}\n"
    },
    {
      "path": "vessels/_vessel.ts",
      "type": "registry:file",
      "target": "components/deck-twin/vessels/_vessel.ts",
      "content": "// Shared vessel geometry: smooth 2:1 shells, conforming pads, skirts, legs, ring platforms, caged ladders, davits, bridles, nameplates.\nimport * as THREE from 'three'\nimport { Kit, mat4, type MatKey, type Profile, type V3 } from '../lib/kit'\nimport { flange, gasket, ladder, member, nozzle, pipeRun, tagPlate } from '../lib/parts'\nimport { TessellateModifier } from 'three/examples/jsm/modifiers/TessellateModifier.js'\nimport { gratingMaterial, levelFlagMaterial } from '../lib/textures'\nimport { ACCESS, NPS_SIZES, elbow90, flange as flangeDims, pipeOD, pipeWall, psv as psvDims, type Orifice, type Rating } from '../lib/standards'\n\nexport const TAU = Math.PI * 2\nexport const deg = (d: number) => (d * Math.PI) / 180\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n/** Azimuth a (rad) measured from +X towards -Z, seen from above. Front (+Z) is -90°. */\nexport const dirOf = (a: number): V3 => [Math.cos(a), 0, -Math.sin(a)]\nexport const at = (a: number, r: number, y: number): V3 => [Math.cos(a) * r, y, -Math.sin(a) * r]\n/** Kit lathe phi for azimuth a (lathe puts phi = 0 on +Z). */\nexport const phiOf = (a: number) => a + Math.PI / 2\nexport const add = (p: V3, d: V3, s = 1): V3 => [p[0] + d[0] * s, p[1] + d[1] * s, p[2] + d[2] * s]\nconst V = (p: V3) => new THREE.Vector3(...p)\n\n// ------------------------------------------------------------------ smooth lathe\n\n/** Lathe about +Y with normals smoothed across joints shallower than `crease` degrees (curved heads without banding). */\nexport function smoothLathe(profile: Profile, seg = 48, phiStart = 0, phiLength = TAU, crease = 30): THREE.BufferGeometry {\n  type S = { r0: number; y0: number; r1: number; y1: number; nr: number; ny: number; l: number }\n  const segs: S[] = []\n  for (let i = 0; i < profile.length - 1; i++) {\n    const [r0, y0] = profile[i]\n    const [r1, y1] = profile[i + 1]\n    const l = Math.hypot(r1 - r0, y1 - y0)\n    if (l > 1e-7) segs.push({ r0, y0, r1, y1, nr: (y1 - y0) / l, ny: -(r1 - r0) / l, l })\n  }\n  const cosC = Math.cos((crease * Math.PI) / 180)\n  const smooth = (a?: S, b?: S): [number, number] | null => {\n    if (!a || !b || Math.abs(a.r1 - b.r0) > 1e-6 || Math.abs(a.y1 - b.y0) > 1e-6 || a.nr * b.nr + a.ny * b.ny < cosC) return null\n    const l = Math.hypot(a.nr + b.nr, a.ny + b.ny) || 1\n    return [(a.nr + b.nr) / l, (a.ny + b.ny) / l]\n  }\n  const pos: number[] = []\n  const nor: number[] = []\n  const uv: number[] = []\n  const idx: number[] = []\n  let vb = 0\n  let vAcc = 0\n  segs.forEach((s, i) => {\n    const nS = smooth(segs[i - 1], s) ?? [s.nr, s.ny]\n    const nE = smooth(s, segs[i + 1]) ?? [s.nr, s.ny]\n    for (let k = 0; k <= seg; k++) {\n      const phi = phiStart + (k / seg) * phiLength\n      const sn = Math.sin(phi)\n      const cs = Math.cos(phi)\n      pos.push(s.r0 * sn, s.y0, s.r0 * cs, s.r1 * sn, s.y1, s.r1 * cs)\n      nor.push(nS[0] * sn, nS[1], nS[0] * cs, nE[0] * sn, nE[1], nE[0] * cs)\n      uv.push(k / seg, vAcc, k / seg, vAcc + s.l)\n    }\n    for (let k = 0; k < seg; k++) {\n      const a = vb + k * 2\n      idx.push(a, a + 2, a + 1, a + 1, a + 2, a + 3)\n    }\n    vb += (seg + 1) * 2\n    vAcc += s.l\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.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))\n  g.setIndex(idx)\n  return g\n}\n\n/** 2:1 vessel outline (r, y) from the bottom pole to the top pole; b = 0 gives flat ends. */\nexport function vesselProfile(a: number, b: number, y0: number, y1: number, n = 28, bottom = true, top = true): Profile {\n  const p: Profile = []\n  if (bottom) for (let i = 0; i <= n; i++) {\n    const t = (Math.PI / 2) * (1 - i / n)\n    p.push([a * Math.cos(t), y0 - b * Math.sin(t)])\n  }\n  else p.push([0, y0], [a, y0])\n  if (top) for (let i = 0; i <= n; i++) {\n    const t = (Math.PI / 2) * (i / n)\n    p.push([a * Math.cos(t), y1 + b * Math.sin(t)])\n  }\n  else p.push([a, y1], [0, y1])\n  return p\n}\n\n/** Closed rectangular ring section, optionally a sector (azimuths a0 → a0 + len). */\nexport function ringSector(k: Kit, mat: MatKey, r0: number, r1: number, y0: number, y1: number, a0 = 0, len = TAU, seg = 48) {\n  k.lathe(mat, [[r1, y0], [r1, y1], [r0, y1], [r0, y0], [r1, y0]], [0, 0, 0], 'y', Math.max(4, Math.round((seg * len) / TAU)), phiOf(a0), len)\n}\n\n/** Raised weld bead ring on a vertical shell of radius R at height y. */\nexport function seam(k: Kit, mat: MatKey, R: number, y: number, seg = 64, axis: 'x' | 'y' = 'y', c: V3 = [0, 0, 0]) {\n  const w = 0.022\n  const h = 0.003\n  const g = smoothLathe([[R - 0.002, y - w / 2], [R + h * 0.7, y - w / 4], [R + h, y], [R + h * 0.7, y + w / 4], [R - 0.002, y + w / 2]], seg, 0, TAU, 60)\n  k.add(mat, g, mat4(c, axis))\n}\n\n/** Opening cut into a skin: base point on the surface (world), outward nozzle axis, cut radius (inside the neck wall). */\nexport interface Hole {\n  p: V3\n  d: V3\n  r: number\n}\n\n/** Cut radius for a flanged nozzle neck: midway through the Sch 80 wall so the cut edge hides inside the neck. */\nexport const holeR = (nps: number) => pipeOD(nps) / 2 - pipeWall(nps, '80') / 2\n\nexport interface SkinOpts {\n  Ro: number\n  /** head depth (0 = open end) */\n  bo: number\n  e0: number\n  e1: number\n  top?: boolean\n  bottom?: boolean\n  holes?: Hole[]\n  axis?: 'x' | 'y'\n  centre?: V3\n  seg?: number\n}\n\ninterface SideWin {\n  a: number\n  y: number\n  r: number\n  h: number\n  da: number\n}\n\nconst wrap = (a: number) => ((a % TAU) + TAU) % TAU\nconst angIn = (t: number, a: number, da: number) => Math.abs(Math.atan2(Math.sin(t - a), Math.cos(t - a))) < da - 1e-9\n\n/** Vessel skin (cylinder + 2:1 heads) with real openings at every nozzle. Returns the geometry in the local frame and its matrix. */\nexport function skinGeometry(o: SkinOpts) {\n  const seg = o.seg ?? 72\n  const M = o.axis === 'x' ? mat4(o.centre ?? [0, 0, 0], 'x') : mat4(o.centre ?? [0, 0, 0])\n  const Mi = M.clone().invert()\n  const { Ro, bo, e0, e1 } = o\n  const side: SideWin[] = []\n  const caps: { top: { x: number; z: number; r: number }[]; bottom: { x: number; z: number; r: number }[] } = { top: [], bottom: [] }\n  for (const h of o.holes ?? []) {\n    const p = V(h.p).applyMatrix4(Mi)\n    const d = V(h.d).transformDirection(Mi)\n    if (Math.abs(d.y) < 0.5) {\n      const hh = h.r * 1.25 + 0.015\n      const y0 = Math.max(e0 + 0.002, p.y - hh)\n      const y1 = Math.min(e1 - 0.002, p.y + hh)\n      if (p.y - h.r < e0 || p.y + h.r > e1) continue\n      side.push({ a: wrap(Math.hypot(p.x, p.z) > 0.5 * Ro ? Math.atan2(-p.z, p.x) : Math.atan2(-d.z, d.x)), y: p.y, r: h.r, h: Math.min(p.y - y0, y1 - p.y), da: Math.asin(Math.min(0.9, hh / Ro)) })\n    } else (d.y > 0 ? caps.top : caps.bottom).push({ x: p.x, z: p.z, r: h.r })\n  }\n  // global angle list: uniform + every window edge, so neighbouring pieces share vertices\n  const G = Array.from({ length: seg }, (_, i) => (i / seg) * TAU)\n  for (const w of side) G.push(wrap(w.a - w.da), wrap(w.a + w.da))\n  G.sort((a, b) => a - b)\n  const A = G.filter((a, i) => i === 0 || a - G[i - 1] > 1e-6)\n  const pos: number[] = []\n  const nor: number[] = []\n  const idx: number[] = []\n  const vtx = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => {\n    pos.push(x, y, z)\n    nor.push(nx, ny, nz)\n    return pos.length / 3 - 1\n  }\n  const cyl = (t: number, y: number) => vtx(Ro * Math.cos(t), y, -Ro * Math.sin(t), Math.cos(t), 0, -Math.sin(t))\n  // cylinder bands\n  const ys = new Set<number>([e0, e1])\n  for (const w of side) ys.add(w.y - w.h).add(w.y + w.h)\n  const band = [...ys].sort((a, b) => a - b)\n  for (let b = 0; b < band.length - 1; b++) {\n    const y0 = band[b]\n    const y1 = band[b + 1]\n    if (y1 - y0 < 1e-6) continue\n    const ym = (y0 + y1) / 2\n    const act = side.filter((w) => Math.abs(ym - w.y) < w.h)\n    for (let i = 0; i < A.length; i++) {\n      const t0 = A[i]\n      const t1 = i + 1 < A.length ? A[i + 1] : A[0] + TAU\n      if (act.some((w) => angIn((t0 + t1) / 2, w.a, w.da))) continue\n      const a0 = cyl(t0, y0)\n      const a1 = cyl(t1, y0)\n      const a2 = cyl(t1, y1)\n      const a3 = cyl(t0, y1)\n      idx.push(a0, a1, a2, a0, a2, a3)\n    }\n  }\n  // window patches: unrolled rectangle with the bore curve as a hole, tessellated, wrapped back onto the cylinder\n  const patch = (shape: THREE.Shape, maxEdge: number, map: (u: number, v: number) => [THREE.Vector3, THREE.Vector3]) => {\n    let g: THREE.BufferGeometry = new THREE.ShapeGeometry(shape, 1)\n    g = new TessellateModifier(maxEdge, 10).modify(g)\n    const P = g.getAttribute('position')\n    const base = pos.length / 3\n    for (let i = 0; i < P.count; i++) {\n      const [q, n] = map(P.getX(i), P.getY(i))\n      vtx(q.x, q.y, q.z, n.x, n.y, n.z)\n    }\n    for (let i = 0; i < P.count; i += 3) {\n      const a = V([pos[(base + i) * 3], pos[(base + i) * 3 + 1], pos[(base + i) * 3 + 2]])\n      const b = V([pos[(base + i + 1) * 3], pos[(base + i + 1) * 3 + 1], pos[(base + i + 1) * 3 + 2]])\n      const c = V([pos[(base + i + 2) * 3], pos[(base + i + 2) * 3 + 1], pos[(base + i + 2) * 3 + 2]])\n      const n = b.sub(a).cross(c.sub(a))\n      const e = V([nor[(base + i) * 3], nor[(base + i) * 3 + 1], nor[(base + i) * 3 + 2]])\n      if (n.dot(e) < 0) idx.push(base + i, base + i + 2, base + i + 1)\n      else idx.push(base + i, base + i + 1, base + i + 2)\n    }\n    g.dispose()\n  }\n  for (const w of side) {\n    const un = (t: number) => Ro * Math.atan2(Math.sin(t - w.a), Math.cos(t - w.a))\n    const inner = A.filter((t) => angIn(t, w.a, w.da + 1e-6)).map(un).sort((a, b) => a - b)\n    const U = Ro * w.da\n    const xs = [-U, ...inner.filter((u) => u > -U + 1e-6 && u < U - 1e-6), U]\n    const nv = Math.max(2, Math.ceil((2 * w.h) / (U / Math.max(1, xs.length - 1) + 0.02)))\n    const sh = new THREE.Shape()\n    sh.moveTo(xs[0], -w.h)\n    for (const u of xs.slice(1)) sh.lineTo(u, -w.h)\n    for (let j = 1; j <= nv; j++) sh.lineTo(U, -w.h + (2 * w.h * j) / nv)\n    for (const u of [...xs].reverse().slice(1)) sh.lineTo(u, w.h)\n    for (let j = 1; j < nv; j++) sh.lineTo(-U, w.h - (2 * w.h * j) / nv)\n    sh.closePath()\n    const hole = new THREE.Path()\n    const nh = 40\n    for (let j = 0; j <= nh; j++) {\n      const f = (j / nh) * TAU\n      const u = Ro * Math.asin(Math.min(1, (w.r * Math.cos(f)) / Ro))\n      if (j === 0) hole.moveTo(u, w.r * Math.sin(f))\n      else hole.lineTo(u, w.r * Math.sin(f))\n    }\n    sh.holes.push(hole)\n    let maxSeg = 0\n    for (let j = 1; j < xs.length; j++) maxSeg = Math.max(maxSeg, xs[j] - xs[j - 1])\n    patch(sh, Math.max(maxSeg, (2 * w.h) / nv) * 1.05, (u, v) => {\n      const t = w.a + u / Ro\n      return [V([Ro * Math.cos(t), w.y + v, -Ro * Math.sin(t)]), V([Math.cos(t), 0, -Math.sin(t)])]\n    })\n  }\n  // heads: knuckle ring as a lathe on the shared angles, crown as a tessellated height field with the holes\n  const head = (sgn: 1 | -1, list: { x: number; z: number; r: number }[]) => {\n    const y0 = sgn > 0 ? e1 : e0\n    if (bo <= 0) return\n    const fits = list.filter((h) => Math.hypot(h.x, h.z) + h.r < 0.9 * Ro)\n    const rCut = clamp(Math.max(0.6 * Ro, ...fits.map((h) => Math.hypot(h.x, h.z) + h.r + 0.04)), 0.6 * Ro, 0.92 * Ro)\n    const nk = 14\n    const tc = Math.acos(rCut / Ro)\n    const ell = (t: number, a: number) => {\n      const r = Ro * Math.cos(t)\n      const y = y0 + sgn * bo * Math.sin(t)\n      const n = V([Math.cos(t) / Ro * Math.cos(a), (sgn * Math.sin(t)) / bo, -(Math.cos(t) / Ro) * Math.sin(a)]).normalize()\n      return vtx(r * Math.cos(a), y, -r * Math.sin(a), n.x, n.y, n.z)\n    }\n    for (let j = 0; j < nk; j++) {\n      const t0 = (tc * j) / nk\n      const t1 = (tc * (j + 1)) / nk\n      for (let i = 0; i < A.length; i++) {\n        const p0 = A[i]\n        const p1 = i + 1 < A.length ? A[i + 1] : A[0] + TAU\n        const a0 = ell(t0, p0)\n        const a1 = ell(t0, p1)\n        const a2 = ell(t1, p1)\n        const a3 = ell(t1, p0)\n        if (sgn > 0) idx.push(a0, a1, a2, a0, a2, a3)\n        else idx.push(a0, a2, a1, a0, a3, a2)\n      }\n    }\n    const sh = new THREE.Shape()\n    A.forEach((t, i) => (i ? sh.lineTo(rCut * Math.cos(t), rCut * Math.sin(t)) : sh.moveTo(rCut * Math.cos(t), rCut * Math.sin(t))))\n    sh.closePath()\n    for (const h of fits) {\n      const hp = new THREE.Path()\n      for (let j = 0; j <= 32; j++) {\n        const f = (j / 32) * TAU\n        const x = h.x + h.r * Math.cos(f)\n        const z = h.z + h.r * Math.sin(f)\n        if (j === 0) hp.moveTo(x, -z)\n        else hp.lineTo(x, -z)\n      }\n      sh.holes.push(hp)\n    }\n    let maxSeg = 0\n    for (let i = 0; i < A.length; i++) maxSeg = Math.max(maxSeg, rCut * ((i + 1 < A.length ? A[i + 1] : A[0] + TAU) - A[i]))\n    patch(sh, maxSeg * 1.05, (sx, sy) => {\n      const x = sx\n      const z = -sy\n      const q = Math.max(0, 1 - (x * x + z * z) / (Ro * Ro))\n      const y = y0 + sgn * bo * Math.sqrt(q)\n      return [V([x, y, z]), V([x / (Ro * Ro), (y - y0) / (bo * bo), z / (Ro * Ro)]).normalize()]\n    })\n  }\n  if (o.top ?? true) head(1, caps.top)\n  if (o.bottom ?? true) head(-1, caps.bottom)\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, M, side }\n}\n\nexport interface ShellOpts {\n  Ro: number\n  bo: number\n  e0: number\n  e1: number\n  mat: MatKey\n  insulated?: boolean\n  /** insulation + cladding thickness, m */\n  ins?: number\n  bottom?: boolean\n  top?: boolean\n  axis?: 'x' | 'y'\n  centre?: V3\n  /** shell course width for seams, m */\n  course?: number\n  seg?: number\n  /** nozzle openings (world frame) */\n  holes?: Hole[]\n}\n\n/** Vessel shell with openings at every nozzle, a dark interior, circumferential + staggered longitudinal seams, or clad insulation with bands. */\nexport function shell(k: Kit, o: ShellOpts) {\n  const seg = o.seg ?? 72\n  const axis = o.axis ?? 'y'\n  const c = o.centre ?? [0, 0, 0]\n  const top = o.top ?? true\n  const bottom = o.bottom ?? true\n  const holes = o.holes ?? []\n  const steel = skinGeometry({ Ro: o.Ro, bo: o.bo, e0: o.e0, e1: o.e1, top, bottom, holes, axis, centre: c, seg })\n  k.add(o.mat, steel.g.clone(), steel.M)\n  k.add('vesselInner', steel.g, steel.M)\n  // open ends get an inside-only cap so the interior reads closed through the nozzles\n  for (const [has, y, s] of [[bottom, o.e0, -1], [top, o.e1, 1]] as const)\n    if (!has || o.bo <= 0) k.with(steel.M, () => k.disc('vesselInner', o.Ro, [0, y, 0], s > 0 ? 'y' : '-y', seg))\n  const gapped = (R: number, y: number, mat: MatKey, wins: SideWin[], rr = R) => {\n    const cut = wins.filter((w) => Math.abs(y - w.y) < w.h + 0.03)\n    if (!cut.length) return seam(k, mat, rr, y, seg, axis, c)\n    const ang = cut.map((w) => [wrap(w.a - w.da - 0.02), 2 * w.da + 0.04] as const).sort((a, b) => a[0] - b[0])\n    for (let i = 0; i < ang.length; i++) {\n      const s0 = ang[i][0] + ang[i][1]\n      const s1 = i + 1 < ang.length ? ang[i + 1][0] : ang[0][0] + TAU\n      if (s1 - s0 > 0.01) k.add(mat, smoothLathe([[rr - 0.002, y - 0.011], [rr + 0.002, y - 0.0055], [rr + 0.003, y], [rr + 0.002, y + 0.0055], [rr - 0.002, y + 0.011]], Math.max(4, Math.round((seg * (s1 - s0)) / TAU)), phiOf(s0), s1 - s0, 60), steel.M)\n    }\n  }\n  if (o.insulated) {\n    const t = o.ins ?? 0.06\n    const jh = holes.map((h) => ({ ...h, r: h.r + 0.012 }))\n    const jacket = skinGeometry({ Ro: o.Ro + t, bo: o.bo + t, e0: o.e0, e1: o.e1, top, bottom, holes: jh, axis, centre: c, seg })\n    k.add('insulation', jacket.g, jacket.M)\n    for (let y = o.e0 + 0.9; y < o.e1 - 0.1; y += 0.9) gapped(o.Ro + t, y, 'insulation', jacket.side)\n    return\n  }\n  const tt = o.e1 - o.e0\n  const n = Math.max(1, Math.round(tt / (o.course ?? 2.4)))\n  for (let i = 0; i <= n; i++) gapped(o.Ro, o.e0 + (i * tt) / n, o.mat, steel.side)\n  for (let i = 0; i < n; i++) {\n    const a = i % 2 ? deg(150) : deg(-30)\n    const y = o.e0 + ((i + 0.5) * tt) / n\n    const hl = tt / n - 0.02\n    if (steel.side.some((w) => angIn(a, w.a, w.da + 0.03) && Math.abs(w.y - y) < hl / 2 + w.h)) continue\n    const pt = at(a, o.Ro + 0.0012, y)\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    k.with(steel.M, () => k.box(o.mat, [0.004, hl, 0.02], pt, q))\n  }\n}\n\n// ------------------------------------------------------------------ surface projection + conforming pads\n\nexport type Projector = (q: THREE.Vector3, d: THREE.Vector3) => number | null\n\nfunction roots(a: number, b: number, c: number): number[] {\n  if (Math.abs(a) < 1e-12) return Math.abs(b) < 1e-12 ? [] : [-c / b]\n  const D = b * b - 4 * a * c\n  if (D < 0) return []\n  const s = Math.sqrt(D)\n  return [(-b - s) / (2 * a), (-b + s) / (2 * a)]\n}\n\n/** Ray solver for a cylinder (radius a, axis x or y through centre, half length halfL) closed by 2:1 heads of depth b. */\nexport function projector(axis: 'x' | 'y', centre: V3, halfL: number, a: number, b: number): Projector {\n  return (q, d) => {\n    const qa = axis === 'x' ? q.x - centre[0] : q.y - centre[1]\n    const da = axis === 'x' ? d.x : d.y\n    const q1 = axis === 'x' ? q.y - centre[1] : q.x - centre[0]\n    const d1 = axis === 'x' ? d.y : d.x\n    const q2 = q.z - centre[2]\n    const d2 = d.z\n    const c: number[] = []\n    for (const t of roots(d1 * d1 + d2 * d2, 2 * (q1 * d1 + q2 * d2), q1 * q1 + q2 * q2 - a * a)) if (Math.abs(qa + t * da) <= halfL + 1e-6) c.push(t)\n    if (b > 0)\n      for (const s of [-1, 1]) {\n        const X = qa - s * halfL\n        const A = (da * da) / (b * b) + (d1 * d1 + d2 * d2) / (a * a)\n        const B = 2 * ((X * da) / (b * b) + (q1 * d1 + q2 * d2) / (a * a))\n        const C = (X * X) / (b * b) + (q1 * q1 + q2 * q2) / (a * a) - 1\n        for (const t of roots(A, B, C)) if (s * (X + t * da) >= -1e-6) c.push(t)\n      }\n    if (!c.length) return null\n    return c.reduce((p, x) => (Math.abs(x) < Math.abs(p) ? x : p))\n  }\n}\n\n/** Reinforcement pad / doubler (annulus rIn…rOut, thickness t) wrapped onto the surface around base along dir. */\nexport function pad(k: Kit, mat: MatKey, proj: Projector, base: V3, dir: V3, rIn: number, rOut: number, t: number, seg = 40) {\n  const d = V(dir).normalize()\n  const u = new THREE.Vector3().crossVectors(d, Math.abs(d.y) < 0.9 ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0)).normalize()\n  const w = new THREE.Vector3().crossVectors(d, u).normalize()\n  const pos: number[] = []\n  const idx: number[] = []\n  const vtx = (p: THREE.Vector3) => (pos.push(p.x, p.y, p.z), pos.length / 3 - 1)\n  const P = (i: number) => new THREE.Vector3(pos[i * 3], pos[i * 3 + 1], pos[i * 3 + 2])\n  const tri = (a: number, b: number, c: number, expect: THREE.Vector3) => {\n    const n = P(b).sub(P(a)).cross(P(c).sub(P(a)))\n    if (n.dot(expect) < 0) idx.push(a, c, b)\n    else idx.push(a, b, c)\n  }\n  const ti: number[] = []\n  const to: number[] = []\n  const rb: number[] = []\n  const rt: number[] = []\n  const ib: number[] = []\n  const it: number[] = []\n  const offs: THREE.Vector3[] = []\n  const B = V(base)\n  for (let i = 0; i <= seg; i++) {\n    const a = (i / seg) * TAU\n    const off = u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a))\n    offs.push(off)\n    const qi = B.clone().addScaledVector(off, rIn)\n    const qo = B.clone().addScaledVector(off, rOut)\n    const si = proj(qi, d)\n    const so = proj(qo, d)\n    if (si === null || so === null) return\n    qi.addScaledVector(d, si)\n    qo.addScaledVector(d, so)\n    ti.push(vtx(qi.clone().addScaledVector(d, t)))\n    to.push(vtx(qo.clone().addScaledVector(d, t)))\n    rt.push(vtx(qo.clone().addScaledVector(d, t)))\n    rb.push(vtx(qo.clone().addScaledVector(d, -0.004)))\n    if (rIn > 0) {\n      it.push(vtx(qi.clone().addScaledVector(d, t)))\n      ib.push(vtx(qi.clone().addScaledVector(d, -0.004)))\n    }\n  }\n  for (let i = 0; i < seg; i++) {\n    tri(ti[i], to[i], to[i + 1], d)\n    tri(ti[i], to[i + 1], ti[i + 1], d)\n    tri(rb[i], rb[i + 1], rt[i + 1], offs[i])\n    tri(rb[i], rt[i + 1], rt[i], offs[i])\n    if (rIn > 0) {\n      const inn = offs[i].clone().negate()\n      tri(ib[i], ib[i + 1], it[i + 1], inn)\n      tri(ib[i], it[i + 1], it[i], inn)\n    }\n  }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  k.add(mat, g)\n}\n\nexport interface ShellNozzle {\n  base: V3\n  dir: V3\n  nps: number\n  rating: Rating\n  standout: number\n  mat: MatKey\n  proj?: Projector\n  insulated?: boolean\n  blind?: boolean\n  /** pad thickness, m (0 = none) */\n  padT?: number\n  /** studs on the flange (false when a joint draws through-studs) */\n  studs?: boolean\n}\n\n/** Flanged nozzle with studs and a conforming reinforcement pad (2× OD, omitted under insulation and below 2\"). */\nexport function shellNozzle(k: Kit, o: ShellNozzle): V3 {\n  const face = nozzle(k, { base: o.base, dir: o.dir, nps: o.nps, rating: o.rating, standout: o.standout, mat: o.mat, blind: o.blind, studs: o.studs ?? !o.blind })\n  if (o.proj && !o.insulated && o.nps >= 2 && (o.padT ?? 0.016) > 0) pad(k, o.mat, o.proj, o.base, o.dir, pipeOD(o.nps) / 2, pipeOD(o.nps), o.padT ?? 0.016, 36)\n  return face\n}\n\n// ------------------------------------------------------------------ supports\n\nexport interface SkirtOpening {\n  a: number\n  y: number\n  /** opening (sleeve) diameter, m */\n  d: number\n  /** access openings get a doubler; pipe sleeves only a short sleeve */\n  access?: boolean\n}\n\n/** Cylindrical skirt from the deck to y = top: openings with sleeves, doublers, vents, base ring and anchor chairs. */\nexport function skirt(k: Kit, mat: MatKey, Ro: number, top: number, ts: number, openings: SkirtOpening[], seg = 72, opts: { bandsOnly?: boolean; y0?: number } = {}) {\n  const yBR = opts.y0 ?? 0.03\n  const ys = new Set<number>([yBR, top])\n  for (const o of openings) {\n    ys.add(o.y - o.d / 2)\n    ys.add(o.y + o.d / 2)\n  }\n  const band = [...ys].filter((y) => y >= yBR && y <= top).sort((a, b) => a - b)\n  for (let i = 0; i < band.length - 1; i++) {\n    const y0 = band[i]\n    const y1 = band[i + 1]\n    const mid = (y0 + y1) / 2\n    const cut = openings\n      .filter((o) => Math.abs(mid - o.y) < o.d / 2)\n      .map((o) => {\n        const h = Math.asin(Math.min(0.95, o.d / 2 / Ro))\n        const s = (((o.a - h) % TAU) + TAU) % TAU\n        return [s, s + 2 * h] as [number, number]\n      })\n      .sort((a, b) => a[0] - b[0])\n    if (!cut.length) {\n      ringSector(k, mat, Ro - ts, Ro, y0, y1, 0, TAU, seg)\n      continue\n    }\n    for (let j = 0; j < cut.length; j++) {\n      const s = cut[j][1]\n      const e = j + 1 < cut.length ? cut[j + 1][0] : cut[0][0] + TAU\n      if (e - s > 1e-3) ringSector(k, mat, Ro - ts, Ro, y0, y1, s, e - s, seg)\n    }\n  }\n  if (opts.bandsOnly) return\n  const sk = projector('y', [0, top / 2, 0], top / 2, Ro, 0)\n  for (const o of openings) {\n    const d = dirOf(o.a)\n    const c = at(o.a, Ro, o.y)\n    const r = o.d / 2\n    const len = o.access ? 0.14 : 0.08\n    k.lathe(mat, [[r, -ts - 0.02], [r, len], [r - 0.012, len], [r - 0.012, -ts - 0.02], [r, -ts - 0.02]], c, d, 32)\n    if (o.access) pad(k, mat, sk, c, d, r, r * 1.45, 0.012, 40)\n  }\n  // vents near the skirt top\n  if (top > 1.2)\n    for (let i = 0; i < 4; i++) {\n      const a = deg(45) + (i * Math.PI) / 2\n      k.lathe(mat, [[0.037, -ts - 0.01], [0.037, 0.05], [0.03, 0.05], [0.03, -ts - 0.01], [0.037, -ts - 0.01]], at(a, Ro, top - 0.2), dirOf(a), 16)\n    }\n  // base ring + anchor chairs\n  ringSector(k, mat, Ro - ts - 0.08, Ro + 0.12, 0, yBR, 0, TAU, seg)\n  const nB = Math.max(4, 4 * Math.ceil((TAU * Ro) / 4.8))\n  const hc = clamp(top * 0.1, 0.16, 0.28)\n  for (let i = 0; i < nB; i++) {\n    const a = ((i + 0.5) / nB) * TAU\n    const d = dirOf(a)\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    for (const s of [-1, 1]) k.box(mat, [0.11, hc, 0.016], add(at(a, Ro + 0.055, yBR + hc / 2), [-d[2], 0, d[0]], s * 0.065), q)\n    k.box(mat, [0.12, 0.022, 0.16], at(a, Ro + 0.06, yBR + hc + 0.011), q)\n    const bp = at(a, Ro + 0.065, 0)\n    k.rod('nut', bp, [bp[0], yBR + hc + 0.1, bp[2]], 0.012, 8)\n    k.hex('nut', 0.042, 0.026, [bp[0], yBR + hc + 0.035, bp[2]])\n    k.cyl('nut', 0.03, 0.006, [bp[0], yBR + hc + 0.025, bp[2]], 'y', 12)\n  }\n}\n\n/** Pipe legs welded tangent to a vertical shell (bottom tangent at e0): pad, base plate, anchor bolts. Returns leg radius. */\nexport function legs(k: Kit, mat: MatKey, Ro: number, e0: number, proj: Projector, n = 4, a0 = deg(45), lr?: number) {\n  const r = lr ?? clamp(0.05 * (Ro / 0.6), 0.045, 0.11)\n  for (let i = 0; i < n; i++) {\n    const a = a0 + (i * TAU) / n\n    const c = at(a, Ro + r - 0.004, 0)\n    const yTop = e0 + clamp(Ro * 0.5, 0.25, 0.6)\n    k.cyl(mat, r, yTop - 0.02, [c[0], 0.02 + (yTop - 0.02) / 2, c[2]], 'y', 20)\n    k.cyl(mat, r + 0.004, 0.012, [c[0], yTop + 0.006, c[2]], 'y', 20)\n    pad(k, mat, proj, at(a, Ro, e0 + (yTop - e0) * 0.45), dirOf(a), 0, r * 2.4, 0.012, 24)\n    const bp = r * 3.2\n    k.box(mat, [bp, 0.02, bp], [c[0], 0.01, c[2]])\n    const d = dirOf(a)\n    const lat: V3 = [-d[2], 0, d[0]]\n    for (const s of [-1, 1]) {\n      k.box(mat, [r * 1.2, r * 1.4, 0.012], add([c[0], 0.02 + r * 0.7, c[2]], lat, s * (r + 0.006)), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a))\n      const b = add(add([c[0], 0, c[2]], d, bp * 0.32), lat, s * bp * 0.32)\n      k.rod('nut', b, [b[0], 0.08, b[2]], 0.009, 8)\n      k.hex('nut', 0.03, 0.018, [b[0], 0.032, b[2]])\n    }\n  }\n  return r\n}\n\n/** Bottom outlet from the bottom head, LR elbow inside the skirt and horizontal run out through a skirt sleeve. Returns the flange face. */\nexport function skirtLine(k: Kit, start: V3, a: number, yRun: number, nps: number, rating: Rating, faceR: number, mat: MatKey = 'equipmentPaint') {\n  const d = dirOf(a)\n  const face = at(a, faceR, yRun)\n  const f = flangeDims(nps, rating)\n  const L = f.hubLength + f.raisedFaceHeight\n  const end = add(face, d, -L)\n  pipeRun(k, [start, [start[0], yRun, start[2]], end], nps, { mat })\n  flange(k, nps, rating, face, d, true)\n  return face\n}\n\n/** Elevation of the run that leaves the skirt: head tip − elbow − straight. */\nexport const skirtRunY = (tipY: number, nps: number) => tipY - elbow90(nps) - 0.12\n\n// ------------------------------------------------------------------ access\n\nexport interface PlatformOpts {\n  y: number\n  /** steel shell radius (brackets weld here) */\n  shellR: number\n  /** inner edge radius (clears insulation) */\n  rIn: number\n  width?: number\n  /** azimuth range, a0 < a1 */\n  a0: number\n  a1: number\n  /** azimuth of a ladder arriving through the outer handrail */\n  gate?: number\n  /** ends left open (stair arrival) */\n  open?: ('a0' | 'a1')[]\n}\n\n/** Bracketed ring platform (grating, edge bars, toe plate, ISO 14122-3 handrail with ladder gate) on a vertical vessel. */\nexport function platform(k: Kit, o: PlatformOpts) {\n  const W = o.width ?? 0.95\n  const rOut = o.rIn + W\n  const len = o.a1 - o.a0\n  const n = Math.max(8, Math.round((len * rOut) / 0.25))\n  const ring = new THREE.RingGeometry(o.rIn, rOut, n, 1, o.a0, len)\n  ring.rotateX(-Math.PI / 2)\n  const p = ring.getAttribute('position') as THREE.BufferAttribute\n  const uv = ring.getAttribute('uv') as THREE.BufferAttribute\n  for (let i = 0; i < uv.count; i++) uv.setXY(i, p.getX(i) / 0.3, p.getZ(i) / 0.3)\n  k.add(gratingMaterial(), ring, mat4([0, o.y, 0]))\n  ringSector(k, 'galvanised', o.rIn, o.rIn + 0.006, o.y - 0.03, o.y, o.a0, len, n)\n  ringSector(k, 'galvanised', rOut - 0.008, rOut, o.y - 0.12, o.y, o.a0, len, n)\n  ringSector(k, 'safetyYellow', rOut - 0.012, rOut - 0.006, o.y, o.y + ACCESS.handrail.toePlate, o.a0, len, n)\n  // brackets: radial beam, knee brace, shell clip\n  const nb = Math.max(2, Math.ceil((len * rOut) / 1.6))\n  for (let i = 0; i <= nb; i++) {\n    const a = o.a0 + (len * i) / nb\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    k.box('galvanised', [rOut - o.shellR, 0.1, 0.05], at(a, (o.shellR + rOut) / 2, o.y - 0.08), q)\n    member(k, at(a, o.shellR + 0.01, o.y - 0.9), at(a, rOut - 0.15, o.y - 0.13), 'L50x50x5', 'galvanised')\n    k.box('galvanised', [0.012, 1.0, 0.14], at(a, o.shellR + 0.006, o.y - 0.5), q)\n  }\n  // handrail\n  const rr = rOut - 0.03\n  const gap = o.gate !== undefined ? 0.33 / rr : 0\n  const spans: [number, number][] = o.gate !== undefined ? [[o.a0, o.gate - gap], [o.gate + gap, o.a1]] : [[o.a0, o.a1]]\n  for (const [s, e] of spans) {\n    if (e - s < 1e-3) continue\n    const m = Math.max(1, Math.ceil(((e - s) * rr) / ACCESS.handrail.postSpacingMax))\n    for (let j = 0; j <= m; j++) {\n      const a = s + ((e - s) * j) / m\n      k.rod('safetyYellow', at(a, rr, o.y), at(a, rr, o.y + ACCESS.handrail.height), 0.024, 8)\n    }\n    const segs = Math.max(4, Math.ceil(((e - s) * rr) / 0.3))\n    for (const h of [ACCESS.handrail.height, ACCESS.handrail.kneeRail]) {\n      const pts: V3[] = []\n      for (let j = 0; j <= segs; j++) pts.push(at(s + ((e - s) * j) / segs, rr, o.y + h))\n      k.tube('safetyYellow', pts, h > 1 ? 0.024 : 0.018, 8, 0.05)\n    }\n  }\n  // end rails / self-closing gates back to the shell\n  for (const a of [o.a0, o.a1]) {\n    if (o.open?.includes(a === o.a0 ? 'a0' : 'a1')) continue\n    k.rod('safetyYellow', at(a, o.rIn + 0.1, o.y), at(a, o.rIn + 0.1, o.y + ACCESS.handrail.height), 0.024, 8)\n    for (const h of [ACCESS.handrail.height, ACCESS.handrail.kneeRail]) k.rod('safetyYellow', at(a, o.rIn + 0.1, o.y + h), at(a, rr, o.y + h), h > 1 ? 0.024 : 0.018, 8)\n  }\n  if (o.gate !== undefined) {\n    const g0 = at(o.gate - gap, rr - 0.04, 0)\n    const g1 = at(o.gate + gap, rr - 0.04, 0)\n    for (const h of [0.5, 1.0]) k.rod('safetyYellow', [g0[0], o.y + h, g0[2]], [g1[0], o.y + h, g1[2]], 0.016, 8)\n    k.rod('safetyYellow', [g1[0], o.y + 0.45, g1[2]], [g1[0], o.y + 1.05, g1[2]], 0.018, 8)\n  }\n  return rOut\n}\n\n/** ISO 14122-4 caged ladder at azimuth a, stiles at radius r, from yFoot up to platform yTop, with wall brackets back to the shell. */\nexport function cagedLadder(k: Kit, a: number, r: number, yFoot: number, yTop: number, shellR: number) {\n  const d = dirOf(a)\n  const foot = at(a, r, yFoot)\n  ladder(k, foot, yTop, d)\n  const lat: V3 = [-d[2], 0, d[0]]\n  for (let y = yFoot + 1.5; y < yTop - 0.4; y += 2.4)\n    for (const s of [-1, 1]) {\n      const p = add([foot[0], y, foot[2]], lat, s * ACCESS.ladder.width * 0.5)\n      const back = add(p, d, -(r - shellR) + 0.004)\n      member(k, p, back, 'L50x50x5', 'galvanised')\n    }\n}\n\n/** Platform-mounted maintenance davit swinging over the top head (socket, mast, arm, brace, hook). */\nexport function topDavit(k: Kit, a: number, Ro: number, yPlat: number, yTip: number) {\n  const m = at(a, Ro + 0.3, 0)\n  k.cyl('safetyYellow', 0.075, 0.4, [m[0], yPlat + 0.2, m[2]], 'y', 20)\n  k.box('galvanised', [0.3, 0.16, 0.02], at(a, Ro + 0.15, yPlat - 0.1), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a))\n  k.cyl('safetyYellow', 0.075, 0.25, [m[0], yPlat - 0.2, m[2]], 'y', 20)\n  k.rod('safetyYellow', [m[0], yPlat - 0.3, m[2]], [m[0], yTip, m[2]], 0.05, 16)\n  const reach = Ro + 0.3\n  const d = dirOf(a)\n  const tip = add([m[0], yTip - 0.04, m[2]], d, -reach * 0.95)\n  k.rod('safetyYellow', [m[0], yTip - 0.03, m[2]], tip, 0.045, 12)\n  k.rod('safetyYellow', [m[0], yTip - 0.6, m[2]], add([m[0], yTip - 0.05, m[2]], d, -reach * 0.4), 0.025, 8)\n  k.rod('nut', add(tip, [0, -1, 0], 0.04), add(tip, [0, -1, 0], 0.45), 0.006, 6)\n  k.torus('nut', 0.035, 0.008, add(tip, [0, -1, 0], 0.5), dirOf(a + Math.PI / 2), 6, 16, Math.PI * 1.5)\n}\n\n/** ASME nameplate on standoff brackets plus the stamped tag plate, facing radially out at azimuth a. */\nexport function nameplate(k: Kit, mat: MatKey, a: number, R: number, y: number, tag: string, s = 1) {\n  const d = dirOf(a)\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), V(d))\n  const lat: V3 = [-d[2], 0, d[0]]\n  for (const j of [-1, 1]) k.box(mat, [0.03, 0.07 * s, 0.07], add(at(a, R + 0.03, y), lat, j * 0.13 * s), q)\n  k.box(mat, [0.4 * s, 0.27 * s, 0.006], at(a, R + 0.068, y), q)\n  k.box('nameplate', [0.34 * s, 0.2 * s, 0.003], at(a, R + 0.072, y + 0.015 * s), q)\n  tagPlate(k, tag, at(a, R + 0.072, y - 0.105 * s), d, 0.2 * s)\n}\n\n/** Earthing boss: welded pad with an M12 stud and nut, facing out along d. */\nexport function earthBoss(k: Kit, mat: MatKey, p: V3, d: V3) {\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), V(d).normalize())\n  k.box(mat, [0.07, 0.05, 0.02], add(p, d, 0.01), q)\n  k.rod('stainless', add(p, d, 0.02), add(p, d, 0.06), 0.006, 8)\n  k.hex('stainless', 0.022, 0.01, add(p, d, 0.035), d)\n  k.cyl('copper', 0.014, 0.003, add(p, d, 0.043), d, 10)\n}\n\n/** Small flanged hand valve (body, bonnet, handwheel) centred on p with its run along `run` and stem along `stem`. */\nexport function smallValve(k: Kit, p: V3, run: V3, stem: V3, od: number) {\n  const L = od * 3.2\n  k.rod('valveBody', add(p, run, -L / 2), add(p, run, L / 2), od * 0.62, 12)\n  for (const s of [-1, 1]) k.cyl('flangeSteel', od * 1.05, od * 0.25, add(p, run, (s * L) / 2), run, 16)\n  k.rod('valveBody', p, add(p, stem, od * 2.4), od * 0.32, 10)\n  k.rod('stainless', add(p, stem, od * 2.4), add(p, stem, od * 3.4), od * 0.08, 6)\n  k.handwheel('handwheel', od * 3, add(p, stem, od * 3.3), stem)\n}\n\nexport interface BridleOpts {\n  shellR: number\n  a: number\n  yLo: number\n  yHi: number\n  rating: Rating\n  /** standpipe centre distance from the steel shell, m */\n  off?: number\n  insulated?: boolean\n}\n\n/** Level bridle: two isolated 2\" tappings, 3\" standpipe with vent and drain valves, magnetic gauge (LG) and GWR transmitter (LT) on top. */\nexport function levelBridle(k: Kit, o: BridleOpts) {\n  const off = o.off ?? (o.insulated ? 0.6 : 0.5)\n  const d = dirOf(o.a)\n  const lat: V3 = [-d[2], 0, d[0]]\n  const so = 0.2 + (o.insulated ? 0.075 : 0)\n  const sp = 3\n  const odS = pipeOD(sp)\n  const C = (y: number) => at(o.a, o.shellR + off, y)\n  for (const y of [o.yLo, o.yHi]) {\n    const face = nozzle(k, { base: at(o.a, o.shellR, y), dir: d, nps: 2, rating: o.rating, standout: so, mat: 'equipmentPaint', studs: false })\n    const vEnd = mateFlange(k, 2, o.rating, face, d)\n    const vc = add(vEnd, d, 0.11)\n    smallValve(k, vc, d, [0, 1, 0], pipeOD(2))\n    k.pipe('stainless', add(vc, d, 0.16), add(C(y), d, -odS / 2 + 0.01), pipeOD(2), pipeWall(2, '80'))\n  }\n  const y0 = o.yLo - 0.25\n  const y1 = o.yHi + 0.2\n  k.pipe('stainless', C(y0), C(y1), odS, pipeWall(sp, '80'))\n  k.cyl('stainless', odS / 2 + 0.004, 0.02, C(y0 - 0.01), 'y', 20)\n  // drain valve below, LT flange + GWR head above\n  smallValve(k, C(y0 - 0.16), [0, 1, 0], d, pipeOD(1))\n  k.pipe('stainless', C(y0 - 0.32), C(y0 - 0.02), pipeOD(1), pipeWall(1, '80'))\n  flange(k, sp, o.rating, C(y1 + 0.08), [0, 1, 0], true)\n  k.pipe('stainless', C(y1 - 0.01), C(y1 + 0.02), odS, pipeWall(sp, '80'))\n  const fd = flangeDims(sp, o.rating)\n  k.cyl('flangeSteel', fd.od / 2, fd.thickness, C(y1 + 0.08 + fd.thickness / 2 + 0.004), 'y', 28)\n  k.cyl('stainless', 0.03, 0.12, C(y1 + 0.16 + fd.thickness), 'y', 16)\n  k.cyl('actuatorBlue', 0.075, 0.13, C(y1 + 0.28 + fd.thickness), 'y', 24)\n  k.rbox('actuatorBlue', [0.15, 0.13, 0.15], 0.025, C(y1 + 0.42 + fd.thickness))\n  k.cyl('brass', 0.013, 0.04, add(C(y1 + 0.42 + fd.thickness), lat, 0.09), lat, 10)\n  // magnetic level gauge on the lateral side of the standpipe\n  const gx = add(C(0), lat, odS / 2 + 0.05)\n  const gh = o.yHi - o.yLo + 0.2\n  const gy = (o.yLo + o.yHi) / 2\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), V(d))\n  k.box('stainless', [0.07, gh, 0.06], [gx[0], gy, gx[2]], q)\n  k.box(levelFlagMaterial(0.45), [0.03, gh * 0.96, 0.004], add([gx[0], gy, gx[2]], d, 0.031), q)\n  for (const y of [o.yLo + 0.05, o.yHi - 0.05]) k.rod('stainless', add([gx[0], y, gx[2]], lat, -0.035), add(C(y), lat, odS / 2 - 0.005), 0.012, 8)\n}\n\n/** typical: manway size for a shell ID (24\" from 1.5 m, 20\" from 1.0 m, 18\" from 0.75 m, else a 12\" handhole). */\nexport const manwayNps = (id: number) => (id >= 1.5 ? 24 : id >= 1.0 ? 20 : id >= 0.75 ? 18 : 12)\n\n/** Handrail along an arc (azimuths a0 → a1) at radius r: posts ≤ 1.5 m, top and knee rails, optional toe plate. y may vary with azimuth. */\nexport function railArc(k: Kit, r: number, a0: number, a1: number, y: (a: number) => number, toe = true) {\n  const n = Math.max(4, Math.ceil((Math.abs(a1 - a0) * r) / 0.3))\n  for (const h of [ACCESS.handrail.height, ACCESS.handrail.kneeRail]) {\n    const pts: V3[] = []\n    for (let j = 0; j <= n; j++) {\n      const a = a0 + ((a1 - a0) * j) / n\n      pts.push(at(a, r, y(a) + h))\n    }\n    k.tube('safetyYellow', pts, h > 1 ? 0.024 : 0.018, 8, 0.05)\n  }\n  const m = Math.max(1, Math.ceil((Math.abs(a1 - a0) * r) / ACCESS.handrail.postSpacingMax))\n  for (let j = 0; j <= m; j++) {\n    const a = a0 + ((a1 - a0) * j) / m\n    k.rod('safetyYellow', at(a, r, y(a)), at(a, r, y(a) + ACCESS.handrail.height), 0.024, 8)\n  }\n  if (toe) ringSector(k, 'safetyYellow', r - 0.003, r + 0.003, y(a0), y(a0) + ACCESS.handrail.toePlate, Math.min(a0, a1), Math.abs(a1 - a0), n)\n}\n\n/** API 526 spring-loaded PSV bolted onto an upward flange face: body, side outlet flange, bonnet, cap and lever. Returns the outlet face. */\nexport function reliefValve(k: Kit, face: V3, rating: Rating, orifice: Orifice, outDir: V3) {\n  const s = psvDims(orifice, rating)\n  const mate = add(face, [0, 1, 0], GASKET_T)\n  const Lf = mateFlange(k, s.inletNps, s.inletRating, face, [0, 1, 0])[1] - mate[1]\n  const yc = face[1] + s.centreToInlet\n  const c: V3 = [face[0], yc, face[2]]\n  const rb = Math.max(pipeOD(s.inletNps) * 0.8, 0.05)\n  k.cyl('valveBody', pipeOD(s.inletNps) * 0.55, yc - mate[1] - Lf + 0.02, [face[0], (mate[1] + Lf + yc) / 2, face[2]], 'y', 20)\n  k.sphere('valveBody', rb, c, undefined, 20)\n  const out = add(c, outDir, s.centreToOutlet)\n  const fo = flangeDims(s.outletNps, s.outletRating)\n  const Lo = fo.hubLength + fo.raisedFaceHeight\n  k.cyl('valveBody', pipeOD(s.outletNps) * 0.55, s.centreToOutlet - Lo + 0.01, add(c, outDir, (s.centreToOutlet - Lo) / 2), outDir, 20)\n  flange(k, s.outletNps, s.outletRating, out, outDir)\n  const top = face[1] + s.height\n  k.cyl('valveBody', rb * 1.15, 0.04, [c[0], yc + rb * 0.9, c[2]], 'y', 20)\n  k.cyl('actuatorRed', rb * 0.78, top - yc - rb - 0.12, [c[0], (yc + rb + top - 0.12) / 2, c[2]], 'y', 20, rb * 0.5)\n  k.cyl('actuatorRed', rb * 0.45, 0.12, [c[0], top - 0.06, c[2]], 'y', 16)\n  k.rod('handwheel', [c[0], top - 0.1, c[2]], add([c[0], top - 0.2, c[2]], outDir, -rb * 1.2), 0.008, 6)\n  return out\n}\n\n/** Vertical-vessel internals: vane-type inlet distributor, mesh pad on a support ring, vortex breakers on bottom outlets. */\nexport function vInternals(k: Kit, o: { Ri: number; inlet?: { a: number; y: number; nps: number }; padY?: number; vortex?: V3[]; trays?: number[]; beds?: [number, number][] }) {\n  const { Ri } = o\n  if (o.inlet) {\n    const od = pipeOD(o.inlet.nps)\n    const f = dirOf(o.inlet.a + Math.PI)\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), o.inlet.a + Math.PI)\n    const Ld = Ri * 1.3\n    const h = od * 1.3\n    const c = at(o.inlet.a, Ri - Ld / 2 - 0.02, o.inlet.y)\n    for (const s of [-1, 1]) k.box('internalsSteel', [Ld, 0.008, od * 1.15], add(c, [0, 1, 0], (s * h) / 2), q)\n    const lat: V3 = [-f[2], 0, f[0]]\n    for (let j = 0; j < 6; j++) {\n      const u = -Ld / 2 + Ld * (0.12 + 0.15 * j)\n      for (const s of [-1, 1]) {\n        const qq = q.clone().multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), s * 0.7))\n        k.box('internalsSteel', [0.006, h, od * 0.45], add(add(c, f, u), lat, s * od * 0.5), qq)\n      }\n    }\n  }\n  if (o.padY !== undefined) {\n    k.cyl('galvanised', Ri - 0.006, 0.15, [0, o.padY, 0], 'y', 48)\n    ringSector(k, 'internalsSteel', Ri - 0.05, Ri - 0.001, o.padY - 0.09, o.padY - 0.075, 0, TAU, 48)\n    for (const z of [-0.4, 0.4]) k.box('internalsSteel', [2 * Math.sqrt(Ri * Ri - (z * Ri) ** 2) - 0.01, 0.06, 0.01], [0, o.padY - 0.11, z * Ri])\n  }\n  for (const v of o.vortex ?? []) for (const r of [0, Math.PI / 2]) k.box('internalsSteel', [0.3, 0.15, 0.01], add(v, [0, 1, 0], 0.09), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), r))\n  for (const [ti, y] of (o.trays ?? []).entries()) {\n    // sieve tray with a segmental downcomer, alternating sides\n    const side = ti % 2 ? 1 : -1\n    const cut = Ri * 0.7\n    const sh = new THREE.Shape()\n    const th = Math.acos(cut / Ri)\n    sh.absarc(0, 0, Ri - 0.003, side > 0 ? th : Math.PI + th, side > 0 ? TAU - th : Math.PI - th + TAU, false)\n    sh.closePath()\n    k.extrude('internalsSteel', sh, 0.006, new THREE.Matrix4().makeRotationX(-Math.PI / 2).setPosition(0, y, 0), 24)\n    k.box('internalsSteel', [0.006, 0.5, 2 * Ri * Math.sin(th) - 0.01], [side * cut, y - 0.22, 0])\n  }\n  for (const [y0, y1] of o.beds ?? []) {\n    k.cyl('galvanised', Ri - 0.006, y1 - y0, [0, (y0 + y1) / 2, 0], 'y', 40)\n    ringSector(k, 'internalsSteel', Ri - 0.06, Ri - 0.001, y0 - 0.03, y0, 0, TAU, 40)\n    k.cyl('internalsSteel', Ri * 0.85, 0.06, [0, y1 + 0.35, 0], 'y', 32)\n  }\n}\n\n/** Straight pipe from a to b as a skin with real branch openings (no solid run behind a branch) and a dark bore. */\nexport function pipeSkin(k: Kit, mat: MatKey, a: V3, b: V3, od: number, holes: Hole[] = [], seg = 40) {\n  const A0 = V(a)\n  const dir = V(b).sub(A0)\n  const len = dir.length()\n  const M = new THREE.Matrix4().compose(A0, new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()), new THREE.Vector3(1, 1, 1))\n  const Mi = M.clone().invert()\n  const local = holes.map((h): Hole => ({ p: V(h.p).applyMatrix4(Mi).toArray() as V3, d: V(h.d).transformDirection(Mi).toArray() as V3, r: h.r }))\n  const sk = skinGeometry({ Ro: od / 2, bo: 0, e0: 0, e1: len, top: false, bottom: false, holes: local, seg })\n  k.add(mat, sk.g.clone(), M)\n  k.add('vesselInner', sk.g, M)\n}\n\nconst GASKET_T = 0.0045\n\n/** Through-studs with a nut at each end and a spiral-wound gasket for a joint whose first face is at `face`, pointing along d. */\nexport function jointBolts(k: Kit, nps: number, rating: Rating, face: V3, d: V3) {\n  const f = flangeDims(nps, rating)\n  gasket(k, nps, rating, add(face, d, GASKET_T / 2), d)\n  const span = 2 * (f.thickness + f.raisedFaceHeight) + GASKET_T\n  const len = span + f.boltDia * 3\n  const start = add(face, d, -(f.thickness + f.raisedFaceHeight) - f.boltDia * 1.5)\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, start, d, f.boltDia, len, [f.boltDia * 0.95, len - f.boltDia * 0.95])\n}\n\n/** Mating weld-neck flange bolted to an existing face (gasket + through-studs). Returns the hub end, where the pipe continues. */\nexport function mateFlange(k: Kit, nps: number, rating: Rating, face: V3, d: V3, studs = true): V3 {\n  const mate = add(face, d, GASKET_T)\n  const Lf = flange(k, nps, rating, mate, [-d[0], -d[1], -d[2]])\n  if (studs) jointBolts(k, nps, rating, face, d)\n  return add(mate, d, Lf)\n}\n\n/** Largest standard NPS not above `nps` whose OD fits `maxOD` (keeps nozzles proportionate to small shells). */\nexport function capNps(nps: number, maxOD: number) {\n  const sizes = [...NPS_SIZES].filter((s) => s <= nps).reverse()\n  return sizes.find((s) => pipeOD(s) <= maxOD) ?? 0.5\n}\n"
    }
  ],
  "categories": [
    "vessels"
  ],
  "meta": {
    "export": "CartridgeFilter",
    "anchor": "base",
    "standards": [
      "ASME BPVC VIII-1",
      "ASME B16.5"
    ]
  }
}