{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slug-catcher",
  "type": "registry:component",
  "title": "Finger-type slug catcher",
  "description": "Pipeline-terminal finger slug catcher built from line pipe: an inlet header splits the flow into parallel upper separation fingers, downcomers drop liquid into sloped storage fingers below, gas risers at the end of the separation fingers join a gas header, and the low end of the storage fingers runs into a liquid header. Every tee is a real opening. Concrete sleepers with pipe shoes, steel portals, girth welds every 12 m.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "vessels/slug-catcher.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/vessels/slug-catcher.tsx",
      "content": "// Finger-type slug catcher: inlet header splitting into parallel fingers, upper separation fingers with downcomers to sloped storage fingers, gas risers to a gas header, liquid header. ASME B31.8, ASME B16.5.\nimport { Kit, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { flange, member, tagPlate, weldBead } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { flange as flangeDims, pipeOD, type Rating } from '../lib/standards'\nimport { holeR, pipeSkin, type Hole } from './_vessel'\n\nexport interface SlugCatcherProps {\n  fingers?: number\n  fingerNps?: number\n  /** storage finger length, m */\n  length?: number\n  /** storage finger fall away from the inlet, % */\n  slope?: number\n  inletNps?: number\n  gasNps?: number\n  liquidNps?: number\n  rating?: Rating\n  tag?: string\n}\n\nexport const defaults = {\n  fingers: 4,\n  fingerNps: 36,\n  length: 36,\n  slope: 1,\n  inletNps: 24,\n  gasNps: 20,\n  liquidNps: 10,\n  rating: 600 as Rating,\n  tag: 'SC-100',\n}\n\ntype P = typeof defaults\n\nfunction layout(p: P) {\n  const n = Math.max(2, Math.round(p.fingers))\n  const OD = pipeOD(p.fingerNps)\n  const pitch = OD * 2.3\n  const zs = Array.from({ length: n }, (_, i) => (i - (n - 1) / 2) * pitch)\n  const L = p.length\n  const s = p.slope / 100\n  const xL0 = 1.2\n  const yL0 = 0.6 + OD / 2 + (L - xL0) * s\n  const yL = (x: number) => yL0 - (x - xL0) * s\n  const yU = yL0 + OD + 0.7\n  const xU1 = Math.max(8, L * 0.4)\n  const hNps = Math.min(48, Math.max(p.inletNps, p.fingerNps) + 6) // headers a size up so the finger tees stay clear of the header sides\n  const hOD = pipeOD(hNps)\n  const gOD = pipeOD(p.gasNps)\n  const rNps = Math.min(p.gasNps, 16)\n  const xR = xU1 - OD\n  const yG = yU + OD / 2 + 1.6\n  const zEnd = zs[n - 1] + pitch * 0.6\n  const fl = (nps: number) => flangeDims(nps, p.rating).hubLength + flangeDims(nps, p.rating).raisedFaceHeight\n  const inletFace: V3 = [-hOD / 2 - 1.6, yU, 0]\n  const gasFace: V3 = [xR, yG, zEnd + 1.2]\n  const lOD = hOD\n  const liqFace: V3 = [L + lOD / 2 + 1.4, yL(L), 0]\n  return { n, OD, pitch, zs, L, s, xL0, yL0, yL, yU, xU1, hOD, gOD, rNps, xR, yG, zEnd, fl, inletFace, gasFace, liqFace, lOD }\n}\n\nexport function ports(props: SlugCatcherProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const G = layout(p)\n  return [\n    port('inlet', 'Inlet', G.inletFace, [-1, 0, 0], p.inletNps, p.rating),\n    port('gas', 'Gas outlet', G.gasFace, [0, 0, 1], p.gasNps, p.rating),\n    port('liquid', 'Liquid outlet', G.liqFace, [1, 0, 0], p.liquidNps, p.rating),\n  ]\n}\n\nexport function SlugCatcher(props: SlugCatcherProps & GroupProps) {\n  const { fingers, fingerNps, length, slope, inletNps, gasNps, liquidNps, rating, tag, ...group } = { ...defaults, ...props }\n  const p = { fingers, fingerNps, length, slope, inletNps, gasNps, liquidNps, rating, tag } as P\n  const data = useKit((k) => build(k, p), Object.values(p))\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default SlugCatcher\n\nfunction build(k: Kit, p: P) {\n  const G = layout(p)\n  const { OD, zs, L, yL, yU, xU1, hOD, gOD, xR, yG, zEnd } = G\n  const m = 'pipePaint'\n  const dcNps = Math.max(8, Math.round(p.fingerNps / 3 / 2) * 2)\n  const dcX = [L * 0.14, L * 0.28].filter((x) => x < xU1 - 2 * OD)\n  const cap = (pos: V3, axis: V3, od: number) => {\n    k.dome(m, od / 2, pos, axis, Math.PI / 2, 0.5, 32)\n    k.dome('vesselInner', od / 2, pos, axis, Math.PI / 2, 0.5, 32)\n  }\n  // inlet header (along Z) with the inlet branch and one branch per finger\n  const hz = zs[zs.length - 1] + G.pitch * 0.5\n  pipeSkin(k, m, [0, yU, -hz], [0, yU, hz], hOD, [\n    { p: [-hOD / 2, yU, 0], d: [-1, 0, 0], r: holeR(p.inletNps) },\n    ...zs.map((z): Hole => ({ p: [hOD / 2, yU, z], d: [1, 0, 0], r: OD / 2 - 0.012 })),\n  ], 48)\n  for (const s of [-1, 1]) cap([0, yU, s * hz], [0, 0, s], hOD)\n  const fi = G.fl(p.inletNps)\n  pipeSkin(k, m, [-hOD * 0.4, yU, 0], [G.inletFace[0] + fi, yU, 0], pipeOD(p.inletNps))\n  flange(k, p.inletNps, p.rating, G.inletFace, [-1, 0, 0], true)\n\n  for (const z of zs) {\n    // upper separation finger: header → gas riser, downcomers below\n    pipeSkin(k, m, [hOD * 0.4, yU, z], [xU1, yU, z], OD, [\n      ...dcX.map((x): Hole => ({ p: [x, yU - OD / 2, z], d: [0, -1, 0], r: holeR(dcNps) })),\n      { p: [xR, yU + OD / 2, z], d: [0, 1, 0], r: holeR(G.rNps) },\n    ], 48)\n    cap([xU1, yU, z], [1, 0, 0], OD)\n    // sloped storage finger\n    const a: V3 = [G.xL0, yL(G.xL0), z]\n    const b: V3 = [L + G.lOD * 0.4, yL(L), z]\n    pipeSkin(k, m, a, b, OD, dcX.map((x): Hole => ({ p: [x, yL(x) + OD / 2, z], d: [0, 1, 0], r: holeR(dcNps) })), 48)\n    cap(a, [-1, 0, -0.0], OD)\n    for (let x = G.xL0 + 12; x < L; x += 12) weldBead(k, p.fingerNps, [x, yL(x), z], [1, 0, 0])\n    for (const x of dcX) pipeSkin(k, m, [x, yU - OD * 0.4, z], [x, yL(x) + OD * 0.4, z], pipeOD(dcNps))\n    pipeSkin(k, m, [xR, yU + OD * 0.4, z], [xR, yG - gOD * 0.4, z], pipeOD(G.rNps))\n  }\n  // gas header with outlet flange on +Z\n  const gf = G.fl(p.gasNps)\n  pipeSkin(k, m, [xR, yG, zs[0] - G.pitch * 0.5], [xR, yG, G.gasFace[2] - gf], gOD, zs.map((z): Hole => ({ p: [xR, yG - gOD / 2, z], d: [0, -1, 0], r: holeR(G.rNps) })), 40)\n  cap([xR, yG, zs[0] - G.pitch * 0.5], [0, 0, -1], gOD)\n  flange(k, p.gasNps, p.rating, G.gasFace, [0, 0, 1], true)\n  // liquid header at the low end with the outlet branch\n  const yLq = yL(L)\n  pipeSkin(k, m, [L + G.lOD / 2, yLq, -hz], [L + G.lOD / 2, yLq, hz], G.lOD, [\n    ...zs.map((z): Hole => ({ p: [L + G.lOD / 2 - G.lOD / 2, yLq, z], d: [-1, 0, 0], r: OD / 2 - 0.012 })),\n    { p: [L + G.lOD, yLq, 0], d: [1, 0, 0], r: holeR(p.liquidNps) },\n  ], 48)\n  for (const s of [-1, 1]) cap([L + G.lOD / 2, yLq, s * hz], [0, 0, s], G.lOD)\n  const lf = G.fl(p.liquidNps)\n  pipeSkin(k, m, [L + G.lOD * 0.9, yLq, 0], [G.liqFace[0] - lf, yLq, 0], pipeOD(p.liquidNps))\n  flange(k, p.liquidNps, p.rating, G.liqFace, [1, 0, 0], true)\n\n  // supports: concrete sleepers with shoes under the storage fingers, steel portals under the upper fingers and gas header\n  const zw = zEnd + 0.4\n  for (let x = 3; x <= L - 1; x += 6) {\n    const top = yL(x) - OD / 2 - 0.12\n    k.box('concrete', [0.6, top, 2 * zw], [x, top / 2, 0])\n    for (const z of zs) {\n      k.box('structSteel', [0.4, 0.12, OD * 0.5], [x, top + 0.06, z])\n      k.box('structSteel', [0.4, 0.02, 0.3], [x, top + 0.13, z])\n    }\n  }\n  const portal = (x: number, yTop: number) => {\n    const zc = [zs[0] - G.pitch / 2, ...zs.slice(1).map((z) => z - G.pitch / 2), zs[zs.length - 1] + G.pitch / 2]\n    for (const z of zc) member(k, [x, 0, z], [x, yTop, z], 'HEA 200')\n    member(k, [x, yTop + 0.1, zc[0] - 0.1], [x, yTop + 0.1, zc[zc.length - 1] + 0.1], 'HEA 200', 'structSteel', [0, 1, 0])\n    for (const z of zs) k.box('structSteel', [0.3, yTop + 0.2 > 0 ? 0.1 : 0.1, OD * 0.45], [x, yTop + 0.25, z])\n  }\n  for (let x = 4.5; x < xU1 - 1; x += 7) if (!dcX.some((d) => Math.abs(d - x) < 0.8)) portal(x, yU - OD / 2 - 0.3)\n  portal(1.0 + hOD / 2, yU - Math.max(hOD, OD) / 2 - 0.3)\n  portal(xR + gOD, yG - gOD / 2 - 0.3)\n  k.box('galvanised', [0.08, 1.4, 0.08], [-1.2, 0.7, -hz - 0.6])\n  k.box('structSteel', [0.5, 0.18, 0.01], [-1.2, 1.3, -hz - 0.55])\n  tagPlate(k, p.tag, [-1.2, 1.3, -hz - 0.544], 'z', 0.45)\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": "SlugCatcher",
    "anchor": "base",
    "standards": [
      "ASME B31.8",
      "ASME B16.5",
      "ASME B36.10"
    ]
  }
}