{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "horizontal-separator",
  "type": "registry:component",
  "title": "Horizontal separator",
  "description": "Horizontal 3-phase or 2-phase separator, or knock-out drum, on Zick saddles (one sliding). 2:1 ellipsoidal heads with real nozzle openings, water boot, inlet deflector, calming baffle, weir plate, mesh pad and vortex breaker inside, level bridle with magnetic gauge and GWR transmitter, blinded vent and drain, davit manway, earth boss and a top access platform with caged ladder. Shell wall from ASME VIII-1 UG-27 for the design pressure.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "vessels/horizontal-separator.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/vessels/horizontal-separator.tsx",
      "content": "// Horizontal separator / KO drum on saddles: 2:1 ellipsoidal heads, water boot (3-phase), level bridle, manway, top platform. ASME VIII-1, API 12J.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { gauge, handrail, grating, junctionBox, ladder, liftingLug, manway, member, nozzle, saddle, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { headDepth, nozzleStandout, pipeOD, saddle as saddleDims, shellThickness, type Rating } from '../lib/standards'\nimport { earthBoss, holeR, shell, type Hole, capNps } from './_vessel'\nimport { textMaterial } from '../lib/textures'\n\nexport interface HorizontalSeparatorProps {\n  service?: '3-phase' | '2-phase' | 'ko-drum'\n  /** inside diameter, m */\n  diameter?: number\n  /** tangent-to-tangent length, m */\n  length?: number\n  /** shell bottom above deck, m */\n  elevation?: number\n  /** design pressure, barg (sets the shell wall per UG-27) */\n  pressure?: number\n  rating?: Rating\n  inletNps?: number\n  gasNps?: number\n  liquidNps?: number\n  waterNps?: number\n  psvNps?: number\n  insulated?: boolean\n  platform?: boolean\n  tag?: string\n  paint?: MatKey\n}\n\nexport const defaults = {\n  service: '3-phase' as const,\n  diameter: 2.4,\n  length: 8,\n  elevation: 1.8,\n  pressure: 40,\n  rating: 600 as Rating,\n  inletNps: 10,\n  gasNps: 8,\n  liquidNps: 6,\n  waterNps: 4,\n  psvNps: 3,\n  insulated: false,\n  platform: true,\n  tag: 'V-100',\n  paint: 'equipmentPaint' as MatKey,\n}\n\ntype P = typeof defaults & { service: HorizontalSeparatorProps['service'] }\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.45), gasNps: capNps(p.gasNps, p.diameter * 0.5), liquidNps: capNps(p.liquidNps, p.diameter * 0.3), waterNps: capNps(p.waterNps, p.diameter * 0.3), psvNps: capNps(p.psvNps, p.diameter * 0.25) })\n\nfunction dims(p: P) {\n  const wall = shellThickness(p.pressure, p.diameter)\n  const Ro = p.diameter / 2 + wall\n  const yc = p.elevation + Ro\n  const hd = headDepth('2:1-ellipsoidal', p.diameter) + wall\n  const bootR = Math.min(Math.max(p.diameter * 0.15, 0.25), 0.5)\n  const boot = p.service === '3-phase' ? { x: Math.min(p.length * 0.3, p.length * 0.4 - 1.4 * (bootR + pipeOD(p.liquidNps) / 2) - 0.1), r: bootR, h: Math.min(0.9, p.elevation - 0.5) } : null\n  const so = (nps: number) => nozzleStandout(nps, p.rating, p.insulated)\n  return { wall, Ro, yc, hd, boot, so }\n}\n\nexport function ports(props: HorizontalSeparatorProps = {}): Port[] {\n  const p = fit({ ...defaults, ...props } as P)\n  const { Ro, yc, hd, boot, so } = dims(p)\n  const L = p.length\n  const out = [\n    port('inlet', 'Inlet', [-L / 2 - hd - so(p.inletNps), yc, 0], [-1, 0, 0], p.inletNps, p.rating),\n    port('gas', 'Gas outlet', [L * 0.4, yc + Ro + so(p.gasNps), 0], [0, 1, 0], p.gasNps, p.rating),\n    port('liquid', p.service === '3-phase' ? 'Oil outlet' : 'Liquid outlet', [L * 0.4, yc - Ro - so(p.liquidNps), 0], [0, -1, 0], p.liquidNps, p.rating),\n  ]\n  if (boot) out.push(port('water', 'Water outlet', [boot.x, yc - Ro - boot.h * 0.55, -boot.r - so(p.waterNps)], [0, 0, -1], p.waterNps, p.rating))\n  out.push(port('psv', 'PSV', [-L * 0.1, yc + Ro + so(p.psvNps), 0], [0, 1, 0], p.psvNps, p.rating, 'relief'))\n  return out\n}\n\nexport function HorizontalSeparator(props: HorizontalSeparatorProps & GroupProps) {\n  const { service, diameter, length, elevation, pressure, rating, inletNps, gasNps, liquidNps, waterNps, psvNps, insulated, platform, tag, paint, ...group } = { ...defaults, ...props }\n  const p = fit({ service, diameter, length, elevation, pressure, rating, inletNps, gasNps, liquidNps, waterNps, psvNps, insulated, platform, 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 HorizontalSeparator\n\nfunction build(k: Kit, p: P) {\n  const { Ro, yc, hd, boot } = dims(p)\n  const L = p.length\n  const a = p.diameter / 2\n  const ins = p.insulated ? 0.06 : 0\n  const bdr = boot ? boot.r : 0\n  const holes: Hole[] = [\n    { p: [-L / 2 - hd, yc, 0], d: [-1, 0, 0], r: holeR(p.inletNps) },\n    { p: [L * 0.4, yc + Ro, 0], d: [0, 1, 0], r: holeR(p.gasNps) },\n    { p: [L * 0.4, yc - Ro, 0], d: [0, -1, 0], r: holeR(p.liquidNps) },\n    { p: [-L * 0.1, yc + Ro, 0], d: [0, 1, 0], r: holeR(p.psvNps) },\n    { p: [L / 2 + hd, yc, 0], d: [1, 0, 0], r: holeR(24) },\n    { p: [-L * 0.35, yc - Ro, 0], d: [0, -1, 0], r: holeR(2) },\n    { p: [L * 0.25, yc + Ro, 0], d: [0, 1, 0], r: holeR(2) },\n  ]\n  if (boot) holes.push({ p: [boot.x, yc - Ro, 0], d: [0, -1, 0], r: bdr - 0.012 })\n  shell(k, { Ro, bo: hd, e0: -L / 2, e1: L / 2, mat: p.paint, axis: 'x', centre: [0, yc, 0], insulated: p.insulated, ins, holes })\n  internals(k, p, yc, a)\n  nozzle(k, { base: [L * 0.25, yc + Ro, 0], dir: [0, 1, 0], nps: 2, rating: p.rating, standout: nozzleStandout(2, p.rating, p.insulated), mat: p.paint, blind: true })\n\n  const sd = saddleDims(2 * Ro, L)\n  k.at([0, yc, 0], undefined, () => {\n    saddle(k, -L / 2 + sd.positionFromTangent, Ro, -yc, { mat: p.paint })\n    saddle(k, L / 2 - sd.positionFromTangent, Ro, -yc, { mat: p.paint, sliding: true })\n  })\n  earthBoss(k, p.paint, [-L / 2 + sd.positionFromTangent + 0.12, 0.15, Ro * 0.88 + 0.045], [0, 0, 1])\n\n  const nz = (base: V3, dir: V3, nps: number, studs = true) => nozzle(k, { base, dir, nps, rating: p.rating, standout: nozzleStandout(nps, p.rating, p.insulated), pad: pipeOD(nps) * 2, mat: p.paint, studs })\n  nz([-L / 2 - hd, yc, 0], [-1, 0, 0], p.inletNps)\n  nz([L * 0.4, yc + Ro, 0], [0, 1, 0], p.gasNps)\n  nz([L * 0.4, yc - Ro, 0], [0, -1, 0], p.liquidNps)\n  nz([-L * 0.1, yc + Ro, 0], [0, 1, 0], p.psvNps)\n  manway(k, [L / 2 + hd, yc, 0], [1, 0, 0], 0.25, 24, Math.min(p.rating, 600) as Rating, p.paint)\n  nozzle(k, { base: [-L * 0.35, yc - Ro, 0], dir: [0, -1, 0], nps: 2, rating: p.rating, standout: 0.2, mat: p.paint, blind: true })\n\n  if (boot) {\n    const yb = yc - Ro\n    const bh: Hole[] = [\n      { p: [boot.x, yb - boot.h * 0.55, -boot.r], d: [0, 0, -1], r: holeR(p.waterNps) },\n      { p: [boot.x, yb - boot.h - boot.r * 0.5, 0], d: [0, -1, 0], r: holeR(2) },\n    ]\n    shell(k, { Ro: boot.r, bo: boot.r / 2, e0: -boot.h, e1: 0.05 + Ro - Math.sqrt(Ro * Ro - boot.r * boot.r), top: false, centre: [boot.x, yb, 0], mat: p.paint, seg: 40, course: 9, holes: bh })\n    nozzle(k, { base: [boot.x, yb - boot.h * 0.55, -boot.r], dir: [0, 0, -1], nps: p.waterNps, rating: p.rating, standout: nozzleStandout(p.waterNps, p.rating, p.insulated), mat: p.paint })\n    nozzle(k, { base: [boot.x, yb - boot.h - boot.r * 0.5, 0], dir: [0, -1, 0], nps: 2, rating: p.rating, standout: 0.15, mat: p.paint, blind: true })\n  }\n\n  // level bridle with magnetic gauge, GWR transmitter and isolation tappings on the +Z side\n  const bx = -L * 0.2\n  const bz = Ro + 0.45\n  const yTop = yc + a * 0.7\n  const yBot = (boot ? yc - Ro - boot.h * 0.8 : yc - a * 0.8)\n  k.cyl('stainless', 0.045, yTop - yBot + 0.2, [bx, (yTop + yBot) / 2, bz], 'y', 20)\n  for (const y of [yTop, yc - a * 0.6]) k.pipe('stainless', [bx, y, Ro * Math.sqrt(Math.max(0, 1 - ((y - yc) / Ro) ** 2)) - 0.02], [bx, y, bz], 0.06, 0.005)\n  k.box('stainless', [0.07, yTop - yBot, 0.05], [bx + 0.09, (yTop + yBot) / 2, bz])\n  k.box(textMaterial('LG', '#ff5a10', '#ffffff', 64, 512), [0.03, (yTop - yBot) * 0.55, 0.052], [bx + 0.09, yBot + (yTop - yBot) * 0.28, bz])\n  k.cyl('actuatorBlue', 0.07, 0.16, [bx, yTop + 0.25, bz], 'y', 20)\n  k.rbox('actuatorBlue', [0.14, 0.12, 0.12], 0.02, [bx, yTop + 0.4, bz])\n  gauge(k, [L * 0.2, yc + a * 0.2, Ro + 0.12], 'z', 0.15, 'bar', 100)\n  k.pipe('stainless', [L * 0.2, yc + a * 0.2, Ro - 0.02], [L * 0.2, yc + a * 0.2, Ro + 0.07], 0.03, 0.004)\n  junctionBox(k, [-L * 0.25, p.elevation * 0.5, 0.0], 'z', [0.32, 0.36, 0.18])\n  member(k, [-L * 0.25, 0, -0.1], [-L * 0.25, p.elevation * 0.8, -0.1], 'UPN 100', 'galvanised')\n\n  for (const x of [-L * 0.3, L * 0.3]) liftingLug(k, [x, yc + Ro - 0.01, 0], 'x', 1.2, p.paint)\n  tagPlate(k, p.tag, [0, yc - Ro * 0.25, Ro * 0.97 + 0.002], 'z', 0.35)\n\n  if (p.platform) {\n    const y = yc + Ro + 0.15\n    const x0 = -L * 0.32\n    const x1 = L * 0.48\n    const w = 1.2\n    grating(k, [(x0 + x1) / 2, y, 0], x1 - x0, w)\n    for (const x of [x0 + 0.1, (x0 + x1) / 2, x1 - 0.1]) {\n      member(k, [x, y - 0.03, -w / 2], [x, y - 0.03, w / 2], 'UPN 100', 'galvanised', [0, 1, 0])\n      for (const s of [-1, 1]) member(k, [x, y - 0.08, s * w * 0.38], [x, yc + Ro * 0.75, s * Ro * 0.55], 'L50x50x5', 'galvanised')\n    }\n    for (const s of [-1, 1]) member(k, [x0, y - 0.08, s * (w / 2 - 0.05)], [x1, y - 0.08, s * (w / 2 - 0.05)], 'UPN 100', 'galvanised', [0, 1, 0])\n    handrail(k, [[x0 + 0.9, y, w / 2], [x1, y, w / 2], [x1, y, -w / 2], [x0, y, -w / 2], [x0, y, w / 2 - 0.05]])\n    ladder(k, [x0 + 0.45, 0, w / 2 + 0.25], y, [0, 0, 1])\n  }\n}\n\n/** Internals seen through the nozzles: inlet deflector, perforated calming baffle, weir (3-phase), mesh pad under the gas outlet, vortex breakers. */\nfunction internals(k: Kit, p: P, yc: number, a: number) {\n  const L = p.length\n  // half-pipe style deflector in front of the inlet\n  k.lathe('internalsSteel', [[a * 0.42, 0], [a * 0.42, 0.012]], [-L / 2 + 0.25, yc, 0], 'x', 32)\n  k.cyl('internalsSteel', a * 0.42, 0.012, [-L / 2 + 0.25, yc, 0], 'x', 32)\n  for (const s of [-1, 1]) k.rod('internalsSteel', [-L / 2 + 0.25, yc + s * a * 0.4, 0], [-L / 2 - 0.05, yc + s * a * 0.4, 0], 0.012, 6)\n  // calming baffle across the liquid section, weir plate ahead of the oil bucket\n  k.extrude('internalsSteel', segmentShape(a - 0.004, a * 0.1), 0.01, new THREE.Matrix4().makeRotationY(Math.PI / 2).setPosition(-L * 0.28, yc, 0))\n  if (p.service === '3-phase') k.extrude('internalsSteel', segmentShape(a - 0.004, -a * 0.05), 0.012, new THREE.Matrix4().makeRotationY(Math.PI / 2).setPosition(L * 0.32, yc, 0))\n  // mesh pad with support grid under the gas outlet\n  const mw = Math.min(1.2, L * 0.18)\n  const yPad = yc + a * 0.55\n  const half = Math.sqrt(Math.max(0, a * a - (a * 0.55) ** 2)) - 0.01\n  k.box('galvanised', [mw, 0.15, 2 * half], [L * 0.4, yPad, 0])\n  k.box('internalsSteel', [mw + 0.04, 0.02, 2 * half], [L * 0.4, yPad - 0.085, 0])\n  // vortex breakers over the bottom outlets\n  for (const x of [L * 0.4]) for (const r of [0, Math.PI / 2]) k.box('internalsSteel', [pipeOD(p.liquidNps) * 2, pipeOD(p.liquidNps), 0.01], [x, yc - a + pipeOD(p.liquidNps) * 0.5, 0], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), r))\n}\n\n/** Circular segment below a horizontal chord at height yTop (vessel-centre frame), in the YZ plane after rotation. */\nfunction segmentShape(r: number, yTop: number) {\n  const th = Math.asin(Math.max(-0.999, Math.min(0.999, yTop / r)))\n  const sh = new THREE.Shape()\n  sh.moveTo(r * Math.cos(th), yTop)\n  sh.absarc(0, 0, r, th, Math.PI - th, true)\n  sh.lineTo(r * Math.cos(th), yTop)\n  return sh\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": "HorizontalSeparator",
    "anchor": "base",
    "standards": [
      "ASME BPVC VIII-1",
      "API 12J",
      "ASME B16.5",
      "ISO 14122"
    ]
  }
}