{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pig-launcher",
  "type": "registry:component",
  "title": "Pig launcher / receiver",
  "description": "Pig trap for launching or receiving pigs: oversized major barrel, reducer (eccentric flat-bottom on launchers, concentric on receivers), minor barrel with pig signaller and a full-bore trap valve, clamp-ring quick-opening closure with pressure-warning screw and key interlock, kicker (launcher) or bypass (receiver) nozzle with valve, vent, drain, thermal-relief PSV and a balance line with valve to pressurise both sides of the pig, adjustable saddle supports. The barrel is one to two sizes over the line; `closureOpen` swings the door on its hinge. The `service` prop picks launcher or receiver (named so to avoid the reserved `type`).",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "wellhead/pig-launcher.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/wellhead/pig-launcher.tsx",
      "content": "// Pig launcher / receiver: oversized barrel, eccentric reducer, clamp-ring quick-opening closure with pressure-warning interlock, trap valve, kicker, vent, drain, PSV, signaller. API 6D / ASME VIII.\nimport * as THREE from 'three'\nimport { useMemo } from 'react'\nimport { Kit, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { flange, gasket, gauge, liftingLug, nozzle, pipeRun, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { faceToFace, flange as flangeDims, nozzleStandout, pipeOD, pipeWall, psv as psvDims, reducer, type Rating } from '../lib/standards'\nimport { ballValve, flangeBore, frame, ring } from './_wellparts'\n\nexport interface PigLauncherProps {\n  service?: 'launcher' | 'receiver'\n  /** pipeline size, NPS */\n  nps?: number\n  rating?: Rating\n  /** major barrel length (reducer to closure), m */\n  length?: number\n  /** barrel axis height above deck, m */\n  elevation?: number\n  kickerNps?: number\n  /** closure door swung open */\n  closureOpen?: boolean\n  tag?: string\n}\n\nexport const defaults = {\n  service: 'launcher' as 'launcher' | 'receiver',\n  nps: 10,\n  rating: 600 as Rating,\n  length: 3.2,\n  elevation: 1.1,\n  kickerNps: 4,\n  closureOpen: false,\n  tag: 'L-110',\n}\n\ntype P = typeof defaults\n\nconst SIZES = [2, 3, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24, 26, 28, 30, 32, 36]\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nfunction layout(p: P) {\n  const nps = p.nps\n  const i = SIZES.findIndex((s) => s >= nps)\n  const major = SIZES[Math.min(SIZES.length - 1, Math.max(0, i) + (nps >= 12 ? 2 : 1))] // oversized 1–2 sizes, typical\n  const Rj = pipeOD(major) / 2\n  const Rn = pipeOD(nps) / 2\n  const yc = p.elevation\n  const yn = p.service === 'launcher' ? yc - (Rj - Rn) : yc // launcher: eccentric flat on bottom; receiver: concentric\n  const Lv = faceToFace('ball-full', nps, p.rating)\n  const minorLen = clamp(2.2 * pipeOD(nps), 0.6, 1.6)\n  const lr = reducer(major, nps)\n  const s = clamp(Rj / 0.162, 0.5, 2)\n  const tD = 0.05 * s\n  const dD = 0.06 * s\n  const hubL = 0.16 * s\n  const Rh = Rj * 1.38 + 0.02\n  // stations from the mainline face (x0)\n  const total = Lv + minorLen + lr + p.length + hubL + tD + dD\n  const x0 = -total / 2\n  const xv1 = x0 + Lv\n  const xr0 = xv1 + minorLen\n  const xr1 = xr0 + lr\n  const xH0 = xr1 + p.length\n  const xc = xH0 + hubL\n  const launcher = p.service === 'launcher'\n  const kn = Math.min(p.kickerNps, major)\n  const so = (n: number) => nozzleStandout(n, p.rating)\n  const xk = launcher ? xH0 - 0.35 - pipeOD(kn) : xr1 + 0.35 + pipeOD(kn)\n  const LvK = faceToFace('ball-full', kn, p.rating)\n  const kFace: V3 = [xk, yc + Rj + so(kn) + LvK, 0]\n  const vn = 2\n  const LvV = faceToFace('ball-full', vn, p.rating)\n  const xvent = xH0 - 0.18\n  const vFace: V3 = [xvent, yc + Rj + so(vn) + LvV, 0]\n  const dn = launcher ? 2 : 4\n  const LvD = faceToFace('ball-full', dn, p.rating)\n  const xd = xH0 - 0.3 - pipeOD(dn)\n  const dSo = Math.min(so(dn), Math.max(0.12, yc - Rj - LvD - 0.25))\n  const dFace: V3 = [xd, yc - Rj - dSo - LvD, 0]\n  const psvRating = Math.min(p.rating, 1500) as Rating\n  const pd = psvDims('D', psvRating)\n  const xpsv = xr1 + p.length * 0.45\n  const psvOut: V3 = [xpsv, yc + Rj + so(pd.inletNps) + pd.centreToInlet, -pd.centreToOutlet]\n  return { major, Rj, Rn, yc, yn, Lv, minorLen, lr, s, tD, dD, hubL, Rh, x0, xv1, xr0, xr1, xH0, xc, launcher, kn, xk, LvK, kFace, vn, LvV, xvent, vFace, dn, LvD, xd, dSo, dFace, pd, psvRating, psvOut, xpsv, so }\n}\n\nexport function ports(props: PigLauncherProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const L = layout(p)\n  return [\n    port('mainline', 'Mainline', [L.x0, L.yn, 0], [-1, 0, 0], p.nps, p.rating),\n    port('kicker', L.launcher ? 'Kicker inlet' : 'Bypass outlet', L.kFace, [0, 1, 0], L.kn, p.rating),\n    port('vent', 'Vent', L.vFace, [0, 1, 0], L.vn, p.rating, 'vent'),\n    port('drain', 'Drain', L.dFace, [0, -1, 0], L.dn, p.rating, 'drain'),\n    port('psv', 'PSV outlet', L.psvOut, [0, 0, -1], L.pd.outletNps, L.pd.outletRating, 'relief'),\n  ]\n}\n\nexport function PigLauncher(props: PigLauncherProps & GroupProps) {\n  const { service, nps, rating, length, elevation, kickerNps, closureOpen, tag, ...group } = { ...defaults, ...props }\n  const p = { service, nps, rating, length, elevation, kickerNps, closureOpen, tag } as P\n  const L = useMemo(() => layout(p), Object.values(p)) // eslint-disable-line react-hooks/exhaustive-deps\n  const data = useKit((k) => build(k, p), Object.values(p))\n  const door = useKit((k) => buildDoor(k, p), [nps, rating, length, elevation, service])\n  const hinge = hingeAt(L)\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n      <group position={hinge} rotation-y={closureOpen ? (100 * Math.PI) / 180 : 0}>\n        <KitMesh data={door} />\n      </group>\n    </group>\n  )\n}\n\nexport default PigLauncher\n\nfunction hingeAt(L: ReturnType<typeof layout>): V3 {\n  return [L.xc + 0.02 * L.s, L.yc, -(L.Rh + 0.11 * L.s)]\n}\n\n/** Lofted eccentric reducer between two circles on X (centres may differ in y). */\nfunction eccentric(k: Kit, x0: number, r0: number, y0: number, x1: number, r1: number, y1: number) {\n  const seg = 48\n  const rows = 12\n  const pos: number[] = []\n  const idx: number[] = []\n  for (let j = 0; j <= rows; j++) {\n    const t = j / rows\n    const e = t * t * (3 - 2 * t)\n    const r = r0 + (r1 - r0) * e\n    const yc = y0 + (y1 - y0) * e\n    const x = x0 + (x1 - x0) * t\n    for (let i = 0; i <= seg; i++) {\n      const a = (i / seg) * Math.PI * 2\n      pos.push(x, yc + Math.cos(a) * r, Math.sin(a) * r)\n    }\n  }\n  for (let j = 0; j < rows; j++)\n    for (let i = 0; i < seg; i++) {\n      const a = j * (seg + 1) + i\n      const b = a + seg + 1\n      idx.push(a, a + 1, b, b, a + 1, b + 1)\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('equipmentPaint', g)\n}\n\nfunction support(k: Kit, x: number, yAxis: number, R: number) {\n  const yC = yAxis - R - 0.012\n  const post = 0.06 + R * 0.25\n  k.lathe('structSteel', [[R + 0.012, -0.08], [R + 0.012, 0.08], [R + 0.0005, 0.08], [R + 0.0005, -0.08], [R + 0.012, -0.08]], [x, yAxis, 0], 'x', 32, Math.PI / 2 + Math.PI / 6, (2 * Math.PI) / 3)\n  k.box('structSteel', [0.2, 0.016, R * 1.9], [x, yC - R * 0.5 - 0.008, 0])\n  for (const s of [-1, 1]) k.box('structSteel', [0.012, R * 0.55, 0.012], [x, yC - R * 0.25, s * R * 0.75])\n  const yTop = yC - R * 0.5 - 0.016\n  k.pipe('structSteel', [x, 0.02, 0], [x, yTop, 0], post * 2, 0.008, 20)\n  // jack screw adjuster\n  k.cyl('boltXylan', 0.02, 0.08, [x, yTop - 0.05, 0], 'y', 10)\n  k.hex('nut', 0.07, 0.03, [x, yTop - 0.09, 0])\n  k.box('structSteel', [0.32, 0.02, 0.32], [x, 0.01, 0])\n  for (const s of [-1, 1]) k.box('structSteel', [0.012, 0.12, 0.1], [x, 0.08, s * (post + 0.05)])\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      k.cyl('nut', 0.008, 0.07, [x + sx * 0.12, 0.035, sz * 0.12], 'y', 6)\n      k.hex('nut', 0.026, 0.016, [x + sx * 0.12, 0.028, sz * 0.12])\n    }\n  // U-bolt\n  const ru = R + 0.012\n  k.torus('nut', ru, 0.008, [x, yAxis, 0], 'x', 6, 24, Math.PI)\n  for (const s of [-1, 1]) k.hex('nut', 0.026, 0.016, [x, yC - R * 0.5 + 0.0, s * ru])\n}\n\nfunction build(k: Kit, p: P) {\n  const L = layout(p)\n  const { Rj, Rn, yc, yn, s } = L\n  const wn = pipeWall(p.nps, '80')\n  const wj = pipeWall(L.major, '80')\n\n  // trap valve (full-bore ball, gear operated), mainline face at x0\n  k.with(frame([L.x0, yn, 0], [1, 0, 0], [0, 0, 1]), () => ballValve(k, p.nps, p.rating, L.Lv, 'gear', true, true, p.service === 'launcher' ? 'LAUNCH VLV' : 'RECV VLV'))\n  // minor barrel with its flange bolted to the valve\n  const fl = flangeDims(p.nps, p.rating)\n  const flL = fl.hubLength + fl.raisedFaceHeight\n  flange(k, p.nps, p.rating, [L.xv1 + 0.0045, yn, 0], '-x')\n  gasket(k, p.nps, p.rating, [L.xv1 + 0.00225, yn, 0], 'x')\n  k.pipe('equipmentPaint', [L.xv1 + flL, yn, 0], [L.xr0 + 0.002, yn, 0], Rn * 2, wn, 40)\n  eccentric(k, L.xr0, Rn, yn, L.xr1, Rj, yc)\n  k.pipe('equipmentPaint', [L.xr1 - 0.002, yc, 0], [L.xH0 + 0.002, yc, 0], Rj * 2, wj, 48)\n  for (const [x, r, y] of [[L.xr0, Rn, yn], [L.xr1, Rj, yc], [L.xH0, Rj, yc]] as const) k.torus('flangeSteel', r + 0.0008, 0.004, [x, y, 0], 'x', 6, 48)\n  k.lathe('pipeBore', [[Rj - wj, L.xc], [Rj - wj, L.xr1]], [0, yc, 0], 'x', 40)\n\n  // closure hub (door is a separate swinging part)\n  k.lathe('equipmentPaint', [[Rj, L.xH0], [Rj + 0.012, L.xH0 + 0.02], [L.Rh, L.xH0 + 0.07 * s], [L.Rh, L.xc], [Rj - wj, L.xc]], [0, yc, 0], 'x', 56)\n  k.lathe('machined', [[L.Rh - 0.005, L.xc + 0.001], [Rj - wj, L.xc + 0.001]], [0, yc, 0], 'x', 56)\n  // hinge bracket and pin on the hub (-Z side)\n  const h = hingeAt(L)\n  k.box('equipmentPaintDark', [0.1 * s, 0.16 * s, 0.12 * s], [L.xc - 0.05 * s, yc, -(L.Rh + 0.04 * s)])\n  k.rod('equipmentPaintDark', [h[0], yc - 0.13 * s, h[2]], [h[0], yc + 0.13 * s, h[2]], 0.02 * s, 12)\n  k.cyl('equipmentPaintDark', 0.038 * s, 0.04 * s, [h[0], yc - 0.08 * s, h[2]], 'y', 16)\n  // safety interlock: pressure-warning screw on the hub, key-interlock box linked to the vent valve\n  k.rod('brass', [L.xc - 0.04 * s, yc + L.Rh - 0.01, 0.1 * s], [L.xc - 0.04 * s, yc + L.Rh + 0.07 * s, 0.1 * s], 0.012 * s, 8)\n  k.rbox('actuatorYellow', [0.12, 0.1, 0.08], 0.01, [L.xc - 0.12 * s, yc + L.Rh + 0.03, -0.08 * s])\n  k.cyl('brass', 0.012, 0.04, [L.xc - 0.12 * s, yc + L.Rh + 0.03, -0.08 * s - 0.05], 'z', 8)\n  k.box('brass', [0.05, 0.012, 0.006], [L.xc - 0.12 * s, yc + L.Rh + 0.03, -0.08 * s - 0.072])\n\n  // kicker / bypass nozzle with its valve\n  const kFaceN = nozzle(k, { base: [L.xk, yc + Rj, 0], dir: [0, 1, 0], nps: L.kn, rating: p.rating, standout: L.so(L.kn), pad: pipeOD(L.kn) * 1.8 })\n  k.with(frame(kFaceN, [0, 1, 0], [0, 0, 1]), () => ballValve(k, L.kn, p.rating, L.LvK, L.kn >= 6 ? 'gear' : 'lever', true))\n  // vent nozzle + valve\n  const vFaceN = nozzle(k, { base: [L.xvent, yc + Rj, 0], dir: [0, 1, 0], nps: L.vn, rating: p.rating, standout: L.so(L.vn) })\n  k.with(frame(vFaceN, [0, 1, 0], [0, 0, 1]), () => ballValve(k, L.vn, p.rating, L.LvV, 'lever', false))\n  // drain nozzle + valve\n  const dFaceN = nozzle(k, { base: [L.xd, yc - Rj, 0], dir: [0, -1, 0], nps: L.dn, rating: p.rating, standout: L.dSo })\n  k.with(frame(dFaceN, [0, -1, 0], [0, 0, 1]), () => ballValve(k, L.dn, p.rating, L.LvD, 'lever', false))\n\n  // thermal-relief PSV (API 526): inlet on a nozzle, outlet flange towards -Z\n  const pr = L.psvRating\n  const pFace = nozzle(k, { base: [L.xpsv, yc + Rj, 0], dir: [0, 1, 0], nps: L.pd.inletNps, rating: pr, standout: L.so(L.pd.inletNps) })\n  {\n    const [x, y] = [pFace[0], pFace[1]]\n    const ri = pipeOD(L.pd.inletNps) / 2\n    const fi = flangeDims(L.pd.inletNps, pr)\n    flange(k, L.pd.inletNps, pr, [x, y + 0.0045, 0], '-y')\n    gasket(k, L.pd.inletNps, pr, [x, y + 0.00225, 0], 'y')\n    const yo = L.psvOut[1]\n    const yb0 = y + fi.hubLength + fi.raisedFaceHeight + 0.0045\n    k.cyl('valveBody', ri * 1.6, yo - yb0 + ri * 1.6, [x, (yb0 + yo + ri * 1.6) / 2, 0], 'y', 24)\n    const fo = flangeDims(L.pd.outletNps, L.pd.outletRating)\n    const flo = fo.hubLength + fo.raisedFaceHeight\n    const rO = pipeOD(L.pd.outletNps) / 2 - pipeWall(L.pd.outletNps, '80')\n    k.at([x, yo, 0], '-z', () => ring(k, 'valveBody', pipeOD(L.pd.outletNps) * 0.55, rO, ri * 1.2, -L.psvOut[2] - flo + 0.002, 24))\n    k.disc('pipeBore', rO, [x, yo, -ri * 1.6 - 0.002], '-z', 20)\n    flange(k, L.pd.outletNps, L.pd.outletRating, L.psvOut, '-z', true)\n    flangeBore(k, L.pd.outletNps, L.pd.outletRating, L.psvOut, '-z')\n    const yt = yo + ri * 1.6\n    const hB = Math.max(0.15, y + L.pd.height - yt - 0.07)\n    k.cyl('actuatorRed', ri * 1.3, hB, [x, yt + hB / 2, 0], 'y', 20, ri * 0.9)\n    k.cyl('actuatorRed', ri * 0.8, 0.07, [x, yt + hB + 0.035, 0], 'y', 16)\n    k.rod('handwheel', [x, yt + hB * 0.9, 0], [x + 0.16, yt + hB * 0.75, 0], 0.007, 6)\n  }\n\n  // balance (equalising) line on -Z: barrel behind the pig to the minor barrel, with its valve\n  {\n    const bn = 2\n    const fb = flangeDims(bn, p.rating)\n    const flb = fb.hubLength + fb.raisedFaceHeight\n    const g = 0.0045\n    const xb1 = L.xH0 - Math.max(0.55, p.length * 0.18)\n    const xb2 = L.xv1 + flL + L.minorLen * 0.75\n    const F1 = nozzle(k, { base: [xb1, yc, -Rj], dir: [0, 0, -1], nps: bn, rating: p.rating, standout: L.so(bn) })\n    const Lvb = faceToFace('ball-full', bn, p.rating)\n    k.with(frame(F1, [0, 0, -1], [0, 1, 0]), () => ballValve(k, bn, p.rating, Lvb, 'lever', false))\n    const zV = F1[2] - Lvb - g\n    flange(k, bn, p.rating, [xb1, yc, zV], 'z')\n    gasket(k, bn, p.rating, [xb1, yc, zV + g / 2], 'z', g)\n    const F2 = nozzle(k, { base: [xb2, yn, -Rn], dir: [0, 0, -1], nps: bn, rating: p.rating, standout: L.so(bn) })\n    flange(k, bn, p.rating, [xb2, yn, F2[2] - g], 'z', true)\n    gasket(k, bn, p.rating, [xb2, yn, F2[2] - g / 2], 'z', g)\n    const zRun = zV - flb - 0.18\n    const pts: V3[] = [[xb1, yc, zV - flb + 0.002], [xb1, yc, zRun], [xb2, yc, zRun]]\n    if (Math.abs(yc - yn) > 0.12) pts.push([xb2, yn, zRun])\n    pts.push([xb2, yn, F2[2] - g - flb + 0.002])\n    if (Math.abs(yc - yn) <= 0.12 && Math.abs(yc - yn) > 1e-4) pts.splice(pts.length - 1, 0, [xb2, yc, zRun * 0.5 + (F2[2] - g - flb) * 0.5])\n    pipeRun(k, pts, bn, { schedule: '160' })\n  }\n\n  // pig signaller on the minor barrel\n  {\n    const xs = L.xv1 + flL + Math.min(0.3, L.minorLen * 0.4)\n    const y0 = yn + Rn - 0.01\n    k.rod('equipmentPaint', [xs, y0, 0], [xs, y0 + 0.07, 0], 0.03, 16)\n    k.rod('flangeSteel', [xs, y0 + 0.07, 0], [xs, y0 + 0.095, 0], 0.06, 20)\n    k.rod('valveBody', [xs, y0 + 0.095, 0], [xs, y0 + 0.21, 0], 0.045, 16)\n    k.rod('valveBody', [xs, y0 + 0.21, 0], [xs, y0 + 0.24, 0], 0.03, 12)\n    k.box('actuatorRed', [0.012, 0.07, 0.05], [xs, y0 + 0.28, 0])\n    k.rod('handwheel', [xs, y0 + 0.15, 0], [xs, y0 + 0.15, 0.11], 0.008, 8)\n  }\n  // pressure gauge near the closure on the operator side (+Z)\n  {\n    const xg = L.xH0 - 0.6\n    const a = Math.PI / 4\n    const b: V3 = [xg, yc + Math.sin(a) * Rj, Math.cos(a) * Rj]\n    const d: V3 = [0, Math.sin(a), Math.cos(a)]\n    k.rod('equipmentPaint', [b[0], b[1] - d[1] * 0.02, b[2] - d[2] * 0.02], [b[0], b[1] + d[1] * 0.08, b[2] + d[2] * 0.08], 0.022, 12)\n    k.box('valveBody', [0.05, 0.05, 0.05], [b[0], b[1] + d[1] * 0.11, b[2] + d[2] * 0.11])\n    k.rod('stainless', [b[0], b[1] + d[1] * 0.13, b[2] + d[2] * 0.13], [b[0], b[1] + 0.3, b[2] + d[2] * 0.13], 0.008, 8)\n    gauge(k, [b[0], b[1] + 0.35, b[2] + d[2] * 0.13], 'z', 0.12, 'bar', p.rating >= 900 ? 250 : 160)\n  }\n\n  // bi-directional disc pig loaded in the barrel (seen with the closure open)\n  {\n    const rp = (Rn - wn) * 1.03\n    const lp = 1.7 * 2 * Rn\n    const xp = L.launcher ? L.xr1 + lp / 2 + 0.05 : L.xc - 0.08 - lp / 2\n    const yp = yc - (Rj - wj - rp) + 0.002\n    const pu = paint('#c4462e', { roughness: 0.62, metalness: 0 })\n    k.cyl('internalsSteel', rp * 0.32, lp - 0.06, [xp, yp, 0], 'x', 20)\n    for (const f of [0.02, 0.26, 0.5, 0.74, 0.98]) {\n      const x = xp - lp / 2 + f * lp\n      k.cyl(pu, f === 0.02 || f === 0.98 ? rp * 0.97 : rp, 0.024, [x, yp, 0], 'x', 40)\n      k.cyl('internalsSteel', rp * 0.6, 0.04, [x, yp, 0], 'x', 28)\n    }\n    k.sphere('rubberBlack', rp * 0.3, [xp - lp / 2 - 0.03, yp, 0], [1.4, 1, 1], 16)\n  }\n\n  // supports, nameplate, lifting lugs\n  support(k, L.xv1 + flL + L.minorLen * 0.55, yn, Rn)\n  support(k, L.xr1 + p.length * 0.2, yc, Rj)\n  support(k, L.xH0 - Math.max(0.75, p.length * 0.28), yc, Rj)\n  const xn = L.xr1 + p.length * 0.62\n  for (const dx of [-0.1, 0.1]) k.box('equipmentPaint', [0.03, 0.05, 0.05], [xn + dx, yc, Rj + 0.02])\n  k.box('nameplate', [0.26, 0.16, 0.004], [xn, yc, Rj + 0.047])\n  tagPlate(k, p.tag, [xn, yc + 0.11, Rj * 0.95], 'z', 0.2)\n  for (const x of [L.xr1 + 0.25, L.xH0 - 0.25]) liftingLug(k, [x, yc + Rj - 0.01, 0], 'x', 0.9)\n}\n\n\n/** Door, clamp band, screw and hinge arm, in the hinge-pin frame. */\nfunction buildDoor(k: Kit, p: P) {\n  const L = layout(p)\n  const { Rh, s, tD, dD, xc, yc } = L\n  const h = hingeAt(L)\n  k.at([-h[0], -h[1], -h[2]], undefined, () => {\n    const prof: [number, number][] = [[0, xc + 0.002], [Rh, xc + 0.002], [Rh, xc + tD]]\n    const a = 0.86 * Rh\n    for (let i = 0; i <= 16; i++) {\n      const t = (Math.PI / 2) * (i / 16)\n      prof.push([a * Math.cos(t), xc + tD + dD * Math.sin(t)])\n    }\n    k.lathe('equipmentPaint', prof, [0, yc, 0], 'x', 56)\n    k.cyl('equipmentPaintDark', 0.045 * s, 0.045 * s, [xc + tD + dD + 0.0125 * s, yc, 0], 'x', 24)\n    // split clamp band with lugs, screw and T-handle\n    const gap = 0.16\n    const bandR = Rh + 0.026 * s\n    k.lathe('flangeSteel', [[bandR, xc - 0.04 * s], [bandR, xc + tD * 0.8], [Rh + 0.0008, xc + tD * 0.8], [Rh + 0.0008, xc - 0.04 * s], [bandR, xc - 0.04 * s]], [0, yc, 0], 'x', 56, -Math.PI / 2 + gap + Math.PI, Math.PI * 2 - 2 * gap)\n    const lugZ = Math.sin(gap) * bandR + 0.022 * s\n    const xS = xc + tD * 0.4 - 0.02 * s\n    const yS = yc + Rh + 0.05 * s\n    for (const sz of [-1, 1]) k.box('flangeSteel', [0.07 * s, 0.085 * s, 0.03 * s], [xS, yc + Rh + 0.035 * s, sz * lugZ])\n    k.rod('stainless', [xS, yS, -lugZ - 0.07 * s], [xS, yS, lugZ + 0.04 * s], 0.012 * s, 10)\n    k.hex('nut', 0.035 * s, 0.022 * s, [xS, yS, lugZ + 0.025 * s], 'z')\n    k.hex('handwheel', 0.04 * s, 0.03 * s, [xS, yS, -lugZ - 0.06 * s], 'z')\n    k.rod('handwheel', [xS, yS, -lugZ - 0.08 * s], [xS, yS, -lugZ - 0.11 * s], 0.012 * s, 8)\n    k.rod('handwheel', [xS - 0.16 * s, yS, -lugZ - 0.11 * s], [xS + 0.16 * s, yS, -lugZ - 0.11 * s], 0.011 * s, 8)\n    // handle on the door face\n    const xF = xc + tD + dD * 0.6\n    k.rod('handwheel', [xF, yc - 0.12 * s, Rh * 0.45], [xF + 0.06 * s, yc - 0.12 * s, Rh * 0.45], 0.012 * s, 8)\n    k.rod('handwheel', [xF, yc + 0.12 * s, Rh * 0.45], [xF + 0.06 * s, yc + 0.12 * s, Rh * 0.45], 0.012 * s, 8)\n    k.rod('handwheel', [xF + 0.06 * s, yc - 0.13 * s, Rh * 0.45], [xF + 0.06 * s, yc + 0.13 * s, Rh * 0.45], 0.014 * s, 8)\n    // hinge arm from the pin to the door boss\n    const xA = xc + tD + dD + 0.06 * s\n    for (const y of [-0.1 * s, 0.06 * s]) k.cyl('equipmentPaintDark', 0.038 * s, 0.04 * s, [h[0], yc + y + 0.02 * s, h[2]], 'y', 16)\n    k.box('equipmentPaintDark', [xA - h[0], 0.07 * s, 0.03 * s], [(xA + h[0]) / 2, yc, h[2]])\n    k.box('equipmentPaintDark', [0.04 * s, 0.07 * s, -h[2]], [xA, yc, h[2] / 2])\n    k.rod('equipmentPaintDark', [xA, yc, 0], [xc + tD + dD, yc, 0], 0.03 * s, 12)\n  })\n}\n"
    },
    {
      "path": "wellhead/_wellparts.ts",
      "type": "registry:file",
      "target": "components/deck-twin/wellhead/_wellparts.ts",
      "content": "// Shared API 6A wellhead parts: RTJ joints, studded faces, slab gate valves (handwheel / hydraulic), angle chokes, crossovers, control-line tubing.\nimport * as THREE from 'three'\nimport { eul, type Kit, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { flange as asmeFlange, tagPlate } from '../lib/parts'\nimport { flange as flangeDims, pipeOD, pipeWall, type Rating, type Schedule } from '../lib/standards'\nimport type { ApiFlange } from './_api6a'\n\nexport const BODY: MatKey = 'valveBody'\nexport const SPOOL: MatKey = paint('#5d646b', { metalness: 0.4, roughness: 0.5 })\nexport const ACT: MatKey = 'actuatorYellow'\nexport const HW: MatKey = 'actuatorRed'\nexport const STUD: MatKey = 'boltXylan'\nexport const NUT: MatKey = 'nut'\nexport const SS: MatKey = 'stainless'\n/** half the ring-gasket gap at each RTJ joint */\nexport const G = 0.003\n\n/** Frame with local +Y = flow, +Z = stem / bonnet, +X = flow × stem, origin at o. */\nexport function frame(o: V3, flow: V3, stem: V3) {\n  const y = new THREE.Vector3(...flow).normalize()\n  const z = new THREE.Vector3(...stem).normalize()\n  const x = new THREE.Vector3().crossVectors(y, z).normalize()\n  return new THREE.Matrix4().makeBasis(x, y, z).setPosition(...o)\n}\nexport const at = (M: THREE.Matrix4, p: V3) => new THREE.Vector3(...p).applyMatrix4(M).toArray() as V3\n\n/** 1/4\"–1/2\" stainless control-line tubing along a polyline with small bends. */\nexport function tubing(k: Kit, pts: V3[], r = 0.0048) {\n  k.tube('tubing', pts, r, 6, Math.max(0.025, r * 6))\n}\n\n/** Tube (hollow cylinder) along local Y from y0 to y1, outer radius ro, bore ri, with a dark bore liner. */\nexport function ring(k: Kit, mat: MatKey, ro: number, ri: number, y0: number, y1: number, seg = 28, liner = true) {\n  k.lathe(mat, [[ri, y0], [ro, y0], [ro, y1], [ri, y1]], [0, 0, 0], 'y', seg)\n  if (liner) bore(k, ri, y0, y1)\n}\n\n/** Dark inward-facing bore liner of radius r between y0 and y1 (local Y). */\nexport function bore(k: Kit, r: number, y0: number, y1: number, seg = 24) {\n  k.lathe('pipeBore', [[r, Math.max(y0, y1)], [r, Math.min(y0, y1)]], [0, 0, 0], 'y', seg)\n}\n\n/** Forged block (rounded in plan) w × h × d centred at c, with a through bore of radius r along local Y. */\nexport function boredBlock(k: Kit, mat: MatKey, size: V3, r: number, c: V3, round = 0.02, liner = true) {\n  const [w, h, d] = size\n  const rr = Math.min(round, w / 2 - 1e-3, d / 2 - 1e-3)\n  const sh = new THREE.Shape()\n  sh.moveTo(-w / 2 + rr, -d / 2)\n  sh.lineTo(w / 2 - rr, -d / 2)\n  sh.quadraticCurveTo(w / 2, -d / 2, w / 2, -d / 2 + rr)\n  sh.lineTo(w / 2, d / 2 - rr)\n  sh.quadraticCurveTo(w / 2, d / 2, w / 2 - rr, d / 2)\n  sh.lineTo(-w / 2 + rr, d / 2)\n  sh.quadraticCurveTo(-w / 2, d / 2, -w / 2, d / 2 - rr)\n  sh.lineTo(-w / 2, -d / 2 + rr)\n  sh.quadraticCurveTo(-w / 2, -d / 2, -w / 2 + rr, -d / 2)\n  const hole = new THREE.Path()\n  hole.absarc(0, 0, r, 0, Math.PI * 2, true)\n  sh.holes.push(hole)\n  // extrude along +Z, then turn Z onto +Y (shape y maps to -Z)\n  const m = new THREE.Matrix4().makeRotationX(-Math.PI / 2).setPosition(c[0], c[1] - h / 2, c[2])\n  k.extrude(mat, sh, h, m, 16)\n  if (liner) k.at([c[0], 0, c[2]], undefined, () => bore(k, r, c[1] - h / 2, c[1] + h / 2))\n}\n\n/** Hollow ellipsoidal ball-valve body about local Y centred at y = yc: radius R, half-height R*sy, bore r. */\nexport function hollowBall(k: Kit, mat: MatKey, R: number, sy: number, r: number, yc: number) {\n  const prof: [number, number][] = []\n  const a0 = -Math.acos(Math.min(1, r / R))\n  for (let i = 0; i <= 20; i++) {\n    const a = a0 + ((-2 * a0) * i) / 20\n    prof.push([Math.max(r, R * Math.cos(a)), yc + R * sy * Math.sin(a)])\n  }\n  prof.unshift([r, yc + R * sy * Math.sin(a0)])\n  prof.push([r, yc - R * sy * Math.sin(a0)])\n  k.lathe(mat, prof, [0, 0, 0], 'y', 32)\n}\n\n/** Closes a B16.5 flange bore: machined annulus at the RF face down to the pipe bore, plus a bore liner back through the hub. */\nexport function flangeBore(k: Kit, nps: number, rating: Rating, face: V3, axis: 'x' | '-x' | 'y' | '-y' | 'z' | '-z', schedule: Schedule = '80') {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const ri = ro - pipeWall(nps, schedule)\n  k.at(face, axis, () => {\n    k.lathe('machined', [[ro * 0.88, 0], [ri, 0]], [0, 0, 0], 'y', 28)\n    k.lathe('pipeBore', [[ri, 0], [ri, -(f.hubLength + f.raisedFaceHeight) - 0.004]], [0, 0, 0], 'y', 24)\n  })\n}\n\nconst nutH = (d: ApiFlange) => d.boltDia * 0.95\nconst seg = (d: ApiFlange) => (d.bolts >= 12 ? 40 : 28)\n\n/** Studs and nuts through two equal API flanges meeting at y = yj, with the ring gasket showing in the gap. */\nexport function joint(k: Kit, d: ApiFlange, yj: number) {\n  const so = d.boltDia * 0.4\n  const len = 2 * (G + d.t) + 2 * nutH(d) + 2 * so\n  k.boltCircle(STUD, NUT, d.bolts, d.boltCircle / 2, [0, yj - len / 2, 0], 'y', d.boltDia, len, [so + nutH(d) / 2, len - so - nutH(d) / 2])\n  k.torus(SS, d.ring / 2, Math.max(0.004, d.boltDia * 0.18), [0, yj, 0], 'y', 4, 28)\n}\n\n/** Studs screwed into a studded block face at y = yf (block below), nut on the flange of the part above. */\nexport function studded(k: Kit, d: ApiFlange, yf: number) {\n  const so = d.boltDia * 0.4\n  const inBlock = d.boltDia * 1.2\n  const len = inBlock + G + d.t + nutH(d) + so\n  k.boltCircle(STUD, NUT, d.bolts, d.boltCircle / 2, [0, yf - inBlock, 0], 'y', d.boltDia, len, [inBlock + G + d.t + nutH(d) / 2])\n  k.torus(SS, d.ring / 2, Math.max(0.004, d.boltDia * 0.18), [0, yf, 0], 'y', 4, 28)\n}\n\n/** Integral API flange with its ring-joint face at y = face, body on side s (+1 above, -1 below). */\nexport function flangeAt(k: Kit, d: ApiFlange, face: number, s: 1 | -1, mat: MatKey = BODY) {\n  const y0 = face + s * G\n  const y1 = face + s * (G + d.t)\n  const lo = Math.min(y0, y1)\n  const hi = Math.max(y0, y1)\n  const ch = Math.min(0.006, d.t * 0.12)\n  // small chamfer on the back edge\n  const back = s > 0 ? hi : lo\n  const front = s > 0 ? lo : hi\n  k.lathe(mat, [[d.bore * 0.0127, front], [d.od / 2, front], [d.od / 2, back - s * ch], [d.od / 2 - ch, back], [d.bore * 0.0127, back]], [0, 0, 0], 'y', seg(d))\n}\n\nexport type Operator = { kind: 'handwheel'; mat?: MatKey; D: number } | { kind: 'hydraulic'; r: number; len: number; fusible?: boolean }\n\n/** Slab gate valve between flange faces y = 0 and y = L, bonnet along +Z. Returns the hydraulic supply port (local) when actuated. */\nexport function gateValve(k: Kit, d: ApiFlange, L: number, op: Operator, mat: MatKey = BODY): V3 | null {\n  flangeAt(k, d, 0, 1, mat)\n  flangeAt(k, d, L, -1, mat)\n  const inner = L - 2 * (G + d.t)\n  const rb = d.bore * 0.0127\n  ring(k, mat, d.od * 0.3, rb, G + d.t - 0.002, L - G - d.t + 0.002, 24, false)\n  bore(k, rb, G, L - G)\n  const w = d.od * 0.82\n  const dz = d.od * 0.95\n  const hb = Math.max(0.05, Math.min(d.od * 1.05, inner - 0.02))\n  const yc = L / 2\n  boredBlock(k, mat, [w, hb, dz], rb, [0, yc, 0], Math.min(0.025, w * 0.1), false)\n  // body sealant injection + seat grease fittings\n  k.cyl('brass', 0.008, 0.03, [w / 2 + 0.012, yc + hb * 0.25, 0], 'x', 8)\n  k.hex('brass', 0.017, 0.012, [w / 2 + 0.029, yc + hb * 0.25, 0], 'x')\n  k.hex('brass', 0.015, 0.012, [-w / 2 - 0.006, yc - hb * 0.2, dz * 0.2], 'x')\n  // bonnet flange, nuts, bonnet\n  const rbf = Math.min(w * 0.5, hb * 0.5)\n  let z = dz / 2\n  const tbf = Math.max(0.03, d.t * 0.6)\n  k.cyl(mat, rbf, tbf, [0, yc, z + tbf / 2], 'z', 28)\n  k.around(8, rbf * 0.8, [0, yc, z + tbf + 0.008], 'z', (p) => k.hex(NUT, Math.max(0.022, d.boltDia * 1.3), 0.016, p, 'z'), Math.PI / 8)\n  z += tbf\n  const bl = 0.07 + d.od * 0.18\n  const rbo = rbf * 0.66\n  k.cyl(mat, rbo, bl, [0, yc, z + bl / 2], 'z', 24)\n  z += bl\n  if (op.kind === 'handwheel') {\n    k.cyl(mat, rbo * 0.62, 0.04, [0, yc, z + 0.02], 'z', 18)\n    k.hex('brass', 0.016, 0.01, [rbo * 0.62, yc, z + 0.02], 'x')\n    const sr = Math.max(0.011, d.od * 0.045)\n    k.cyl(SS, sr, 0.12, [0, yc, z + 0.06], 'z', 12)\n    k.handwheel(op.mat ?? HW, op.D, [0, yc, z + 0.1], 'z', 4)\n    k.hex(NUT, sr * 2.4, 0.02, [0, yc, z + 0.12], 'z')\n    return null\n  }\n  // hydraulic piston actuator: adapter, cylinder with caps and tie rods, stem protector with position band\n  const { r, len } = op\n  k.cyl(mat, rbo * 0.85, 0.05, [0, yc, z + 0.025], 'z', 24)\n  z += 0.05\n  k.cyl(ACT, r * 1.08, 0.03, [0, yc, z + 0.015], 'z', 32)\n  k.cyl(ACT, r, len, [0, yc, z + 0.03 + len / 2], 'z', 32)\n  k.cyl(ACT, r * 1.08, 0.035, [0, yc, z + 0.03 + len + 0.0175], 'z', 32)\n  k.around(4, r * 1.04, [0, yc, 0], 'z', (p) => {\n    k.rod(SS, [p[0], p[1], z], [p[0], p[1], z + 0.065 + len + 0.012], 0.007, 8)\n    k.hex(NUT, 0.02, 0.012, [p[0], p[1], z + 0.065 + len + 0.006], 'z')\n  }, Math.PI / 4)\n  const zt = z + 0.065 + len\n  k.cyl(SS, 0.02, 0.1, [0, yc, zt + 0.05], 'z', 14)\n  k.cyl('safetyGreen', 0.023, 0.025, [0, yc, zt + 0.03], 'z', 14)\n  if (op.fusible) {\n    // heat-sensitive lock-open device: collar, segmented lock, fusible plug, manual release lever\n    k.cyl(SS, 0.045, 0.06, [0, yc, zt + 0.13], 'z', 20)\n    k.cyl(HW, 0.047, 0.012, [0, yc, zt + 0.12], 'z', 20)\n    k.around(4, 0.045, [0, yc, zt + 0.14], 'z', (q) => k.box(SS, [0.018, 0.018, 0.02], q), Math.PI / 4)\n    k.cyl('brass', 0.012, 0.03, [0.055, yc, zt + 0.13], 'x', 10)\n    k.box(HW, [0.012, 0.09, 0.012], [0, yc + 0.07, zt + 0.15])\n  } else k.cyl(ACT, 0.03, 0.012, [0, yc, zt + 0.104], 'z', 14)\n  const zp = z + 0.03 + len * 0.22\n  k.cyl('brass', 0.012, 0.04, [r + 0.012, yc, zp], 'x', 10)\n  k.hex('brass', 0.026, 0.016, [r + 0.036, yc, zp], 'x')\n  k.cyl('nameplate', r * 1.002, 0.04, [0, yc, z + 0.03 + len * 0.62], 'z', 32)\n  return [r + 0.046, yc, zp]\n}\n\n/** Angle choke geometry: inlet face distance along -X, outlet face distance along -Y, from the body centre. */\nexport function chokeDims(dIn: ApiFlange, dOut: ApiFlange) {\n  const B = Math.max(dIn.od, dOut.od) * 0.78\n  return { B, inlet: B / 2 + 0.05 + dIn.t + G, outlet: B / 2 + 0.09 + dOut.t + G }\n}\n\nexport type ChokeOp = 'handwheel' | 'actuator' | 'positive'\n\n/**\n * API 6A angle choke, body centre at the origin: inlet flange face at x = -inlet facing -X, outlet face at y = -outlet\n * facing -Y, bonnet / operator along +X. Returns the far end of the operator (x).\n */\nexport function angleChoke(k: Kit, dIn: ApiFlange, dOut: ApiFlange, op: ChokeOp, opening = 0.5): number {\n  const { B, inlet, outlet } = chokeDims(dIn, dOut)\n  // forged block body with inlet and outlet necks\n  k.rbox(BODY, [B, B, B * 0.92], Math.min(0.03, B * 0.12), [0, 0, 0])\n  const ri = dIn.bore * 0.0127\n  const ro = dOut.bore * 0.0127\n  k.at([0, 0, 0], '-x', () => {\n    ring(k, BODY, dIn.od * 0.3, ri, B / 2 - 0.01, inlet - dIn.t - G + 0.002, 24)\n    bore(k, ri, inlet - dIn.t - G, inlet - G)\n  })\n  k.at([0, 0, 0], '-y', () => {\n    ring(k, BODY, dOut.od * 0.32, ro, B / 2 - 0.01, outlet - dOut.t - G + 0.002, 24)\n    bore(k, ro, outlet - dOut.t - G, outlet - G)\n  })\n  // cage trim and seat seen down the bores\n  k.disc('internalsSteel', ri * 1.02, [-B / 2 + 0.012, 0, 0], '-x', 24)\n  k.disc('internalsSteel', ro * 1.02, [0, -B / 2 + 0.012, 0], '-y', 24)\n  k.cyl('pipeBore', ri * 0.35, 0.004, [-B / 2 + 0.014, 0, 0], 'x', 16)\n  k.at([-inlet, 0, 0], '-x', () => flangeAt(k, dIn, 0, -1))\n  k.at([0, -outlet, 0], '-y', () => flangeAt(k, dOut, 0, -1))\n  // wear sleeve body drain / bleed plug\n  k.hex('brass', 0.022, 0.016, [0, B * 0.25, B * 0.46 + 0.008], 'z')\n  // bonnet: hammer-nut with three lugs\n  let x = B / 2\n  const rn = B * 0.42\n  k.cyl(BODY, rn * 0.9, 0.02, [x + 0.01, 0, 0], 'x', 24)\n  x += 0.02\n  k.cyl(BODY, rn, 0.07, [x + 0.035, 0, 0], 'x', 24)\n  for (let i = 0; i < 3; i++) {\n    const a = (i / 3) * Math.PI * 2 + Math.PI / 6\n    k.box(BODY, [0.06, B * 0.2, B * 0.13], [x + 0.035, Math.cos(a) * (rn + B * 0.08), Math.sin(a) * (rn + B * 0.08)], eul(-a, 0, 0))\n  }\n  x += 0.07\n  if (op === 'positive') {\n    // blanking cap over a fixed bean\n    k.cyl(BODY, rn * 0.75, 0.05, [x + 0.025, 0, 0], 'x', 20)\n    k.hex('brass', 0.03, 0.02, [x + 0.06, 0, 0], 'x')\n    return x + 0.07\n  }\n  k.cyl(BODY, rn * 0.55, 0.1, [x + 0.05, 0, 0], 'x', 20)\n  // stem lock (holds the setting when run as a fixed choke)\n  k.rod(SS, [x + 0.05, rn * 0.5, 0], [x + 0.05, rn * 0.5 + 0.05, 0], 0.008, 8)\n  k.rod(HW, [x + 0.05, rn * 0.5 + 0.05, -0.04], [x + 0.05, rn * 0.5 + 0.05, 0.04], 0.007, 8)\n  x += 0.1\n  // position indicator sleeve with brass scale ring; stem travel shown by the opening\n  k.cyl(SS, rn * 0.4, 0.07, [x + 0.035, 0, 0], 'x', 18)\n  k.cyl('brass', rn * 0.42, 0.012, [x + 0.02, 0, 0], 'x', 18)\n  k.box('actuatorRed', [0.05, 0.004, 0.012], [x + 0.035, rn * 0.4 + 0.002, 0])\n  x += 0.07\n  if (op === 'actuator') {\n    // electro-hydraulic stepping actuator\n    k.cyl(BODY, rn * 0.35, 0.05, [x + 0.025, 0, 0], 'x', 14)\n    x += 0.05\n    k.rbox(ACT, [0.17, 0.2, 0.16], 0.02, [x + 0.085, 0, 0])\n    k.cyl(ACT, 0.05, 0.14, [x + 0.085, 0.0, 0.13], 'z', 18)\n    k.cyl(NUT, 0.052, 0.012, [x + 0.085, 0, 0.2], 'z', 18)\n    k.box('nameplate', [0.05, 0.08, 0.002], [x + 0.09, 0, -0.081])\n    k.hex('brass', 0.024, 0.02, [x + 0.05, 0.1 + 0.01, 0], 'y')\n    return x + 0.17\n  }\n  const s = 0.02 + 0.06 * opening\n  k.cyl(SS, 0.014, 0.06 + s, [x + (0.06 + s) / 2, 0, 0], 'x', 10)\n  k.handwheel(HW, Math.max(0.25, B * 1.2), [x + 0.04 + s, 0, 0], 'x', 4)\n  return x + 0.06 + s\n}\n\n/** Crossover: API flange (ring face at y = 0, body above) on a forged neck ending in a B16.5 weld-neck flange whose RF face is at y = L. */\nexport function crossover(k: Kit, d: ApiFlange, nps: number, rating: Rating, L: number) {\n  flangeAt(k, d, 0, 1, 'flangeSteel')\n  const rp = pipeOD(nps) / 2\n  const y0 = G + d.t\n  const fl = asmeFlange(k, nps, rating, [0, L, 0], 'y', true)\n  flangeBore(k, nps, rating, [0, L, 0], 'y', '160')\n  const yN = L - fl\n  if (yN > y0) {\n    k.lathe('flangeSteel', [[rp - pipeWall(nps, '160'), y0], [Math.max(rp * 1.3, d.od * 0.3), y0], [rp * 1.04, y0 + Math.min(0.05, (yN - y0) * 0.6)], [rp, yN + 0.002], [rp - pipeWall(nps, '160'), yN + 0.002]], [0, 0, 0], 'y', 28)\n    k.torus('flangeSteel', rp + 0.0008, Math.max(0.0025, rp * 0.03), [0, yN, 0], 'y', 6, 28)\n  }\n}\n\n/** Minimum crossover length so the API flange, a short neck and the B16.5 weld-neck all fit. */\nexport function crossoverLength(d: ApiFlange, nps: number, rating: Rating, neck = 0.06) {\n  const f = flangeDims(nps, rating)\n  return G + d.t + neck + f.hubLength + f.raisedFaceHeight\n}\n\n/** Plain flanged spacer spool between API faces 0 and L. */\nexport function spacer(k: Kit, d: ApiFlange, L: number, mat: MatKey = BODY) {\n  flangeAt(k, d, 0, 1, mat)\n  flangeAt(k, d, L, -1, mat)\n  ring(k, mat, d.od * 0.3, d.bore * 0.0127, G + d.t - 0.002, L - G - d.t + 0.002, 24, false)\n  bore(k, d.bore * 0.0127, G, L - G)\n}\n\n/** Flanged flow tee between faces 0 and L with an instrument branch (+Z) and needle valve. Returns the branch top (z). */\nexport function flowTee(k: Kit, d: ApiFlange, L: number) {\n  spacer(k, d, L)\n  const r = d.od * 0.3\n  k.cyl(BODY, r * 0.55, r + 0.04, [0, L / 2, (r + 0.04) / 2], 'z', 16)\n  const z = r + 0.04\n  needleValve(k, [0, L / 2, z], 'z')\n  return z + 0.105\n}\n\n/** 1/2\" needle valve on a boss, axis along +axis from base, handle to the side. */\nexport function needleValve(k: Kit, base: V3, axis: 'y' | 'z' | 'x' | '-x', handle: MatKey = HW) {\n  const v = axis === 'y' ? [0, 1, 0] : axis === 'z' ? [0, 0, 1] : axis === 'x' ? [1, 0, 0] : [-1, 0, 0]\n  const p = (t: number): V3 => [base[0] + v[0] * t, base[1] + v[1] * t, base[2] + v[2] * t]\n  k.hex(SS, 0.032, 0.02, p(0.01), axis)\n  k.box(SS, [0.036, 0.036, 0.036], p(0.042))\n  const side: V3 = axis === 'y' ? [1, 0, 0] : [0, 1, 0]\n  const q = p(0.042)\n  k.rod(SS, q, [q[0] + side[0] * 0.05, q[1] + side[1] * 0.05, q[2] + side[2] * 0.05], 0.006, 8)\n  k.box(handle, axis === 'y' ? [0.012, 0.012, 0.06] : [0.06, 0.012, 0.012], [q[0] + side[0] * 0.055, q[1] + side[1] * 0.055, q[2] + side[2] * 0.055])\n  k.rod(SS, p(0.06), p(0.105), 0.008, 8)\n}\n\n/** Exposed API ring-joint face at y = 0 (flange body below): machined face, ring groove, studs projecting out with nuts on the back. */\nexport function openFace(k: Kit, d: ApiFlange) {\n  const rb = d.bore * 0.0127\n  k.lathe('machined', [[d.ring / 2 + d.boltDia * 0.6, -G + 0.0008], [rb, -G + 0.0008]], [0, 0, 0], 'y', 32)\n  k.torus('pipeBore', d.ring / 2, Math.max(0.003, d.boltDia * 0.16), [0, -G + 0.0008, 0], 'y', 6, 32)\n  const len = d.t + nutH(d) * 2 + d.boltDia * 0.6\n  k.boltCircle(STUD, NUT, d.bolts, d.boltCircle / 2, [0, -G - d.t - nutH(d) - d.boltDia * 0.3, 0], 'y', d.boltDia, len, [nutH(d) / 2])\n}\n\nexport type BallOp = 'gear' | 'lever' | 'actuator'\n\n/**\n * API 6D full-bore ball valve, flow along +Y between RF faces y = 0 (facing -Y) and y = Lv (facing +Y), operator on +Z.\n * Studs on the outlet face only (the inlet bolts to a studded flange). Returns the operator's far z.\n */\nexport function ballValve(k: Kit, nps: number, rating: Rating, Lv: number, op: BallOp = 'gear', open = true, studsIn = false, tag?: string) {\n  const f = flangeDims(nps, rating)\n  const D = pipeOD(nps)\n  asmeFlange(k, nps, rating, [0, 0, 0], '-y', studsIn)\n  asmeFlange(k, nps, rating, [0, Lv, 0], 'y', true)\n  const fl = f.hubLength + f.raisedFaceHeight\n  const Rb = Math.max(f.od * 0.5, D * 0.95)\n  const rB = D / 2 - pipeWall(nps, '80')\n  ring(k, 'valveBody', D * 0.6, rB, fl - 0.01, Lv - fl + 0.01, 28, false)\n  hollowBall(k, 'valveBody', Rb, Math.min(1, (Lv - 2 * fl) / (2 * Rb) + 0.3), rB, Lv / 2)\n  bore(k, rB, 0, Lv)\n  flangeBore(k, nps, rating, [0, 0, 0], '-y')\n  flangeBore(k, nps, rating, [0, Lv, 0], 'y')\n  k.at([0, Lv / 2 - Rb * 0.3, 0], undefined, () => ring(k, 'valveBody', Rb * 1.02, D * 0.6, -f.boltDia * 0.7, f.boltDia * 0.7, 28, false))\n  k.around(8, Rb * 0.94, [0, Lv / 2 - Rb * 0.3 + f.boltDia * 0.7, 0], 'y', (q) => k.hex(NUT, f.boltDia * 1.6, f.boltDia * 0.8, q, 'y'), Math.PI / 8)\n  k.cyl('valveBody', Rb * 0.3, Rb * 0.3, [0, Lv / 2, -Rb * 0.92], 'z', 16)\n  k.hex('brass', 0.02, 0.016, [Rb * 0.88, Lv / 2 + Rb * 0.2, 0], 'x')\n  if (tag) tagPlate(k, tag, [-Rb * 0.5, Lv / 2, Rb * 0.6], 'z', Math.min(0.12, Rb * 0.8))\n  const z0 = Rb * 0.85\n  k.cyl('valveBody', Rb * 0.3, 0.06, [0, Lv / 2, z0 + 0.03], 'z', 18)\n  k.cyl('valveBody', Rb * 0.4, 0.02, [0, Lv / 2, z0 + 0.07], 'z', 18)\n  const zt = z0 + 0.08\n  const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), open ? 0 : Math.PI / 2)\n  if (op === 'lever') {\n    k.cyl(SS, 0.012, 0.04, [0, Lv / 2, zt + 0.02], 'z', 10)\n    const len = Math.max(0.2, 0.15 + 0.06 * nps)\n    const a = open ? Math.PI / 2 : 0\n    k.box(HW, [0.03, len, 0.012], [Math.cos(a) * len * 0.45, Lv / 2 + Math.sin(a) * len * 0.45, zt + 0.045], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), a - Math.PI / 2))\n    return zt + 0.06\n  }\n  if (op === 'gear') {\n    const gb = Math.max(0.12, D * 0.9)\n    k.rbox('actuatorBlue', [gb * 1.1, gb * 0.9, gb * 0.6], gb * 0.06, [0, Lv / 2, zt + gb * 0.3])\n    k.cyl('actuatorBlue', gb * 0.16, gb * 0.35, [gb * 0.75, Lv / 2, zt + gb * 0.3], 'x', 14)\n    k.handwheel('handwheel', Math.max(0.25, gb * 2.2), [gb * 0.95, Lv / 2, zt + gb * 0.3], 'x', 5)\n    k.box(open ? 'safetyGreen' : HW, [0.025, gb * 0.7, 0.012], [0, Lv / 2, zt + gb * 0.6 + 0.006], q)\n    return zt + gb * 0.6\n  }\n  // scotch-yoke actuator with spring can (fail-close), hydraulic cylinder, limit-switch box\n  const a = Math.max(0.18, D * 1.25)\n  const ya = Lv / 2\n  const za = zt + a * 0.4\n  k.rbox('actuatorYellow', [a * 1.0, a * 0.9, a * 0.8], a * 0.06, [0, ya, za])\n  k.cyl('actuatorYellow', a * 0.42, a * 1.9, [-a * 0.5 - a * 0.95, ya, za], 'x', 32)\n  k.cyl('actuatorYellow', a * 0.46, a * 0.07, [-a * 0.5 - a * 1.9, ya, za], 'x', 32)\n  k.cyl('actuatorYellow', a * 0.46, a * 0.07, [-a * 0.5 - 0.02, ya, za], 'x', 32)\n  k.around(6, a * 0.44, [0, ya, za], 'x', (pp) => k.rod(SS, [-a * 0.5, pp[1], pp[2]], [-a * 0.5 - a * 1.95, pp[1], pp[2]], 0.008, 6))\n  k.cyl('actuatorYellow', a * 0.26, a * 0.95, [a * 0.5 + a * 0.475, ya, za], 'x', 24)\n  k.cyl('actuatorYellow', a * 0.29, a * 0.05, [a * 0.5 + a * 0.95, ya, za], 'x', 24)\n  k.around(4, a * 0.27, [0, ya, za], 'x', (pp) => k.rod(SS, [a * 0.5, pp[1], pp[2]], [a * 0.5 + a * 0.98, pp[1], pp[2]], 0.006, 6), Math.PI / 4)\n  k.rbox('stainless', [a * 0.45, a * 0.3, a * 0.28], 0.01, [0, ya, za + a * 0.4 + a * 0.14])\n  k.cyl(open ? 'safetyGreen' : HW, a * 0.08, 0.03, [0, ya, za + a * 0.68 + 0.015], 'z', 14)\n  k.rbox('stainless', [a * 0.35, a * 0.28, a * 0.22], 0.01, [a * 0.3, ya - a * 0.6, za])\n  tubingRun(k, [[a * 0.3, ya - a * 0.6 - a * 0.14, za], [a * 0.3, ya - a * 0.75, za], [a * 0.9, ya - a * 0.75, za], [a * 0.9, ya, za - a * 0.2]])\n  return za + a * 0.7\n}\n\nfunction tubingRun(k: Kit, pts: V3[]) {\n  k.tube('tubing', pts, 0.005, 6, 0.03)\n}\n"
    },
    {
      "path": "wellhead/_api6a.ts",
      "type": "registry:file",
      "target": "components/deck-twin/wellhead/_api6a.ts",
      "content": "// API 6A type 6B / 6BX flange and full-bore gate valve dimensions (typical, from published 6A tables; verify against the current edition).\nimport { inch, nearest, type Rating } from '../lib/standards'\n\nexport type Psi = 2000 | 3000 | 5000 | 10000 | 15000\n\nexport interface ApiFlange {\n  /** nominal bore, inches (e.g. 2.0625) */\n  bore: number\n  od: number\n  /** total thickness, ring-joint face to back */\n  t: number\n  bolts: number\n  boltDia: number\n  boltCircle: number\n  /** ring groove pitch diameter (approx) */\n  ring: number\n  /** 6BX (true for 10k+, and for 13-5/8 and up at 5k) */\n  bx: boolean\n}\n\n// inches: [OD, thickness, bolt circle, bolt count, bolt dia]\ntype Row = [od: number, t: number, bc: number, n: number, bd: number]\n\nconst T: Record<Psi, Record<number, Row>> = {\n  2000: {\n    2.0625: [6.5, 1.31, 5.0, 8, 0.625], 2.5625: [7.5, 1.44, 5.88, 8, 0.75], 3.125: [8.25, 1.56, 6.62, 8, 0.75],\n    4.0625: [10.75, 1.81, 8.5, 8, 0.875], 7.0625: [14.0, 2.19, 11.5, 12, 1.0], 9: [16.5, 2.5, 13.75, 12, 1.125],\n    11: [20.0, 2.81, 17.0, 16, 1.25], 13.625: [22.0, 2.94, 19.25, 20, 1.25], 16.75: [27.0, 3.31, 23.75, 20, 1.5],\n    21.25: [32.0, 3.88, 28.5, 24, 1.625],\n  },\n  3000: {\n    2.0625: [8.5, 1.81, 6.5, 8, 0.875], 2.5625: [9.62, 1.94, 7.5, 8, 1.0], 3.125: [9.5, 1.81, 7.5, 8, 0.875],\n    4.0625: [11.5, 2.06, 9.25, 8, 1.125], 7.0625: [15.0, 2.5, 12.5, 12, 1.125], 9: [18.5, 2.81, 15.5, 12, 1.375],\n    11: [21.5, 3.06, 18.5, 16, 1.375], 13.625: [24.0, 3.44, 21.0, 20, 1.375], 16.75: [27.75, 3.94, 24.25, 20, 1.625],\n    20.75: [33.75, 4.75, 29.5, 20, 2.0],\n  },\n  5000: {\n    2.0625: [8.5, 1.81, 6.5, 8, 0.875], 2.5625: [9.62, 1.94, 7.5, 8, 1.0], 3.125: [10.5, 2.19, 8.0, 8, 1.125],\n    4.0625: [12.25, 2.44, 9.5, 8, 1.25], 5.125: [14.75, 3.19, 11.5, 8, 1.5], 7.0625: [15.5, 3.62, 12.5, 12, 1.375],\n    9: [19.0, 4.06, 15.5, 12, 1.625], 11: [23.0, 4.69, 19.0, 12, 1.875], 13.625: [26.5, 4.44, 23.25, 16, 1.625],\n    16.75: [30.38, 5.12, 26.62, 16, 1.875], 21.25: [39.38, 7.12, 35.38, 24, 2.0],\n  },\n  10000: {\n    1.8125: [7.38, 1.66, 5.75, 8, 0.75], 2.0625: [7.88, 1.73, 6.25, 8, 0.75], 2.5625: [9.12, 2.02, 7.25, 8, 0.875],\n    3.0625: [10.62, 2.3, 8.5, 8, 1.0], 4.0625: [12.44, 2.77, 10.19, 8, 1.125], 5.125: [14.06, 3.12, 11.81, 12, 1.125],\n    7.0625: [18.88, 4.06, 15.88, 12, 1.5], 9: [21.75, 4.88, 18.75, 16, 1.5], 11: [25.75, 5.56, 22.25, 16, 1.75],\n    13.625: [30.25, 6.62, 26.5, 20, 1.875], 16.75: [34.31, 6.62, 30.56, 24, 1.875], 18.75: [40.94, 8.78, 36.44, 24, 2.25],\n    21.25: [45.0, 9.5, 40.25, 24, 2.5],\n  },\n  15000: {\n    1.8125: [8.19, 1.78, 6.31, 8, 0.875], 2.0625: [8.75, 2.0, 6.88, 8, 0.875], 2.5625: [10.0, 2.25, 7.88, 8, 1.0],\n    3.0625: [11.31, 2.53, 9.06, 8, 1.125], 4.0625: [14.19, 3.09, 11.44, 8, 1.375], 5.125: [16.5, 3.88, 13.63, 12, 1.5],\n    7.0625: [19.88, 4.69, 16.88, 16, 1.5], 9: [25.5, 5.75, 21.75, 16, 1.875], 11: [32.0, 7.38, 28.0, 20, 2.0],\n    13.625: [34.88, 8.06, 30.38, 20, 2.25], 18.75: [45.75, 10.06, 40.0, 20, 3.0],\n  },\n}\n\n/** API 6B / 6BX flange for a nominal bore (snapped to the nearest size the pressure class defines). Typical dims. */\nexport function apiFlange(bore: number, psi: Psi = 5000): ApiFlange {\n  const tbl = T[psi] ?? T[5000]\n  const b = nearest(Object.keys(tbl).map(Number), bore)\n  const [od, t, bc, n, bd] = tbl[b]\n  return { bore: b, od: inch(od), t: inch(t), bolts: n, boltDia: inch(bd), boltCircle: inch(bc), ring: inch(bc - bd * 1.9), bx: psi >= 10000 || b >= 13.625 }\n}\n\n// Full-bore flanged gate valve face-to-face, inches (typical API 6A, slab gate).\nconst GATE: Record<Psi, Record<number, number>> = {\n  2000: { 2.0625: 11.62, 2.5625: 13.12, 3.125: 14.12, 4.0625: 17.12, 7.0625: 22.12 },\n  3000: { 2.0625: 14.62, 2.5625: 16.62, 3.125: 17.12, 4.0625: 20.12, 7.0625: 24.12 },\n  5000: { 2.0625: 14.62, 2.5625: 16.62, 3.125: 18.62, 4.0625: 21.62, 5.125: 28.62, 7.0625: 29.0 },\n  10000: { 1.8125: 18.25, 2.0625: 20.5, 2.5625: 22.25, 3.0625: 24.38, 4.0625: 26.38, 5.125: 29.0, 7.0625: 35.0 },\n  15000: { 1.8125: 18.0, 2.0625: 19.0, 2.5625: 21.0, 3.0625: 23.56, 4.0625: 29.0, 5.125: 35.0, 7.0625: 40.0 },\n}\n\nexport function apiGateLength(bore: number, psi: Psi = 5000): number {\n  const tbl = GATE[psi] ?? GATE[5000]\n  return inch(tbl[nearest(Object.keys(tbl).map(Number), bore)])\n}\n\n/** Nearest bore the pressure class actually defines (2-9/16 vs 3-1/8 at 5k, 3-1/16 at 10k, …). */\nexport function apiBore(bore: number, psi: Psi = 5000): number {\n  return apiFlange(bore, psi).bore\n}\n\n/** ASME B16.5 class whose bolt pattern matches the API 6B class (2000→600, 3000→900, 5000→1500); 10k+ → 2500 is the closest, not interchangeable. */\nexport function asmeEquivalent(psi: Psi): Rating {\n  return psi <= 2000 ? 600 : psi <= 3000 ? 900 : psi <= 5000 ? 1500 : 2500\n}\n\n/** Line-pipe NPS for an API nominal bore (2-1/16 → 2, 3-1/16 / 3-1/8 → 3, 4-1/16 → 4, 13-5/8 → 14…). */\nexport function boreNps(bore: number): number {\n  return nearest([1.5, 2, 2.5, 3, 4, 5, 6, 8, 10, 12, 14, 16, 18, 20], bore - 0.07)\n}\n\nexport const BORE_LABEL: Record<number, string> = {\n  1.8125: '1-13/16\"', 2.0625: '2-1/16\"', 2.5625: '2-9/16\"', 3.0625: '3-1/16\"', 3.125: '3-1/8\"', 4.0625: '4-1/16\"',\n  5.125: '5-1/8\"', 7.0625: '7-1/16\"', 9: '9\"', 11: '11\"', 13.625: '13-5/8\"', 16.75: '16-3/4\"', 18.75: '18-3/4\"', 20.75: '20-3/4\"', 21.25: '21-1/4\"',\n}\n"
    }
  ],
  "categories": [
    "wellhead"
  ],
  "meta": {
    "export": "PigLauncher",
    "anchor": "base",
    "standards": [
      "API 6D",
      "ASME VIII-1",
      "ASME B31.4 / B31.8",
      "ASME B16.5",
      "API 526"
    ]
  }
}