{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "olet",
  "type": "registry:component",
  "title": "Branch outlet (olet)",
  "description": "MSS SP-97 integrally reinforced branch outlets on a run pipe stub. Weldolet (butt-weld outlet with a flared bore), Sockolet (B16.11 socket and pipe stop), Thredolet (NPT female outlet), Nipolet (integral XS nipple, plain or male-threaded end), Elbolet (on a B16.9 LR elbow, outlet on the axis of one leg for thermowells) and Latrolet (45° lateral); the type names are used generically. The base is contoured to the run along the true intersection curve and finished with a fillet weld cap. The run is cut along the bore, so the branch opens into the run bore. Height, base OD and bore come from the SP-97 tables; `kind` selects the type.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "piping/olet.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/piping/olet.tsx",
      "content": "// Branch outlet fittings (MSS SP-97): Weldolet, Sockolet, Thredolet, Nipolet, Elbolet and Latrolet types contoured to the run, with the branch bore opened into the run bore.\nimport * as THREE from 'three'\nimport type { Kit, V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { port, type Port } from '../lib/ports'\nimport { elbow90, inch, pipeOD, pipeWall, type Schedule } from '../lib/standards'\nimport { BEVEL_TAN, LAND, buttWeld, nptTpi, pipeStub, seg, thread } from './_flanges'\nimport { gridMesh, rayHit, socket, sockolet, weldolet, type Klass } from './_flanges-b1611'\n\nexport type OletKind = 'weldolet' | 'sockolet' | 'threadolet' | 'nipolet' | 'elbolet' | 'latrolet'\nexport type OletEnd = 'weld' | 'socket' | 'thread'\n\nexport interface OletProps {\n  kind?: OletKind\n  /** run pipe size, inches */\n  runNps?: number\n  /** branch size, inches */\n  nps?: number\n  runSchedule?: Schedule\n  /** branch schedule (weld and nipple ends) */\n  schedule?: Schedule\n  /** socket / threaded outlet class */\n  pressureClass?: Klass\n  /** outlet end for elbolet and latrolet; nipolet uses plain or thread */\n  end?: OletEnd\n  /** nipolet nipple length beyond the body, m */\n  nipple?: number\n  /** run pipe length, m (0 = auto) */\n  runLength?: number\n}\n\nexport const defaults = {\n  kind: 'weldolet' as OletKind,\n  runNps: 8,\n  nps: 2,\n  runSchedule: 'STD' as Schedule,\n  schedule: 'STD' as Schedule,\n  pressureClass: 3000 as Klass,\n  end: 'weld' as OletEnd,\n  nipple: 0.09,\n  runLength: 0,\n}\n\nconst S45 = Math.SQRT1_2\n\n/** Shared layout: run surface, branch axis frame, crown and outlet positions. Used by ports() and the geometry. */\nfunction layout(p: OletProps) {\n  const o = { ...defaults, ...p }\n  const runNps = Math.max(0.5, o.runNps)\n  const elb = o.kind === 'elbolet'\n  const nps = Math.min(o.nps, elb ? runNps / 2 : runNps)\n  const R = pipeOD(runNps) / 2\n  const Ri = R - pipeWall(runNps, o.runSchedule)\n  const end: OletEnd = o.kind === 'weldolet' ? 'weld' : o.kind === 'sockolet' ? 'socket' : o.kind === 'threadolet' ? 'thread' : o.kind === 'nipolet' ? (o.end === 'thread' ? 'thread' : 'weld') : o.end\n  const cls = o.pressureClass === 6000 ? 6000 : 3000\n  const [wA, wB, wC] = weldolet(nps)\n  const [sA, sB, sD] = sockolet(nps, cls)\n  const wEnd = end === 'weld' || o.kind === 'nipolet'\n  // approx: elbolet / latrolet envelopes scaled from the weldolet / sockolet of the same outlet\n  const scale = o.kind === 'elbolet' ? 1.15 : o.kind === 'latrolet' ? 1.35 : 1\n  const A = (wEnd ? wA : sA) * scale\n  const rB = Math.min((wEnd ? wB : sB) / 2, R * 0.94)\n  const rh = Math.min((wEnd ? wC : sD) / 2, Ri * 0.9)\n  let a = new THREE.Vector3(0, 1, 0)\n  let e1 = new THREE.Vector3(1, 0, 0)\n  const e2 = new THREE.Vector3(0, 0, 1)\n  let sc = R\n  const Rb = elbow90(runNps, 'LR')\n  if (o.kind === 'latrolet') {\n    a = new THREE.Vector3(S45, S45, 0)\n    e1 = new THREE.Vector3(S45, -S45, 0)\n    sc = R / S45\n  } else if (elb) {\n    e1 = new THREE.Vector3(-1, 0, 0)\n    sc = Math.sqrt((Rb + R) ** 2 - Rb ** 2)\n  }\n  // top of the contoured band (highest base point): the body profile starts here\n  const s0 = o.kind === 'latrolet' ? sc + rB : elb ? Math.sqrt((Rb + R) ** 2 - (Rb - rB) ** 2) : sc\n  const nip = o.kind === 'nipolet' ? Math.max(0.02, o.nipple) : 0\n  const sTop = s0 + A + nip\n  const leg = Math.max(0.12, R * 2.2)\n  const L = o.runLength > 0 ? o.runLength : Math.max(0.3, 2 * (Math.abs(sc * a.x) + rB / S45 + 2 * R) + 0.05)\n  return { ...o, nps, runNps, R, Ri, end, cls: cls as Klass, A, rB, rh, a, e1, e2, sc, s0, sTop, Rb, elb, leg, L, nip }\n}\n\n// Run ends: 'run' and 'run-b' (weld bevels), outlet: 'branch'.\nexport function ports(p: OletProps = {}): Port[] {\n  const g = layout(p)\n  const top = g.a.clone().multiplyScalar(g.sTop).toArray() as V3\n  const runPorts = g.elb\n    ? [port('run', 'Run end A', [0, -g.leg, 0], [0, -1, 0], g.runNps), port('run-b', 'Run end B', [-g.Rb - g.leg, g.Rb, 0], [-1, 0, 0], g.runNps)]\n    : [port('run', 'Run end A', [-g.L / 2, 0, 0], [-1, 0, 0], g.runNps), port('run-b', 'Run end B', [g.L / 2, 0, 0], [1, 0, 0], g.runNps)]\n  return [...runPorts, port('branch', 'Branch outlet', top, g.a.toArray() as V3, g.nps)]\n}\n\nexport function Olet({ kind = defaults.kind, runNps = defaults.runNps, nps = defaults.nps, runSchedule = defaults.runSchedule, schedule = defaults.schedule, pressureClass = defaults.pressureClass, end = defaults.end, nipple = defaults.nipple, runLength = defaults.runLength, ...group }: OletProps & GroupProps) {\n  const data = useKit((k) => build(k, { kind, runNps, nps, runSchedule, schedule, pressureClass, end, nipple, runLength }), [kind, runNps, nps, runSchedule, schedule, pressureClass, end, nipple, runLength])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default Olet\n\ninterface Run {\n  S: (u: number, v: number, r: number) => THREE.Vector3\n  C: (u: number) => THREE.Vector3\n  f: (p: THREE.Vector3, r: number) => number\n  inv: (p: THREE.Vector3) => [number, number]\n}\n\nconst straightRun: Run = {\n  S: (u, v, r) => new THREE.Vector3(u, r * Math.cos(v), r * Math.sin(v)),\n  C: (u) => new THREE.Vector3(u, 0, 0),\n  f: (p, r) => Math.hypot(p.y, p.z) - r,\n  inv: (p) => [p.x, Math.atan2(p.z, p.y)],\n}\n\nfunction torusRun(Rb: number): Run {\n  const O = new THREE.Vector3(-Rb, 0, 0)\n  return {\n    S: (u, v, r) => new THREE.Vector3(O.x + (Rb + r * Math.cos(v)) * Math.cos(u), O.y + (Rb + r * Math.cos(v)) * Math.sin(u), r * Math.sin(v)),\n    C: (u) => new THREE.Vector3(O.x + Rb * Math.cos(u), O.y + Rb * Math.sin(u), 0),\n    f: (p, r) => Math.hypot(Math.hypot(p.x - O.x, p.y - O.y) - Rb, p.z) - r,\n    inv: (p) => [Math.atan2(p.y - O.y, p.x - O.x), Math.atan2(p.z, Math.hypot(p.x - O.x, p.y - O.y) - Rb)],\n  }\n}\n\nconst T = (v: THREE.Vector3) => v.toArray() as V3\n\n/** Run surface grid over u0..u1, v0..v1 at radius r, normals radial (outward or inward). */\nfunction runGrid(k: Kit, run: Run, r: number, u0: number, u1: number, v0: number, v1: number, nu: number, nv: number, inward: boolean) {\n  const P: V3[][] = []\n  const N: V3[][] = []\n  for (let i = 0; i <= nu; i++) {\n    const u = u0 + ((u1 - u0) * i) / nu\n    const c = run.C(u)\n    P.push([])\n    N.push([])\n    for (let j = 0; j <= nv; j++) {\n      const p = run.S(u, v0 + ((v1 - v0) * j) / nv, r)\n      P[i].push(T(p))\n      N[i].push(T(p.clone().sub(c).normalize().multiplyScalar(inward ? -1 : 1)))\n    }\n  }\n  gridMesh(k, inward ? 'pipeBore' : 'pipePaint', P, { N })\n}\n\nfunction build(k: Kit, p: OletProps) {\n  const g = layout(p)\n  const { R, Ri, a, e1, e2, rB, rh, sc, s0, A, sTop, nps } = g\n  const run = g.elb ? torusRun(g.Rb) : straightRun\n  const nT = 48\n  const ts = Array.from({ length: nT + 1 }, (_, j) => (j / nT) * Math.PI * 2)\n  const off = (r: number, t: number) => e1.clone().multiplyScalar(r * Math.cos(t)).addScaledVector(e2, r * Math.sin(t))\n  const sMax = sTop + R * 4 + g.Rb\n  const hit = (r: number, rr: number, t: number) => rayHit((q) => run.f(q, rr), off(r, t), a, 0, sMax)\n  // hole outlines on the outer and inner skins, in run parameters\n  const crown = run.inv(a.clone().multiplyScalar(sc))\n  const uc = crown[0]\n  const holes = [R, Ri].map((rr) => ts.map((t) => run.inv(off(rh, t).addScaledVector(a, hit(rh, rr, t)))))\n  let du = 0\n  let dv = 0\n  for (const hl of holes) for (const [u, v] of hl) (du = Math.max(du, Math.abs(u - uc))), (dv = Math.max(dv, Math.abs(v)))\n  du = du * 1.3 + (g.elb ? 0.02 : 0.006)\n  if (g.elb) du = Math.min(du, uc - 0.02, Math.PI / 2 - uc - 0.02)\n  // every piece shares one angular sampling so the joints have no cracks\n  const nv = 64\n  const step = (Math.PI * 2) / nv\n  const kv = Math.min(Math.ceil((dv * 1.25 + 0.08) / step), nv / 2 - 4)\n  dv = kv * step\n  const nuS = 8\n  const rect: [number, number][] = []\n  for (let m = 0; m <= 2 * kv; m++) rect.push([uc + du, -dv + step * m])\n  for (let i = 1; i <= nuS; i++) rect.push([uc + du - (2 * du * i) / nuS, dv])\n  for (let m = 1; m <= 2 * kv; m++) rect.push([uc - du, dv - step * m])\n  for (let i = 1; i <= nuS; i++) rect.push([uc - du + (2 * du * i) / nuS, -dv])\n  ;[R, Ri].forEach((rr, h) => {\n    const hole = rect.map(([u, v]) => run.inv(((t) => off(rh, t).addScaledVector(a, hit(rh, rr, t)))(Math.atan2(v / dv, (u - uc) / du))))\n    const P: V3[][] = []\n    const N: V3[][] = []\n    const nr = 6\n    for (let i = 0; i <= nr; i++) {\n      P.push([])\n      N.push([])\n      rect.forEach(([qu, qv], j) => {\n        const [hu, hv] = hole[j]\n        const u = hu + (qu - hu) * (i / nr)\n        const v = hv + (qv - hv) * (i / nr)\n        const q = run.S(u, v, rr)\n        P[i].push(T(q))\n        N[i].push(T(q.clone().sub(run.C(u)).normalize().multiplyScalar(h ? -1 : 1)))\n      })\n    }\n    gridMesh(k, h ? 'pipeBore' : 'pipePaint', P, { N })\n    // the rest of the ring alongside the patch\n    runGrid(k, run, rr, uc - du, uc + du, dv, Math.PI * 2 - dv, nuS, nv - 2 * kv, h === 1)\n  })\n  // run beyond the patch\n  const sched = g.runSchedule\n  if (g.elb) {\n    for (const rr of [R, Ri]) {\n      runGrid(k, run, rr, 0, uc - du, 0, Math.PI * 2, 10, nv, rr === Ri)\n      runGrid(k, run, rr, uc + du, Math.PI / 2, 0, Math.PI * 2, 16, nv, rr === Ri)\n    }\n    pipeStub(k, [0, -g.leg, 0], [0, 1, 0], g.leg, g.runNps, sched, 'bevel', 'open')\n    pipeStub(k, [-g.Rb, g.Rb, 0], [-1, 0, 0], g.leg, g.runNps, sched, 'open', 'bevel')\n    buttWeld(k, [0, 0, 0], [0, 1, 0], R, R - Ri)\n    buttWeld(k, [-g.Rb, g.Rb, 0], [1, 0, 0], R, R - Ri)\n  } else {\n    // lathed ends phased to the grid (lathe phi = v - 90°), with B16.25 bevels\n    const bev = Math.max(0, R - Ri - LAND) * BEVEL_TAN\n    const x0 = uc - du + g.L / 2\n    const x1 = g.L / 2 - (uc + du)\n    const ph = -Math.PI / 2\n    k.lathe('pipePaint', [[R, bev], [R, x0]], [-g.L / 2, 0, 0], 'x', nv, ph)\n    k.lathe('pipeBore', [[Ri, x0], [Ri, 0]], [-g.L / 2, 0, 0], 'x', nv, ph)\n    k.lathe('machined', [[Ri, 0], [Ri + LAND, 0], [R, bev]], [-g.L / 2, 0, 0], 'x', nv, ph)\n    k.lathe('pipePaint', [[R, 0], [R, x1 - bev]], [uc + du, 0, 0], 'x', nv, ph)\n    k.lathe('pipeBore', [[Ri, x1], [Ri, 0]], [uc + du, 0, 0], 'x', nv, ph)\n    k.lathe('machined', [[R, x1 - bev], [Ri + LAND, x1], [Ri, x1]], [uc + du, 0, 0], 'x', nv, ph)\n  }\n  // body: lower band contoured to the run surface, then lathed about the branch axis\n  const sb = ts.map((t) => hit(rB, R, t))\n  const sK = Math.max(...sb) + 0.001\n  const radial = (t: number) => T(off(1, t))\n  const band: V3[][] = []\n  for (let i = 0; i <= 4; i++) band.push(ts.map((t, j) => T(off(rB, t).addScaledVector(a, sb[j] + ((sK - sb[j]) * i) / 4))))\n  gridMesh(k, 'flangeSteel', band, { wrap: true, ref: (_q, _i, j) => radial(ts[j]) })\n  const si = ts.map((t) => hit(rh, Ri, t))\n  const sBot = Math.max(...si) + 0.001\n  const bore: V3[][] = []\n  for (let i = 0; i <= 3; i++) bore.push(ts.map((t, j) => T(off(rh, t).addScaledVector(a, si[j] + ((sBot - si[j]) * i) / 3))))\n  gridMesh(k, 'pipeBore', bore, { wrap: true, ref: (_q, _i, j) => T(off(-1, ts[j])) })\n  // fillet weld cap following the saddle\n  const w = Math.min(Math.max(0.004, (rB - rh) * 0.45), 0.02)\n  const pts = ts.slice(0, -1).map((t, j) => {\n    const b = off(rB, t).addScaledVector(a, sb[j])\n    const n = b.clone().sub(run.C(run.inv(b)[0])).normalize()\n    return b.addScaledVector(off(1, t), w * 0.25).addScaledVector(n, w * 0.25)\n  })\n  k.add('flangeSteel', new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts, true), nT * 2, w * 0.55, 6, true))\n  // outlet end\n  const ax = T(a)\n  const n = seg(rB * 2)\n  const rO = pipeOD(nps) / 2\n  const pitch = 0.0254 / nptTpi(nps)\n  if (g.kind === 'nipolet') {\n    // integral XS nipple: plain end for socket welding or male NPT\n    const riN = rO - pipeWall(nps, 'XS')\n    const sN = s0 + A\n    k.lathe('flangeSteel', [[rB, sK], [rB, s0 + A * 0.3], [rO * 1.25, s0 + A * 0.85], [rO, sN]], [0, 0, 0], ax, n)\n    k.lathe('pipeBore', [[riN, sTop], [riN, s0 + A * 0.5], [rh, sBot]], [0, 0, 0], ax, n)\n    if (g.end === 'thread') {\n      const L2 = Math.min(g.nip * 0.8, inch((0.8 * (rO * 2) / 0.0254 + 6.8) / nptTpi(nps)))\n      k.lathe('flangeSteel', [[rO, sN], [rO, sTop - L2]], [0, 0, 0], ax, n)\n      k.lathe('machined', [...thread(rO, rO - L2 / 32, sTop - L2, sTop - 0.0003, pitch, false), [riN, sTop]], [0, 0, 0], ax, n)\n    } else k.lathe('flangeSteel', [[rO, sN], [rO, sTop - 0.001], [rO - 0.001, sTop], [riN, sTop]], [0, 0, 0], ax, n)\n    return\n  }\n  if (g.end === 'weld') {\n    const riB = rO - pipeWall(nps, g.schedule)\n    const bev = Math.max(0, rO - riB - LAND) * BEVEL_TAN\n    k.lathe('flangeSteel', [[rB, sK], [rB, s0 + A * 0.32], [rO + (rB - rO) * 0.12, s0 + A * 0.78], [rO, s0 + A * 0.86], [rO, sTop - bev]], [0, 0, 0], ax, n)\n    k.lathe('machined', [[rO, sTop - bev], [riB + LAND, sTop], [riB, sTop]], [0, 0, 0], ax, n)\n    k.lathe('pipeBore', [[riB, sTop], [riB, s0 + A * 0.55], [rh, sBot]], [0, 0, 0], ax, n)\n    return\n  }\n  const sk = socket(nps, g.cls)\n  const rS = sk.bore / 2\n  const rT = Math.max(rS + sk.wall, rB * 0.8, rO + pitch * 2.5)\n  const rD = Math.min(sk.d / 2, rS - 0.001)\n  k.lathe('flangeSteel', [[rB, sK], [rB, s0 + A * 0.3], [rT, s0 + A * 0.62], [rT, sTop - 0.0015], [rT - 0.0015, sTop]], [0, 0, 0], ax, n)\n  if (g.end === 'socket') {\n    k.lathe('machined', [[rT - 0.0015, sTop], [rS + 0.0015, sTop], [rS, sTop - 0.0015], [rS, sTop - sk.depth], [rD, sTop - sk.depth]], [0, 0, 0], ax, n)\n    k.lathe('pipeBore', [[rD, sTop - sk.depth], [rD, sTop - sk.depth - 0.003], [rh, sBot]], [0, 0, 0], ax, n)\n  } else {\n    // NPT female outlet, crest largest at the mouth, 1:16 taper\n    const Lt = Math.min(A * 0.6, inch((0.8 * (rO * 2) / 0.0254 + 6.8) / nptTpi(nps)))\n    const r0 = rO - pitch * 0.56\n    const r1 = r0 - Lt / 32\n    const ch = pitch\n    k.lathe('machined', [[rT - 0.0015, sTop], [r0 + ch, sTop], ...thread(r0, r1, sTop - ch, sTop - Lt, pitch, true), [rD, sTop - Lt - 0.002]], [0, 0, 0], ax, n)\n    k.lathe('pipeBore', [[rD, sTop - Lt - 0.002], [rh, sBot]], [0, 0, 0], ax, n)\n  }\n}\n"
    },
    {
      "path": "piping/_flanges.ts",
      "type": "registry:file",
      "target": "components/deck-twin/piping/_flanges.ts",
      "content": "// Flange-type geometry: ASME B16.5 weld-neck, slip-on, socket-weld, threaded and lap-joint bodies, drilled plates, RF / FF / RTJ facings, NPT threads, fillet welds and pipe stubs.\nimport * as THREE from 'three'\nimport { latheCrisp, mat4, perp, uvScale, type Kit, type MatKey, type Profile, type V3 } from '../lib/kit'\nimport { textMaterial } from '../lib/textures'\nimport { GASKET_SPIRAL_WOUND, flange as flangeDims, inch, mm, nearest, pipeOD, pipeWall, stubEnd, studLength, tableKeys, type Rating, type Schedule } from '../lib/standards'\n\nexport type Facing = 'RF' | 'FF' | 'RTJ'\nexport type FlangeKind = 'WN' | 'SO' | 'SW' | 'THD' | 'LJ'\nexport const GAP = GASKET_SPIRAL_WOUND\nexport const LAND = 0.0016\nexport const BEVEL_TAN = Math.tan((37.5 * Math.PI) / 180)\nconst BEVEL = BEVEL_TAN\n\nexport const add = (a: V3, d: V3, s = 1): V3 => [a[0] + d[0] * s, a[1] + d[1] * s, a[2] + d[2] * s]\nexport const seg = (d: number) => (d > 0.5 ? 64 : d > 0.25 ? 48 : d > 0.1 ? 40 : 32)\nconst look = <T>(t: Record<number, T>, nps: number): T => t[nearest(tableKeys(t), nps)]\nconst has = (t: Record<number, unknown> | undefined, nps: number) => !!t && t[nearest(tableKeys(t), nps)] !== undefined && Math.abs(nearest(tableKeys(t), nps) - nps) < 0.01\n\n// ------------------------------------------------------------------ B16.5 tables (mm)\n\n// Slip-on / threaded / socket-weld length through hub Y, raised face excluded.\nconst SO_Y: Partial<Record<Rating, Record<number, number>>> = {\n  150: { 0.5: 14, 0.75: 14, 1: 16, 1.25: 19, 1.5: 21, 2: 24, 2.5: 27, 3: 29, 3.5: 30, 4: 32, 5: 35, 6: 38, 8: 43, 10: 48, 12: 54, 14: 56, 16: 62, 18: 67, 20: 71, 24: 81 },\n  300: { 0.5: 21, 0.75: 24, 1: 25, 1.25: 25, 1.5: 29, 2: 32, 2.5: 37, 3: 41, 3.5: 43, 4: 46, 5: 49, 6: 51, 8: 60, 10: 65, 12: 71, 14: 75, 16: 81, 18: 87, 20: 94, 24: 105 },\n  600: { 0.5: 22, 0.75: 25, 1: 27, 1.25: 29, 1.5: 32, 2: 37, 2.5: 41, 3: 46, 4: 54, 5: 60, 6: 67, 8: 76, 10: 86, 12: 92, 14: 94, 16: 106, 18: 117, 20: 127, 24: 140 },\n  900: { 0.5: 32, 0.75: 35, 1: 41, 1.25: 41, 1.5: 44, 2: 57, 2.5: 64, 3: 54, 4: 70, 5: 79, 6: 86, 8: 102, 10: 108, 12: 117, 14: 130, 16: 133, 18: 152, 20: 159, 24: 203 },\n  1500: { 0.5: 32, 0.75: 35, 1: 41, 1.25: 41, 1.5: 44, 2: 57, 2.5: 64 },\n}\n// Slip-on bore B (also the socket bore of socket-weld flanges).\nconst SO_B: Record<number, number> = {\n  0.5: 22.2, 0.75: 27.7, 1: 34.5, 1.25: 43.2, 1.5: 49.5, 2: 61.9, 2.5: 74.6, 3: 90.7, 3.5: 103.4, 4: 116.1, 5: 143.8,\n  6: 170.7, 8: 221.5, 10: 276.2, 12: 327, 14: 359.2, 16: 410.5, 18: 461.8, 20: 513.1, 24: 616,\n}\n// Socket-weld flange socket depth D.\nconst SW_D: Record<number, number> = { 0.5: 9.5, 0.75: 11.2, 1: 12.7, 1.25: 14.2, 1.5: 15.7, 2: 17.5, 2.5: 19.1, 3: 20.6 }\n\n/** True when B16.5 defines this flange type at this size and class. */\nexport function defined(kind: FlangeKind, nps: number, rating: Rating) {\n  if (kind === 'WN') return true\n  if (rating === 2500 && kind !== 'THD' && kind !== 'LJ') return false\n  if (kind === 'SW') return nps <= (rating >= 900 ? 2.5 : 3)\n  if (rating === 1500) return nps <= 2.5\n  if (rating === 2500) return nps <= 12\n  return nps <= 24\n}\n\n/** The requested class if B16.5 defines this flange type there, else the next lower class that does. */\nexport function definedRating(kind: FlangeKind, nps: number, rating: Rating): Rating {\n  const all: Rating[] = [2500, 1500, 900, 600, 300, 150]\n  return all.find((r) => r <= rating && defined(kind, nps, r)) ?? 150\n}\n\n/** Slip-on / threaded / socket-weld / lap-joint length through hub, raised face excluded. */\nexport function hubLength(kind: FlangeKind, nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  if (kind === 'WN') return f.hubLength\n  const t = SO_Y[rating]\n  // approx: outside the B16.5 slip-on table, 0.58 x the weld-neck hub\n  let y = has(t, nps) ? mm(look(t!, nps)) : f.hubLength * 0.58\n  // approx: B16.5 lap-joint hubs are longer than slip-on from NPS 14 in Class 150 / 300\n  if (kind === 'LJ' && nps >= 14 && rating <= 300) y *= 1.35\n  return Math.max(y, f.thickness + 0.004)\n}\nexport const soBore = (nps: number) => (nps <= 24 ? mm(look(SO_B, nps)) : pipeOD(nps) + 0.006)\nexport const swDepth = (nps: number) => mm(look(SW_D, nps))\n/** B16.5 bolt hole: bolt + 1/8\" up to 1-3/4\" bolts, + 1/4\" above. */\nexport const boltHole = (bd: number) => bd + inch(bd <= inch(1.75) + 1e-6 ? 0.125 : 0.25)\n\n// Octagonal ring grooves by ring number [P pitch dia, E depth, F width], mm (B16.5 / B16.20).\nconst RING: Record<number, [number, number, number]> = {\n  11: [34.14, 5.54, 7.14], 12: [39.67, 6.35, 8.74], 13: [42.88, 6.35, 8.74], 14: [44.45, 6.35, 8.74], 15: [47.63, 6.35, 8.74],\n  16: [50.8, 6.35, 8.74], 17: [57.15, 6.35, 8.74], 18: [60.33, 6.35, 8.74], 19: [65.07, 6.35, 8.74], 20: [68.27, 6.35, 8.74],\n  21: [72.23, 7.92, 11.91], 22: [82.55, 6.35, 8.74], 23: [82.55, 7.92, 11.91], 24: [95.25, 7.92, 11.91], 25: [101.6, 6.35, 8.74],\n  26: [101.6, 7.92, 11.91], 27: [107.95, 7.92, 11.91], 28: [111.13, 9.53, 13.49], 29: [114.3, 6.35, 8.74], 31: [123.83, 7.92, 11.91],\n  32: [127, 9.53, 13.49], 33: [131.78, 6.35, 8.74], 34: [131.78, 7.92, 11.91], 35: [136.53, 7.92, 11.91], 36: [149.23, 6.35, 8.74],\n  37: [149.23, 7.92, 11.91], 38: [157.18, 11.13, 16.66], 39: [161.93, 7.92, 11.91], 40: [171.45, 6.35, 8.74], 41: [180.98, 7.92, 11.91],\n  42: [190.5, 12.7, 19.84], 43: [193.68, 6.35, 8.74], 44: [193.68, 7.92, 11.91], 45: [211.12, 7.92, 11.91], 46: [211.14, 9.53, 13.49],\n  47: [228.6, 12.7, 19.84], 48: [247.65, 6.35, 8.74], 49: [269.88, 7.92, 11.91], 50: [269.88, 11.13, 16.66], 51: [279.4, 14.27, 23.01],\n  52: [304.8, 6.35, 8.74], 53: [323.85, 7.92, 11.91], 54: [323.85, 11.13, 16.66], 55: [342.9, 17.48, 30.18], 56: [381, 6.35, 8.74],\n  57: [381, 7.92, 11.91], 58: [381, 14.27, 23.01], 59: [396.88, 6.35, 8.74], 60: [406.4, 17.48, 33.32], 61: [419.1, 7.92, 11.91],\n  62: [419.1, 11.13, 16.66], 63: [419.1, 15.88, 26.97], 64: [454.03, 6.35, 8.74], 65: [469.9, 7.92, 11.91], 66: [469.9, 11.13, 16.66],\n  67: [469.9, 17.48, 30.18], 68: [517.53, 6.35, 8.74], 69: [533.4, 7.92, 11.91], 70: [533.4, 12.7, 19.84], 71: [533.4, 17.48, 30.18],\n  72: [558.8, 6.35, 8.74], 73: [584.2, 9.53, 13.49], 74: [584.2, 12.7, 19.84], 75: [584.2, 17.48, 33.32], 76: [673.1, 6.35, 8.74],\n  77: [692.15, 11.13, 16.66], 78: [692.15, 15.88, 26.97], 79: [692.15, 20.62, 36.53],\n}\nconst RN_300 = { 0.5: 11, 0.75: 13, 1: 16, 1.25: 18, 1.5: 20, 2: 23, 2.5: 26, 3: 31, 3.5: 34, 4: 37, 5: 41, 6: 45, 8: 49, 10: 53, 12: 57, 14: 61, 16: 65, 18: 69, 20: 73, 24: 77 }\nconst RN_900 = { 0.5: 12, 0.75: 14, 1: 16, 1.25: 18, 1.5: 20, 2: 24, 2.5: 27, 3: 31, 4: 37, 5: 41, 6: 45, 8: 49, 10: 53, 12: 57, 14: 62, 16: 66, 18: 70, 20: 74, 24: 78 }\n// B16.5 ring number per class and NPS.\nconst RING_NO: Record<Rating, Record<number, number>> = {\n  150: { 1: 15, 1.25: 17, 1.5: 19, 2: 22, 2.5: 25, 3: 29, 3.5: 33, 4: 36, 5: 40, 6: 43, 8: 48, 10: 52, 12: 56, 14: 59, 16: 64, 18: 68, 20: 72, 24: 76 },\n  300: RN_300,\n  600: RN_300,\n  900: RN_900,\n  1500: { ...RN_900, 3: 35, 4: 39, 5: 44, 6: 46, 8: 50, 10: 54, 12: 58, 14: 63, 16: 67, 18: 71, 20: 75, 24: 79 },\n  2500: { 0.5: 13, 0.75: 16, 1: 18, 1.25: 21, 1.5: 23, 2: 26, 2.5: 28, 3: 32, 4: 38, 5: 42, 6: 47, 8: 51, 10: 55, 12: 60 },\n}\n\n/** RTJ groove for a flange: ring number, pitch dia, depth, top width and raised-portion dia K (m). */\nexport function ringGroove(nps: number, rating: Rating) {\n  const f0 = flangeDims(nps, rating)\n  // approx: B16.47 Series A ring grooves scaled from the raised-face diameter\n  if (nps > 24) return { ring: 'R', pitch: f0.raisedFace * 0.9, depth: mm(rating <= 300 ? 11.13 : 15.88), width: mm(rating <= 300 ? 16.66 : 26.97), K: f0.raisedFace * 0.9 + mm(rating <= 300 ? 29 : 39) }\n  const t = has(RING_NO[rating], nps) ? RING_NO[rating] : RING_NO[300]\n  const no = look(t, nps)\n  const [p, e, w] = RING[no]\n  const f = flangeDims(nps, rating)\n  // approx: K = P + F + 12 mm (fits the B16.5 K column within a few mm), kept inside the bolt holes\n  const K = Math.min(mm(p + w + 12), f.boltCircle - boltHole(f.boltDia) - 0.004)\n  return { ring: `R${no}`, pitch: mm(p), depth: mm(e), width: mm(w), K }\n}\n\n/** Facing height above the flange plate (port offset). */\nexport function facingHeight(facing: Facing, nps: number, rating: Rating) {\n  if (facing === 'FF') return 0\n  if (facing === 'RTJ') return ringGroove(nps, rating).depth\n  return flangeDims(nps, rating).raisedFaceHeight\n}\n\n/** NPT threads per inch. */\nexport const nptTpi = (nps: number) => (nps <= 0.375 ? 18 : nps <= 0.75 ? 14 : nps <= 2 ? 11.5 : 8)\n\n// ------------------------------------------------------------------ primitives\n\n/** Orthonormal frame (u, w, v) about dir, matching Kit.boltCircle / perp. */\nexport function frame(dir: V3) {\n  const v = new THREE.Vector3(...dir).normalize()\n  const [u, w] = perp(dir)\n  return { u, w, v }\n}\n\nconst circle = (cx: number, cy: number, r: number, n: number, cw: boolean) => {\n  const pts: THREE.Vector2[] = []\n  for (let i = 0; i < n; i++) {\n    const a = ((cw ? -i : i) / n) * Math.PI * 2\n    pts.push(new THREE.Vector2(cx + Math.cos(a) * r, cy + Math.sin(a) * r))\n  }\n  return pts\n}\n\nexport interface Drill {\n  n: number\n  bc: number\n  hole: number\n}\n\n/**\n * Flat plate with real bolt holes (and a central opening rIn) from `back` along `dir`, thickness t. Hole phase matches\n * Kit.boltCircle. `bore` draws the central opening wall; the front face can take its own material.\n */\nexport function drilledPlate(k: Kit, mat: MatKey, frontMat: MatKey, back: V3, dir: V3, t: number, rIn: number, rOut: number, d: Drill | null, n = seg(rOut * 2), bore = false) {\n  const { u, w, v } = frame(dir)\n  const M = new THREE.Matrix4().makeBasis(u, w, v).setPosition(...back)\n  const contour = circle(0, 0, rOut, n, false)\n  const holes: { c: [number, number]; r: number; pts: THREE.Vector2[] }[] = []\n  if (rIn > 1e-5) holes.push({ c: [0, 0], r: rIn, pts: circle(0, 0, rIn, n, true) })\n  if (d)\n    for (let i = 0; i < d.n; i++) {\n      const a = Math.PI / d.n + (i / d.n) * Math.PI * 2\n      const c: [number, number] = [Math.cos(a) * d.bc / 2, Math.sin(a) * d.bc / 2]\n      holes.push({ c, r: d.hole / 2, pts: circle(c[0], c[1], d.hole / 2, d.hole > 0.04 ? 20 : 14, true) })\n    }\n  const tris = THREE.ShapeUtils.triangulateShape(contour, holes.map((h) => h.pts))\n  const all = [...contour, ...holes.flatMap((h) => h.pts)]\n  const face = (z: number, nz: number) => {\n    const pos: number[] = []\n    const nor: number[] = []\n    for (const p of all) pos.push(p.x, p.y, z), nor.push(0, 0, nz)\n    const idx: number[] = []\n    for (const [a, b, c] of tris) nz > 0 ? idx.push(a, b, c) : idx.push(a, c, b)\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\n  }\n  k.add(frontMat, face(t, 1), M)\n  k.add(mat, face(0, -1), M.clone())\n  // walls: outer (normals out), holes (normals into the hole)\n  const pos: number[] = []\n  const nor: number[] = []\n  const idx: number[] = []\n  const wall = (cx: number, cy: number, r: number, m: number, inward: boolean) => {\n    const b = pos.length / 3\n    for (let j = 0; j <= m; j++) {\n      const a = ((inward ? -j : j) / m) * Math.PI * 2\n      const c = Math.cos(a)\n      const s = Math.sin(a)\n      const sg = inward ? -1 : 1\n      pos.push(cx + c * r, cy + s * r, 0, cx + c * r, cy + s * r, t)\n      nor.push(c * sg, s * sg, 0, c * sg, s * sg, 0)\n    }\n    for (let j = 0; j < m; j++) {\n      const a0 = b + j * 2\n      if (inward) idx.push(a0, a0 + 1, a0 + 2, a0 + 1, a0 + 3, a0 + 2)\n      else idx.push(a0, a0 + 2, a0 + 1, a0 + 1, a0 + 2, a0 + 3)\n    }\n  }\n  wall(0, 0, rOut, n, false)\n  holes.forEach((h, i) => (i > 0 || bore || rIn <= 1e-5 ? wall(h.c[0], h.c[1], h.r, h.pts.length, true) : null))\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  k.add(mat, g, M.clone())\n}\n\n/** Quarter-ish arc points from angle a0 to a1 about (cr, cy) in the (r, y) plane. */\nexport function arc(cr: number, cy: number, r: number, a0: number, a1: number, n = 5): Profile {\n  return Array.from({ length: n + 1 }, (_, i) => {\n    const a = a0 + ((a1 - a0) * i) / n\n    return [cr + Math.cos(a) * r, cy + Math.sin(a) * r] as [number, number]\n  })\n}\n\n/** Sawtooth thread profile between y0 and y1 (walks y0 → y1). External: crest at r, root at r - h. Internal: crest at r, root at r + h. */\nexport function thread(r0: number, r1: number, y0: number, y1: number, pitch: number, internal: boolean): Profile {\n  const n = Math.max(1, Math.round(Math.abs(y1 - y0) / pitch))\n  const h = pitch * 0.8 * 0.7\n  const p: Profile = []\n  for (let i = 0; i <= n; i++) {\n    const s = i / n\n    const r = r0 + (r1 - r0) * s\n    const y = y0 + (y1 - y0) * s\n    p.push([r, y])\n    if (i < n) p.push([r + (internal ? h : -h), y + (y1 - y0) / n / 2])\n  }\n  return p\n}\n\n/** Hollow pipe stub from `start` along `dir`. Ends: 'bevel' (B16.25 37.5° with land), 'square', 'open', 'thread' (NPT). */\nexport type End = 'bevel' | 'square' | 'open' | 'thread'\nexport function pipeStub(k: Kit, start: V3, dir: V3, L: number, nps: number, schedule: Schedule, endA: End, endB: End, mat: MatKey = 'pipePaint', ro = pipeOD(nps) / 2, ri = ro - pipeWall(nps, schedule)) {\n  if (L <= 1e-5) return\n  const n = seg(ro * 2)\n  const wall = ro - ri\n  const bev = Math.min(Math.max(0, wall - LAND) * BEVEL, L / 3)\n  const pitch = 0.0254 / nptTpi(nps)\n  // NPT effective thread length L2 = (0.8 D + 6.8) / tpi\n  const thr = Math.min(L * 0.6, inch((0.8 * (ro * 2) / 0.0254 + 6.8) / nptTpi(nps)))\n  let y0 = 0\n  let y1 = L\n  if (endA === 'bevel') k.lathe('machined', [[ri, 0], [ri + LAND, 0], [ro, bev]], start, dir, n), (y0 = bev)\n  else if (endA === 'square') k.lathe(mat, [[ri, 0], [ro, 0]], start, dir, n)\n  else if (endA === 'thread') {\n    k.lathe('machined', [[ri, 0], [ro - thr / 32 - pitch * 0.4, 0], ...thread(ro - thr / 32, ro, 0.0003, thr, pitch, false)], start, dir, n)\n    y0 = thr\n  }\n  if (endB === 'bevel') k.lathe('machined', [[ro, L - bev], [ri + LAND, L], [ri, L]], start, dir, n), (y1 = L - bev)\n  else if (endB === 'square') k.lathe(mat, [[ro, L], [ri, L]], start, dir, n)\n  else if (endB === 'thread') {\n    k.lathe('machined', [...thread(ro, ro - thr / 32, L - thr, L - 0.0003, pitch, false), [ro - thr / 32 - pitch * 0.4, L], [ri, L]], start, dir, n)\n    y1 = L - thr\n  }\n  if (y1 > y0) k.lathe(mat, [[ro, y0], [ro, y1]], start, dir, n)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], start, dir, n)\n}\n\n/** Fillet weld ring: legs along the pipe (-axis from y) and on a face at y; profile slightly convex. */\nexport function filletWeld(k: Kit, base: V3, dir: V3, r: number, y: number, leg: number, n = seg(r * 2)) {\n  const p: Profile = []\n  for (let i = 0; i <= 6; i++) {\n    const s = i / 6\n    const bulge = Math.sin(s * Math.PI) * leg * 0.12\n    p.push([r + leg * s + bulge * 0.7, y - leg * (1 - s) + bulge * 0.7])\n  }\n  k.lathe('flangeSteel', p, base, dir, n)\n}\n\n/** Circumferential butt weld cap. */\nexport function buttWeld(k: Kit, pos: V3, dir: V3, r: number, wall: number) {\n  const w = Math.max(0.005, Math.min(0.02, wall * 1.6))\n  k.torus('flangeSteel', r + 0.0006, w / 2, pos, dir, 6, seg(r * 2))\n}\n\n/** Stamped marking on a cylindrical rim: a thin band of text centred on angle `at` (radians from the frame's u axis). */\nexport function stamp(k: Kit, text: string, base: V3, dir: V3, r: number, y0: number, y1: number, at = 0) {\n  const h = Math.abs(y1 - y0)\n  if (h < 0.004 || !text) return\n  const phi = Math.min(h * 4, r * 2.2) / r\n  const g = uvScale(latheCrisp([[r + 0.0004, Math.min(y0, y1)], [r + 0.0004, Math.max(y0, y1)]], 12, at - phi / 2, phi), 1, 1 / h)\n  k.add(textMaterial(text, '#878d91', '#2b3035', 512, 128), g, mat4(base, dir))\n}\n\n// ------------------------------------------------------------------ loose flange body\n\n/**\n * Gasket facing on a plate whose front is at `base` (axis dir): RF with serrations, RTJ raised portion with the octagonal\n * groove, FF flat ring, or (lapBore > 0) the radiused lap-joint bore edge. Returns facing height, outer radius and the\n * axial position where the bore wall starts.\n */\nexport function facingLathe(k: Kit, fc: Facing, nps: number, rating: Rating, rb: number, base: V3, dir: V3, n: number, M: MatKey = 'flangeSteel', lapBore = 0) {\n  const f = flangeDims(nps, rating)\n  if (lapBore > 0) {\n    const r = Math.min(inch(0.12 + nps * 0.025), inch(0.5))\n    k.lathe('machined', arc(lapBore + r, -r, r, Math.PI / 2, Math.PI, 5), base, dir, n)\n    return { h: 0, rFace: lapBore + r, yb: -r }\n  }\n  if (fc === 'RF') {\n    const h = f.raisedFaceHeight\n    const rFace = f.raisedFace / 2\n    // serrated finish suggested by a few fine concentric grooves\n    const p: Profile = [[rFace, 0], [rFace, h]]\n    const step = (rFace - rb) / 7\n    for (let i = 1; i < 7; i++) {\n      const r = rFace - step * i\n      p.push([r + 0.0006, h], [r, h - 0.0003], [r - 0.0006, h])\n    }\n    p.push([rb + 0.001, h], [rb, h - 0.001])\n    k.lathe('machined', p, base, dir, n)\n    return { h, rFace, yb: h - 0.001 }\n  }\n  if (fc === 'RTJ') {\n    const g = ringGroove(nps, rating)\n    const h = g.depth\n    const rFace = g.K / 2\n    const bw = Math.max(0.0008, g.width / 2 - h * Math.tan((23 * Math.PI) / 180))\n    k.lathe(M, [[rFace, 0], [rFace, h]], base, dir, n)\n    k.lathe('machined', [[rFace, h], [g.pitch / 2 + g.width / 2, h], [g.pitch / 2 + bw, 0.0003], [g.pitch / 2 - bw, 0.0003], [g.pitch / 2 - g.width / 2, h], [rb + 0.001, h], [rb, h - 0.001]], base, dir, n)\n    return { h, rFace, yb: h - 0.001 }\n  }\n  const rFace = (rb + f.boltCircle / 2 - boltHole(f.boltDia)) / 2\n  k.lathe('machined', [[rFace, 0], [rb + 0.001, 0], [rb, -0.001]], base, dir, n)\n  return { h: 0, rFace, yb: -0.001 }\n}\n\nexport interface FlangeOpts {\n  kind: FlangeKind\n  nps: number\n  rating: Rating\n  facing: Facing\n  /** pipe schedule for the WN / SW bore */\n  schedule: Schedule\n  /** bolt holes drilled (true) or a plain disc */\n  drilled?: boolean\n  marking?: string\n  mat?: MatKey\n}\n\nexport interface FlangeInfo {\n  /** facing height above the plate front */\n  h: number\n  /** plate thickness */\n  t: number\n  /** plate front to hub end */\n  Y: number\n  /** bore radius the line sees */\n  bore: number\n  /** slip-on / socket / lap bore radius */\n  B: number\n  /** socket depth from the hub end (SW) */\n  D: number\n  od: number\n}\n\n/**\n * One loose B16.5 flange: plate front face at `base`, facing pointing along `dir`, hub running back along -dir.\n * Bolt holes are real. Returns the key dimensions.\n */\nexport function looseFlange(k: Kit, base: V3, dir: V3, o: FlangeOpts): FlangeInfo {\n  const { kind, nps, rating, facing, schedule } = o\n  const f = flangeDims(nps, rating)\n  const M: MatKey = o.mat ?? 'flangeSteel'\n  const ro = pipeOD(nps) / 2\n  const n = seg(f.od)\n  const lj = kind === 'LJ'\n  const fc: Facing = lj ? 'FF' : facing\n  // lap-joint flanges are flat-faced; 150 / 300 tf includes the 1.6 mm that other types spend on the raised face\n  const t = f.thickness + (lj && rating <= 300 ? f.raisedFaceHeight : 0)\n  const Y = Math.max(hubLength(kind, nps, rating), t + 0.004)\n  const hubR = Math.max(f.hubDia / 2, ro * 1.05)\n  const riPipe = ro - pipeWall(nps, schedule)\n  const B = kind === 'WN' ? riPipe : soBore(nps) / 2\n  const pitch = 0.0254 / nptTpi(nps)\n  const hth = pitch * 0.56\n  const D = kind === 'SW' ? swDepth(nps) : 0\n  // radius of the bore where it meets the face\n  const rb = kind === 'WN' || kind === 'SW' ? riPipe : kind === 'THD' ? ro - hth - (Y - 0.003) / 32 : B\n  const bore = rb\n  const { h, rFace, yb } = facingLathe(k, fc, nps, rating, rb, base, dir, n, M, lj ? B : 0)\n  // drilled plate between rFace and the OD\n  drilledPlate(k, M, fc === 'FF' && !lj ? 'machined' : M, add(base, dir, -t), dir, t, rFace, f.od / 2, o.drilled === false ? null : { n: f.bolts, bc: f.boltCircle, hole: boltHole(f.boltDia) }, n)\n  // hub: outer surface from the hub end up to the back face\n  const fil = Math.min(inch(0.25 + nps * 0.012), (Y - t) * 0.35)\n  const y0 = -Y\n  const hubTop: Profile = [...arc(hubR + fil, -t - fil, fil, Math.PI, Math.PI / 2, 5), ...(rFace > hubR + fil ? [[rFace, -t] as [number, number]] : [])]\n  if (kind === 'WN') {\n    const bev = Math.max(0, ro - riPipe - LAND) * BEVEL\n    const neck = Math.min((Y - t) * 0.12, (ro - riPipe) * 1.5)\n    k.lathe('machined', [[riPipe, y0], [riPipe + LAND, y0], [ro, y0 + bev]], base, dir, n)\n    k.lathe(M, [[ro, y0 + bev], [ro, y0 + bev + neck], [hubR, -t - fil], ...hubTop], base, dir, n)\n    k.lathe('pipeBore', [[riPipe, yb], [riPipe, y0]], base, dir, n)\n  } else {\n    // slip-on style hubs taper slightly to the hub end\n    const rHe = Math.max(B + (ro - riPipe) * 1.6 + 0.002, hubR - (Y - t) * 0.1)\n    const hub: Profile = [[rHe, y0], [hubR, -t - fil], ...hubTop]\n    let rIn: number\n    if (kind === 'SO' || kind === 'LJ') {\n      k.lathe('machined', [[B, yb], [B, y0 + 0.002], [B + 0.002, y0]], base, dir, n)\n      rIn = B + 0.002\n    } else if (kind === 'SW') {\n      const yS = y0 + D\n      k.lathe('pipeBore', [[riPipe, yb], [riPipe, yS]], base, dir, n)\n      k.lathe('machined', [[riPipe, yS], [B, yS], [B, y0 + 0.0015], [B + 0.0015, y0]], base, dir, n)\n      rIn = B + 0.0015\n    } else {\n      // NPT internal thread, largest at the hub end, tapering 1:16 on diameter towards the face; 45° entry chamfer\n      const r1 = rb + (Y - 0.003) / 32\n      const ch = pitch * 1.5\n      k.lathe('machined', [[rb, yb], ...thread(rb, r1, yb - 0.001, y0 + ch, pitch, true), [r1 + ch, y0]], base, dir, n)\n      rIn = r1 + ch\n    }\n    k.lathe(M, [[rIn, y0], ...hub], base, dir, n)\n  }\n  if (o.marking) stamp(k, o.marking, base, dir, f.od / 2, -t * 0.82, -t * 0.18)\n  return { h, t, Y, bore, B, D, od: f.od }\n}\n\n/** Default stamped marking. */\nexport const marking = (kind: string, nps: number, rating: Rating, facing: Facing, extra = '') =>\n  `${nps}\" ${rating}# ${kind} ${facing} ${extra} A105N`.replace(/\\s+/g, ' ')\n\n// ------------------------------------------------------------------ inline end flanges with half joint studs\n\n/** Weld-neck RF end flange whose face is a port at `face` pointing along `dir`, plus the back half of the joint studs. Returns face to weld end. */\nexport function endFlange(k: Kit, nps: number, rating: Rating, face: V3, dir: V3, schedule: Schedule = 'STD', studs = true, mat?: MatKey) {\n  const f = flangeDims(nps, rating)\n  const base = add(face, dir, -f.raisedFaceHeight)\n  const i = looseFlange(k, base, dir, { kind: 'WN', nps, rating, facing: 'RF', schedule, mat })\n  if (studs) {\n    const half = studLength(nps, rating) / 2\n    k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(face, dir, GAP / 2 - half), dir, f.boltDia, half, [f.boltDia * 0.6])\n  }\n  return i.Y + f.raisedFaceHeight\n}\n\n/** Lap-joint stub end (B16.9 long or MSS SP-43 short pattern): lap face at `face` pointing along dir. Returns length, lap radius, wall. */\nexport function stubEndGeo(k: Kit, face: V3, dir: V3, nps: number, schedule: Schedule, pattern: 'long' | 'short', mat: MatKey = 'flangeSteel') {\n  const s = stubEnd(nps)\n  // typical: MSS SP-43 short-pattern lengths\n  const short = inch(nps <= 1.5 ? 2 : nps <= 3.5 ? 2.5 : nps <= 5 ? 3 : nps <= 12 ? 4 : 6)\n  const L = pattern === 'long' ? s.length : short\n  const ro = pipeOD(nps) / 2\n  const wt = pipeWall(nps, schedule)\n  const ri = ro - wt\n  const G = s.lapDia / 2\n  const n = seg(G * 2)\n  const fr = Math.max(wt * 1.2, 0.003)\n  const bev = Math.max(0, wt - LAND) * BEVEL\n  k.lathe('machined', [[G, -0.002], [G - 0.002, 0], [ri + 0.0008, 0], [ri, -0.0008]], face, dir, n)\n  k.lathe(mat, [[ro, -L + bev], [ro, -wt - fr], ...arc(ro + fr, -wt - fr, fr, Math.PI, Math.PI / 2, 4), [G - 0.001, -wt], [G, -wt + 0.001], [G, -0.002]], face, dir, n)\n  k.lathe('pipeBore', [[ri, -0.0008], [ri, -L]], face, dir, n)\n  k.lathe('machined', [[ri, -L], [ri + LAND, -L], [ro, -L + bev]], face, dir, n)\n  return { L, G, wt }\n}\n\n// ------------------------------------------------------------------ inline assemblies\n\n/** Weld end to raised face of a WN RF flange. */\nexport const flangeLen = (nps: number, rating: Rating) => {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\n/** Inline flanged ends: WN RF flanges at x = ±L/2 facing out with half joint studs, plus pipe nipples of length `neck` inward. Returns the nipple inner ends. */\nexport function inlineEnds(k: Kit, nps: number, rating: Rating, L: number, neck: number, schedule: Schedule = 'STD', mat: MatKey = 'pipePaint') {\n  const fl = endFlange(k, nps, rating, [-L / 2, 0, 0], [-1, 0, 0], schedule)\n  endFlange(k, nps, rating, [L / 2, 0, 0], [1, 0, 0], schedule)\n  const ro = pipeOD(nps) / 2\n  const wt = pipeWall(nps, schedule)\n  const xa = -L / 2 + fl\n  const xb = L / 2 - fl\n  if (neck > 0) {\n    pipeStub(k, [xa, 0, 0], [1, 0, 0], neck, nps, schedule, 'open', 'square', mat)\n    pipeStub(k, [xb - neck, 0, 0], [1, 0, 0], neck, nps, schedule, 'square', 'open', mat)\n    buttWeld(k, [xa, 0, 0], [1, 0, 0], ro, wt)\n    buttWeld(k, [xb, 0, 0], [1, 0, 0], ro, wt)\n  }\n  return { xa: xa + neck, xb: xb - neck, fl }\n}\n\n/** Sinusoidal-convolution metal bellows along +X from x0: n convolutions of pitch q, root radius rr, height h, wall t, with cuffs. Returns its length. */\nexport function bellows(k: Kit, x0: number, n: number, q: number, rr: number, h: number, t: number, cuff: number, mat: MatKey = 'stainless') {\n  const out: Profile = [[rr, 0]]\n  const L = n * q + 2 * cuff\n  const m = 14\n  for (let i = 0; i <= n * m; i++) {\n    const y = cuff + (i / m) * q\n    out.push([rr + h * (0.5 - 0.5 * Math.cos((2 * Math.PI * i) / m)), y])\n  }\n  out.push([rr, L])\n  const segs = seg((rr + h) * 2)\n  k.lathe(mat, out, [x0, 0, 0], 'x', segs)\n  k.lathe(mat, out.map(([r, y]) => [r - t, y] as [number, number]).reverse(), [x0, 0, 0], 'x', segs)\n  return L\n}\n\n/** Wafer clamped between two WN RF flanges: flanges with faces at ±(w/2 + gasket), spiral-wound gaskets and full studs spanning. */\nexport function waferFlanges(k: Kit, nps: number, rating: Rating, w: number, schedule: Schedule = 'STD', stub = 0.12) {\n  const f = flangeDims(nps, rating)\n  const xf = w / 2 + GAP\n  for (const s of [-1, 1]) {\n    const face: V3 = [s * xf, 0, 0]\n    const dir: V3 = [s, 0, 0]\n    const base = add(face, dir, f.raisedFaceHeight)\n    const i = looseFlange(k, base, [-s, 0, 0], { kind: 'WN', nps, rating, facing: 'RF', schedule })\n    const we = s * (xf + f.raisedFaceHeight + i.Y)\n    if (stub > 0) {\n      pipeStub(k, [we, 0, 0], dir, stub, nps, schedule, 'open', 'bevel')\n      buttWeld(k, [we, 0, 0], dir, pipeOD(nps) / 2, pipeWall(nps, schedule))\n    }\n    k.lathe('gasket', [[pipeOD(nps) / 2 + 0.003, s * (w / 2) + (s < 0 ? -GAP : 0)], [f.raisedFace / 2 - 0.002, s * (w / 2) + (s < 0 ? -GAP : 0)], [f.raisedFace / 2 - 0.002, s * (w / 2) + (s < 0 ? 0 : GAP)], [pipeOD(nps) / 2 + 0.003, s * (w / 2) + (s < 0 ? 0 : GAP)]], [0, 0, 0], 'x', seg(f.raisedFace))\n  }\n  const span = 2 * (xf + f.raisedFaceHeight + f.thickness) + f.boltDia * 2.2\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, [-span / 2, 0, 0], 'x', f.boltDia, span, [f.boltDia * 0.5, span - f.boltDia * 0.5])\n  return { xf, f }\n}\n\n/** Fig 1502 union ends relative to the seal plane (typical proportions). */\nexport function hammerSpan(nps: number) {\n  const od = pipeOD(nps)\n  const nutL = od * 1.08\n  return { xm: -nutL * 0.72 - od * 1.25, xf: nutL * 0.28 + od * 1.35 * 0.55 }\n}\n\n/**\n * typical Fig 1502 hammer union about the X axis, seal plane at x = jx: male sub with nose and red three-lug wing nut\n * (-X side), female sub with ACME thread and lip seal (+X side). Returns the outer ends of both subs.\n */\nexport function hammerUnion(k: Kit, jx: number, nps: number, mat: MatKey = 'valveBody', label = '1502') {\n  const od = pipeOD(nps)\n  const ri = od / 2 - pipeWall(nps, '160')\n  const ringR = od * 0.98\n  const nutL = od * 1.08\n  const rBody = od * 0.62\n  const rFem = ringR * 0.84\n  const n = seg(ringR * 2)\n  const { xm: dm, xf: df } = hammerSpan(nps)\n  const xm = jx + dm\n  const xf = jx + df\n  // male sub: body, nose shoulder under the nut, seal face at jx\n  k.lathe(mat, [[ri, xm], [rBody, xm], [rBody, jx - nutL * 0.8], [rFem * 0.92, jx - nutL * 0.62], [rFem * 0.92, jx - 0.002], [ri + 0.004, jx]], [0, 0, 0], 'x', n)\n  // female sub: ACME thread band showing beyond the nut, shoulder, body\n  const pitch = 0.0254 / 3\n  k.lathe('machined', thread(rFem, rFem, jx + nutL * 0.2, jx + nutL * 0.28 + pitch * 2, pitch, false), [0, 0, 0], 'x', n)\n  k.lathe(mat, [[ri + 0.004, jx], [rFem, jx + 0.003], [rFem, jx + nutL * 0.2], [rFem, jx + nutL * 0.28 + pitch * 2], [rFem * 1.04, jx + nutL * 0.34], [rFem * 1.04, jx + nutL * 0.42], [rBody, jx + nutL * 0.5], [rBody, xf], [ri, xf]], [0, 0, 0], 'x', n)\n  k.lathe('pipeBore', [[ri, xf], [ri, xm]], [0, 0, 0], 'x', n)\n  // lip seal ring proud of the bore at the joint\n  k.lathe('rubberBlack', [[ri, jx + 0.006], [ri - 0.002, jx + 0.003], [ri - 0.002, jx - 0.001], [ri, jx - 0.003]], [0, 0, 0], 'x', n)\n  // wing nut: ring with three hammer lugs\n  const x0 = jx - nutL * 0.72\n  const x1 = jx + nutL * 0.28\n  k.lathe('actuatorRed', [[rFem * 1.01, x0], [ringR * 0.96, x0], [ringR, x0 + 0.004], [ringR, x1 - 0.004], [ringR * 0.96, x1], [rFem * 1.01, x1]], [0, 0, 0], 'x', n)\n  k.lathe('actuatorRed', [[rFem * 1.01, x1], [rFem * 1.01, x0]], [0, 0, 0], 'x', n)\n  const lugH = od * 0.5\n  for (let i = 0; i < 3; i++) {\n    const a = Math.PI / 2 + (i / 3) * Math.PI * 2\n    const r = ringR + lugH / 2 - 0.004\n    const c = new THREE.Vector3(0, Math.sin(a), Math.cos(a))\n    // hammer lug with a striking face\n    k.rbox('actuatorRed', [nutL * 0.78, lugH, od * 0.3], od * 0.03, [(x0 + x1) / 2, c.y * r, c.z * r], [0, c.y, c.z])\n  }\n  stamp(k, label, [0, 0, 0], [1, 0, 0], ringR, x0 + nutL * 0.2, x1 - nutL * 0.2, Math.PI / 3)\n  return { xm, xf, ri, rBody, od }\n}\n\n/** Hex prism (across flats af) with a round through-hole, from `from` along unit `dir` for len. */\nexport function hexRing(k: Kit, af: number, rIn: number, from: V3, dir: V3, len: number, mat: MatKey = 'flangeSteel') {\n  const R = af / Math.sqrt(3)\n  const sh = new THREE.Shape()\n  for (let i = 0; i < 6; i++) {\n    const a = (i / 6) * Math.PI * 2 + Math.PI / 6\n    if (i) sh.lineTo(R * Math.cos(a), R * Math.sin(a))\n    else sh.moveTo(R * Math.cos(a), R * Math.sin(a))\n  }\n  sh.closePath()\n  if (rIn > 0) {\n    const h = new THREE.Path()\n    h.absarc(0, 0, rIn, 0, Math.PI * 2, true)\n    sh.holes.push(h)\n  }\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(...dir))\n  k.extrude(mat, sh, len, new THREE.Matrix4().compose(new THREE.Vector3(...from), q, new THREE.Vector3(1, 1, 1)), 24)\n}\n"
    },
    {
      "path": "piping/_flanges-b1611.ts",
      "type": "registry:file",
      "target": "components/deck-twin/piping/_flanges-b1611.ts",
      "content": "// ASME B16.11 forged socket-weld / threaded fitting and MSS SP-97 branch-outlet dimension tables (mm), plus grid-mesh and ray-surface helpers.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { mm, nearest, tableKeys } from '../lib/standards'\n\nexport type Klass = 3000 | 6000\nconst at = <T>(t: Record<number, T>, nps: number): T => t[nearest(tableKeys(t), nps)]\n\n// B16.11 socket-weld: [socket bore B, socket depth J, socket wall C 3000, C 6000, bore D 3000, D 6000, A90 3000, A90 6000, A45 3000, A45 6000]\nconst SW: Record<number, number[]> = {\n  0.5: [21.95, 10, 4.65, 5.95, 16.6, 12.5, 15.5, 19.5, 11.5, 12.5],\n  0.75: [27.3, 13, 4.9, 6.95, 21.7, 16.3, 19.5, 22.5, 12.5, 14.5],\n  1: [34.05, 13, 5.7, 7.9, 27.4, 21.5, 22, 27, 14, 17],\n  1.25: [42.8, 13, 6.05, 7.9, 35.8, 30.2, 27, 32, 17, 21],\n  1.5: [48.9, 13, 6.35, 8.9, 41.7, 34.7, 32, 38, 21, 25],\n  2: [61.35, 16, 6.95, 10.9, 53.5, 43.6, 38, 41, 25, 29],\n  // approx: 6000 above NPS 2 is not in B16.11; extrapolated\n  2.5: [74.2, 16, 8.75, 12.5, 64.2, 52, 41.5, 50, 29, 33],\n  3: [90.15, 16, 9.5, 14, 79.5, 64, 57.5, 60, 31.5, 37],\n  4: [115.8, 19, 10.7, 16, 103.8, 86, 66.5, 72, 41.5, 46],\n}\nexport function socket(nps: number, cls: Klass) {\n  const r = at(SW, nps)\n  const s = cls === 6000 ? 1 : 0\n  return { bore: mm(r[0]), depth: mm(r[1]), wall: mm(r[2 + s]), d: mm(r[4 + s]), a90: mm(r[6 + s]), a45: mm(r[8 + s]) }\n}\n\n// approx: B16.11 threaded fittings [A90, A45, band OD H, min thread length] for Class 3000 / 6000, from the B16.11 tables (threaded 3000 pairs with SW 6000 wall).\nconst THD: Record<number, [number[], number[]]> = {\n  0.5: [[33.5, 25, 38, 10.9], [38, 28.5, 46, 10.9]],\n  0.75: [[38, 28.5, 46, 12.7], [44.5, 33.5, 56, 12.7]],\n  1: [[44.5, 33.5, 56, 14.7], [51, 35, 62, 14.7]],\n  1.25: [[51, 35, 62, 17], [60.5, 43.5, 75, 17]],\n  1.5: [[60.5, 43.5, 75, 17.8], [64, 44.5, 84, 17.8]],\n  2: [[64, 44.5, 84, 19], [83, 52, 102, 19]],\n  2.5: [[83, 52, 102, 23.6], [95, 64, 121, 23.6]],\n  3: [[95, 64, 121, 25.9], [106, 79, 146, 25.9]],\n  4: [[114, 79, 152, 27.7], [114, 79, 152, 27.7]],\n}\nexport function threaded(nps: number, cls: Klass) {\n  const r = at(THD, nps)[cls === 6000 ? 1 : 0]\n  return { a90: mm(r[0]), a45: mm(r[1]), od: mm(r[2]), thread: mm(r[3]) }\n}\n\n// MSS SP-97 Weldolet (standard weight): [A height, B base OD, C base bore].\nconst WELDOLET: Record<number, number[]> = {\n  0.25: [14.3, 25.4, 15.9], 0.375: [19.05, 31.75, 19.05], 0.5: [19.05, 34.93, 23.81], 0.75: [22.23, 44.45, 30.16], 1: [26.99, 53.98, 36.51],\n  1.25: [31.75, 65.09, 44.45], 1.5: [33.34, 73.03, 50.8], 2: [38.1, 88.9, 65.09], 2.5: [41.28, 103.19, 76.2], 3: [44.45, 122.24, 93.66],\n  3.5: [47.63, 142.88, 112.71], 4: [50.8, 152.4, 120.65], 5: [57.15, 179.3, 141.29], 6: [60.33, 215.9, 169.86], 8: [69.85, 263.53, 220.66],\n  10: [77.79, 322.26, 274.64], 12: [85.73, 377.83, 325.44], 14: [88.9, 409.58, 357.19], 16: [93.6, 463.55, 407.99], 18: [96.84, 520.7, 458.79],\n  20: [101.6, 571.5, 508], 24: [144.5, 717.5, 609.6],\n}\n// MSS SP-97 Sockolet [A, B body OD, D bore] for Class 3000 and 6000 (Thredolet shares the envelope, approx).\nconst SOCKOLET: Record<Klass, Record<number, number[]>> = {\n  3000: {\n    0.25: [19.05, 25.4, 9.25], 0.375: [20.64, 31.75, 12.52], 0.5: [25.4, 35.72, 15.8], 0.75: [26.99, 43.66, 20.93], 1: [33.34, 50.8, 26.64],\n    1.25: [33.34, 65.09, 35.05], 1.5: [34.93, 72.23, 40.89], 2: [38.1, 88.11, 52.5], 2.5: [39.69, 103.19, 62.71], 3: [44.45, 122.24, 77.93],\n    4: [47.63, 151.61, 102.26],\n  },\n  6000: { 0.5: [31.75, 43.66, 11.84], 0.75: [36.51, 49.61, 15.6], 1: [39.69, 61.91, 20.7], 1.25: [41.28, 69.06, 29.46], 1.5: [42.86, 82.55, 33.99], 2: [52.39, 102.39, 42.9] },\n}\nexport const weldolet = (nps: number) => at(WELDOLET, nps).map(mm)\nexport const sockolet = (nps: number, cls: Klass) => at(SOCKOLET[cls], nps).map(mm)\n\n// ------------------------------------------------------------------ meshing\n\n/** Quad grid P[i][j] with optional explicit normals; otherwise normals from central differences, oriented by `ref`. `wrap` joins the last column to the first. */\nexport function gridMesh(k: Kit, mat: MatKey, P: V3[][], opts: { N?: V3[][]; ref?: (p: V3, i: number, j: number) => V3; wrap?: boolean } = {}) {\n  const nr = P.length\n  const nc = P[0].length\n  const V = (q: V3) => new THREE.Vector3(...q)\n  const pos: number[] = []\n  const nor: number[] = []\n  const N: THREE.Vector3[][] = []\n  for (let i = 0; i < nr; i++) {\n    N.push([])\n    for (let j = 0; j < nc; j++) {\n      let n: THREE.Vector3\n      if (opts.N) n = V(opts.N[i][j])\n      else {\n        const jm = j > 0 ? j - 1 : opts.wrap ? nc - 2 : 0\n        const jp = j < nc - 1 ? j + 1 : opts.wrap ? 1 : nc - 1\n        const im = Math.max(0, i - 1)\n        const ip = Math.min(nr - 1, i + 1)\n        n = V(P[i][jp]).sub(V(P[i][jm])).cross(V(P[ip][j]).sub(V(P[im][j])))\n        if (n.lengthSq() < 1e-20) n.set(0, 1, 0)\n        n.normalize()\n        if (opts.ref && n.dot(V(opts.ref(P[i][j], i, j))) < 0) n.negate()\n      }\n      N[i].push(n)\n      pos.push(...P[i][j])\n      nor.push(n.x, n.y, n.z)\n    }\n  }\n  let sign = 0\n  for (let i = 0; i < nr - 1 && !sign; i++)\n    for (let j = 0; j < nc - 1 && !sign; j++) {\n      const a = V(P[i][j])\n      const g = V(P[i + 1][j]).sub(a).cross(V(P[i][j + 1]).sub(a))\n      if (g.lengthSq() > 1e-16) sign = g.dot(N[i][j]) >= 0 ? 1 : -1\n    }\n  const idx: number[] = []\n  for (let i = 0; i < nr - 1; i++)\n    for (let j = 0; j < nc - 1; j++) {\n      const a = i * nc + j\n      const b = a + nc\n      if (sign >= 0) idx.push(a, b, a + 1, b, b + 1, a + 1)\n      else 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.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n  g.setIndex(idx)\n  k.add(mat, g)\n}\n\n/** Bisection along q + s·a for where f (distance-minus-radius, < 0 inside) crosses zero between s0 (inside) and s1 (outside). */\nexport function rayHit(f: (p: THREE.Vector3) => number, q: THREE.Vector3, a: THREE.Vector3, s0: number, s1: number) {\n  const p = new THREE.Vector3()\n  let lo = s0\n  let hi = s1\n  for (let it = 0; it < 48; it++) {\n    const m = (lo + hi) / 2\n    if (f(p.copy(q).addScaledVector(a, m)) < 0) lo = m\n    else hi = m\n  }\n  return (lo + hi) / 2\n}\n\n/**\n * Cylinder about X from x0 to x1 (radius r) with a round hole of radius h on its +Y side centred at x = 0, so a\n * perpendicular branch bore can open into it. Shares one angular sampling across the patch, strip and ends.\n */\nexport function cutCylinder(k: Kit, mat: MatKey, r: number, x0: number, x1: number, h: number, inward: boolean, nv = 48) {\n  h = Math.min(h, r * 0.95)\n  const step = (Math.PI * 2) / nv\n  const du = Math.min(h * 1.3 + 0.0015, Math.min(-x0, x1) * 0.95)\n  const kv = Math.min(Math.ceil((Math.asin(h / r) * 1.2 + 0.05) / step), nv / 2 - 2)\n  const dv = kv * step\n  const S = (u: number, v: number): V3 => [u, r * Math.cos(v), r * Math.sin(v)]\n  const Nn = (v: number): V3 => (inward ? [0, -Math.cos(v), -Math.sin(v)] : [0, Math.cos(v), Math.sin(v)])\n  const nuS = 6\n  const rect: [number, number][] = []\n  for (let m = 0; m <= 2 * kv; m++) rect.push([du, -dv + step * m])\n  for (let i = 1; i <= nuS; i++) rect.push([du - (2 * du * i) / nuS, dv])\n  for (let m = 1; m <= 2 * kv; m++) rect.push([-du, dv - step * m])\n  for (let i = 1; i <= nuS; i++) rect.push([-du + (2 * du * i) / nuS, -dv])\n  const P: V3[][] = []\n  const N: V3[][] = []\n  for (let i = 0; i <= 4; i++) {\n    P.push([])\n    N.push([])\n    for (const [qu, qv] of rect) {\n      const t = Math.atan2(qv / dv, qu / du)\n      const hu = h * Math.cos(t)\n      const hv = Math.atan2(h * Math.sin(t), Math.sqrt(r * r - (h * Math.sin(t)) ** 2))\n      const u = hu + (qu - hu) * (i / 4)\n      const v = hv + (qv - hv) * (i / 4)\n      P[i].push(S(u, v))\n      N[i].push(Nn(v))\n    }\n  }\n  gridMesh(k, mat, P, { N })\n  const band = (u0: number, u1: number, v0: number, v1: number, nu: number, n: number) => {\n    const Q: V3[][] = []\n    const M: V3[][] = []\n    for (let i = 0; i <= nu; i++) {\n      Q.push([])\n      M.push([])\n      for (let j = 0; j <= n; j++) {\n        const v = v0 + ((v1 - v0) * j) / n\n        Q[i].push(S(u0 + ((u1 - u0) * i) / nu, v))\n        M[i].push(Nn(v))\n      }\n    }\n    gridMesh(k, mat, Q, { N: M })\n  }\n  band(-du, du, dv, Math.PI * 2 - dv, nuS, nv - 2 * kv)\n  band(x0, -du, 0, Math.PI * 2, 1, nv)\n  band(du, x1, 0, Math.PI * 2, 1, nv)\n}\n\n/** Branch tube of radius rb along +Y from its saddle on an X-axis cylinder of radius rr up to y1. */\nexport function saddleTube(k: Kit, mat: MatKey, rb: number, rr: number, y1: number, inward: boolean, nT = 48) {\n  rb = Math.min(rb, rr * 0.95)\n  const P: V3[][] = []\n  const N: V3[][] = []\n  for (let i = 0; i <= 3; i++) {\n    P.push([])\n    N.push([])\n    for (let j = 0; j <= nT; j++) {\n      const t = (j / nT) * Math.PI * 2\n      const ys = Math.sqrt(rr * rr - (rb * Math.sin(t)) ** 2)\n      P[i].push([rb * Math.cos(t), ys + ((y1 - ys) * i) / 3, rb * Math.sin(t)])\n      N[i].push(inward ? [-Math.cos(t), 0, -Math.sin(t)] : [Math.cos(t), 0, Math.sin(t)])\n    }\n  }\n  gridMesh(k, mat, P, { N })\n}\n"
    }
  ],
  "categories": [
    "piping"
  ],
  "meta": {
    "export": "Olet",
    "anchor": "fitting",
    "standards": [
      "MSS SP-97",
      "ASME B16.11",
      "ASME B1.20.1",
      "ASME B16.9",
      "ASME B16.25",
      "ASME B31.3"
    ]
  }
}