{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "elbow",
  "type": "registry:component",
  "title": "Elbow",
  "description": "Butt-welding elbow: 90° and 180° return in long (1.5D) or short (1.0D) radius, 45° long radius. Centre-to-end from ASME B16.9, machined B16.25 weld bevels or welded-on weld-neck flanges. The work point (tangent intersection) is the origin; turn sets whether end B goes up, down or sideways.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "piping/elbow.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/piping/elbow.tsx",
      "content": "// Butt-welding elbow 90° / 45° / 180° return, long or short radius, per ASME B16.9 with B16.25 weld bevels or welded WN flanges.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { port, type Port } from '../lib/ports'\nimport { elbow90, pipeOD, pipeWall, type Rating, type Schedule, type Service } from '../lib/standards'\nimport { LAND, SEG, bevelDepth, buttWeld, flangeLength, neg, portFlange, ringSheet, turn, type Ring } from './_piping'\n\nexport interface ElbowProps {\n  nps?: number\n  schedule?: Schedule\n  /** bend angle, degrees */\n  angle?: 90 | 45 | 180\n  /** long radius (1.5 D) or short radius (1.0 D); 45° elbows are LR only */\n  radius?: 'LR' | 'SR'\n  ends?: 'butt-weld' | 'flanged'\n  rating?: Rating\n  /** direction end B turns towards; the fitting rotates only about +Y in a plant */\n  turn?: 'up' | 'down' | 'side'\n  /** firewater paints the fitting red */\n  service?: Service | 'none'\n}\n\nexport const defaults = {\n  nps: 6,\n  schedule: '40' as Schedule,\n  angle: 90 as 90 | 45 | 180,\n  radius: 'LR' as 'LR' | 'SR',\n  ends: 'butt-weld' as 'butt-weld' | 'flanged',\n  rating: 300 as Rating,\n  turn: 'up' as 'up' | 'down' | 'side',\n  service: 'none' as Service | 'none',\n}\n\ntype P = typeof defaults\n\n// Work point at the origin (tangent intersection) for 45/90; bend centre at the origin for the 180° return.\nfunction geom(p: P) {\n  const ang = p.angle === 180 ? 180 : p.angle === 45 ? 45 : 90\n  // 45° and flanged 180° returns are LR only (SR return flanges would clash)\n  const R = elbow90(p.nps, ang === 45 || (ang === 180 && p.ends === 'flanged') ? 'LR' : p.radius)\n  const th = (ang * Math.PI) / 180\n  const u = new THREE.Vector3(1, 0, 0)\n  const v = new THREE.Vector3(...((p.turn === 'down' ? [0, -1, 0] : p.turn === 'side' ? [0, 0, 1] : [0, 1, 0]) as V3))\n  const dB = u.clone().multiplyScalar(Math.cos(th)).addScaledVector(v, Math.sin(th))\n  let a: THREE.Vector3\n  let b: THREE.Vector3\n  if (ang === 180) {\n    a = v.clone().multiplyScalar(-R)\n    b = v.clone().multiplyScalar(R)\n  } else {\n    const T = R * Math.tan(th / 2)\n    a = u.clone().multiplyScalar(-T)\n    b = dB.clone().multiplyScalar(T)\n  }\n  const fl = p.ends === 'flanged' ? flangeLength(p.nps, p.rating) : 0\n  const pa = a.clone().addScaledVector(u, -fl)\n  const pb = b.clone().addScaledVector(dB, fl)\n  const t = (x: THREE.Vector3) => x.toArray() as V3\n  return { R, th, u: t(u), v: t(v), dB: t(dB), a: t(a), b: t(b), pa: t(pa), pb: t(pb) }\n}\n\nexport function ports(props: ElbowProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  const g = geom(p)\n  const r = p.ends === 'flanged' ? p.rating : undefined\n  return [port('A', 'End A', g.pa, neg(g.u), p.nps, r), port('B', 'End B', g.pb, g.dB.map((x) => Math.round(x * 1e6) / 1e6) as V3, p.nps, r)]\n}\n\nexport function Elbow(props: ElbowProps & GroupProps) {\n  const { nps, schedule, angle, radius, ends, rating, turn, service, ...group } = { ...defaults, ...props }\n  const p = { nps, schedule, angle, radius, ends, rating, turn, service } as P\n  const data = useKit((k) => build(k, p), Object.values(p))\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default Elbow\n\nfunction build(k: Kit, p: P) {\n  const g = geom(p)\n  const mat: MatKey = p.service === 'firewater' ? 'firewaterRed' : 'pipePaint'\n  const od = pipeOD(p.nps)\n  const wall = pipeWall(p.nps, p.schedule)\n  const flanged = p.ends === 'flanged'\n  // bevelled ends: shorten the arc by the bevel depth and finish each end with a machined bevel ring\n  const h = flanged ? 0 : Math.min(bevelDepth(p.nps, p.schedule), g.R * g.th * 0.1)\n  const d = h / g.R\n  const u = new THREE.Vector3(...g.u)\n  const v = new THREE.Vector3(...g.v)\n  const C = new THREE.Vector3(...g.a).addScaledVector(v, g.R)\n  const at = (s: number) => C.clone().addScaledVector(v, -g.R * Math.cos(s)).addScaledVector(u, g.R * Math.sin(s))\n  const w = new THREE.Vector3().crossVectors(u, v)\n  const n = SEG(p.nps)\n  const ang = turn(n)\n  const ro = od / 2\n  const ri = ro - wall\n  // rings in the plane normal to the bend: e1 points away from the bend centre, e2 is the bend-plane normal\n  const ring = (s: number, r: number): Ring => ({ c: at(s).toArray() as V3, e1: at(s).sub(C).divideScalar(g.R).toArray() as V3, e2: w.toArray() as V3, r })\n  const N = Math.max(10, Math.round((g.th / (Math.PI / 2)) * 28))\n  const along = (s0: number, s1: number, r: number) => Array.from({ length: N + 1 }, (_, i) => ring(s0 + ((s1 - s0) * i) / N, r))\n  ringSheet(k, mat, along(d, g.th - d, ro), ang, 'out')\n  ringSheet(k, 'pipeBore', along(0, g.th, ri), ang, 'in')\n  if (!flanged) {\n    ringSheet(k, 'machined', [ring(d, ro), ring(0, ri + LAND)], ang, 'computed', neg(g.u))\n    ringSheet(k, 'machined', [ring(0, ri + LAND), ring(0, ri)], ang, 'computed', neg(g.u))\n    ringSheet(k, 'machined', [ring(g.th - d, ro), ring(g.th, ri + LAND)], ang, 'computed', g.dB)\n    ringSheet(k, 'machined', [ring(g.th, ri + LAND), ring(g.th, ri)], ang, 'computed', g.dB)\n  }\n  if (flanged) {\n    portFlange(k, p.nps, p.rating, g.pa, neg(g.u), { bore: od / 2 - wall })\n    portFlange(k, p.nps, p.rating, g.pb, g.dB, { bore: od / 2 - wall })\n    buttWeld(k, p.nps, g.a, g.u)\n    buttWeld(k, p.nps, g.b, g.dB)\n  }\n}\n"
    },
    {
      "path": "piping/_piping.ts",
      "type": "registry:file",
      "target": "components/deck-twin/piping/_piping.ts",
      "content": "// Shared piping geometry: port flanges with joint studs, B16.25 bevelled pipe, grid-swept surfaces, ISO 14726 bands, clad insulation, small valves.\nimport * as THREE from 'three'\nimport { mat4, type Kit, type MatKey, type Orient, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { textMaterial } from '../lib/textures'\nimport { GASKET_SPIRAL_WOUND, SERVICE, flange as flangeDims, pipeOD, pipeWall, studLength, type Rating, type Schedule, type Service } from '../lib/standards'\n\nexport const GAP = GASKET_SPIRAL_WOUND\n/** Radial segments for a size; every lathe and ring sheet of that size uses it so joints share vertices. */\nexport const SEG = (nps: number) => (nps >= 10 ? 64 : nps >= 4 ? 48 : 32)\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 neg = (d: V3): V3 => [-d[0], -d[1], -d[2]]\n\n/** Weld end to raised face of a WN flange. */\nexport function flangeLength(nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\nexport interface FlangeOpts {\n  /** draw the back half of the joint studs and nuts (default true) */\n  studs?: boolean\n  /** body material for integral cast / forged flanges; default a forged WN flange in flange steel */\n  mat?: MatKey\n  /** bore radius, m (default: Sch 40 / STD bore of the size) */\n  bore?: number\n}\n\n/**\n * Weld-neck (or integral) RF flange whose raised face is a port face pointing along `dir`: closed solid with the bore\n * open through, plus the back half of the joint studs and nuts exactly where the plant assembler draws its mating\n * joint. Returns weld-end-to-face length.\n */\nexport function portFlange(k: Kit, nps: number, rating: Rating, face: V3, dir: V3, o: FlangeOpts = {}) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const ri = Math.min(o.bore ?? ro - pipeWall(nps, 'STD'), ro - 0.0015)\n  const rf = f.raisedFaceHeight\n  const L = f.hubLength + rf\n  const back = L - rf - f.thickness\n  const hubR = Math.max(f.hubDia / 2, ro * 1.05)\n  const cast = o.mat !== undefined\n  const mat: MatKey = o.mat ?? 'flangeSteel'\n  const r0 = cast ? Math.max(ro * 1.08, ri + 0.004) : ro\n  const seg = SEG(nps)\n  const base = add(face, dir, -L)\n  k.lathe(mat, [[ri, 0], [r0, 0], [hubR, Math.max(back - 0.004, back * 0.7)], [hubR + 0.004, back], [f.od / 2, back], [f.od / 2, L - rf], [f.raisedFace / 2, L - rf]], base, dir, seg)\n  k.lathe('machined', [[f.raisedFace / 2, L - rf], [f.raisedFace / 2, L], [ri, L]], base, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], base, dir, seg)\n  if (o.studs !== false) {\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 L\n}\n\n/** Bore radius of a pipe size and schedule. */\nexport const boreOf = (nps: number, schedule: Schedule) => pipeOD(nps) / 2 - pipeWall(nps, schedule)\n\n/** Full joint stud set (studs + two nuts each) centred on the gasket plane at `centre`, as the assembler draws it. */\nexport function jointStuds(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(centre, dir, -studL / 2), dir, f.boltDia, studL, [f.boltDia * 0.6, studL - f.boltDia * 0.6])\n}\n\nconst BEVEL = Math.tan((37.5 * Math.PI) / 180)\nexport const LAND = 0.0016\n\n/** Axial depth of an ASME B16.25 37.5° weld bevel with a 1.6 mm land. */\nexport const bevelDepth = (nps: number, schedule: Schedule) => Math.max(0, pipeWall(nps, schedule) - LAND) * BEVEL\n\n/** Straight pipe from `start` along `dir` for length L. Ends: 'bevel' (machined B16.25 prep), 'square' (closed wall end) or 'open' (no end face, for joints). */\nexport type PipeEnd = 'bevel' | 'square' | 'open'\nexport function straight(k: Kit, mat: MatKey, start: V3, dir: V3, L: number, nps: number, schedule: Schedule, endA: PipeEnd = 'square', endB: PipeEnd = 'square', seg = SEG(nps)) {\n  if (L <= 1e-5) return\n  const ro = pipeOD(nps) / 2\n  const ri = ro - pipeWall(nps, schedule)\n  const h = Math.min(bevelDepth(nps, schedule), L / 3)\n  const hA = endA === 'bevel' ? h : 0\n  const hB = endB === 'bevel' ? h : 0\n  k.lathe(mat, [[ro, hA], [ro, L - hB]], start, dir, seg)\n  if (hA) k.lathe('machined', [[ri, 0], [ri + LAND, 0], [ro, hA]], start, dir, seg)\n  else if (endA === 'square') k.lathe(mat, [[ri, 0], [ro, 0]], start, dir, seg)\n  if (hB) k.lathe('machined', [[ro, L - hB], [ri + LAND, L], [ri, L]], start, dir, seg)\n  else if (endB === 'square') k.lathe(mat, [[ro, L], [ri, L]], start, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], start, dir, seg)\n}\n\n/**\n * Quad mesh from rows of points P[i][j]. With `normal`, normals are analytic and the winding follows them;\n * otherwise `out` (a rough outward direction) picks the winding and normals are computed.\n */\nexport function sheet(k: Kit, mat: MatKey, P: V3[][], normal?: (i: number, j: number) => V3, out?: (i: number, j: number) => V3) {\n  const nr = P.length\n  const nc = P[0].length\n  const pos: number[] = []\n  const nor: number[] = []\n  for (let i = 0; i < nr; i++)\n    for (let j = 0; j < nc; j++) {\n      pos.push(...P[i][j])\n      if (normal) nor.push(...normal(i, j))\n    }\n  const V = (q: V3) => new THREE.Vector3(...q)\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 n = V(P[i + 1][j]).sub(a).cross(V(P[i][j + 1]).sub(a))\n      if (n.lengthSq() < 1e-14) continue\n      const ref = normal?.(i, j) ?? out?.(i, j)\n      sign = !ref || n.dot(V(ref)) >= 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      const c = a + 1\n      const d = b + 1\n      if (sign >= 0) idx.push(a, b, c, b, d, c)\n      else idx.push(a, c, b, b, c, d)\n    }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  if (normal) g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n  g.setIndex(idx)\n  if (!normal) g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n/** Circumferential butt weld cap: slightly proud bead. */\nexport function buttWeld(k: Kit, nps: number, pos: V3, dir: V3) {\n  const r = pipeOD(nps) / 2\n  const w = Math.max(0.006, Math.min(0.02, pipeWall(nps, 'STD') * 1.6))\n  k.torus('flangeSteel', r + 0.0006, w / 2, pos, dir, 6, nps >= 6 ? 48 : 32)\n}\n\n/**\n * Surface swept as a grid: f(s, phi) returns a local point (s along +Y from 0 to 1, phi around); x = .. sin(phi), z = .. cos(phi)\n * keeps the same handedness as a lathe so normals face out. Placed at `pos` with local +Y on `axis`.\n */\nexport function gridSurface(k: Kit, mat: MatKey, f: (s: number, phi: number) => V3, nu: number, nv: number, pos: V3 = [0, 0, 0], axis: Orient = 'y', inward = false) {\n  const p: number[] = []\n  const uv: number[] = []\n  const idx: number[] = []\n  for (let i = 0; i <= nu; i++)\n    for (let j = 0; j <= nv; j++) {\n      const q = f(i / nu, (j / nv) * Math.PI * 2)\n      p.push(q[0], q[1], q[2])\n      uv.push(j / nv, i / nu)\n    }\n  for (let i = 0; i < nu; i++)\n    for (let j = 0; j < nv; j++) {\n      const a = i * (nv + 1) + j\n      const b = a + nv + 1\n      if (inward) 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(p, 3))\n  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  // weld the seam normals (phi = 0 and 2π are duplicate vertices)\n  const n = g.getAttribute('normal') as THREE.BufferAttribute\n  const t = new THREE.Vector3()\n  for (let i = 0; i <= nu; i++) {\n    const a = i * (nv + 1)\n    const b = a + nv\n    t.set(n.getX(a) + n.getX(b), n.getY(a) + n.getY(b), n.getZ(a) + n.getZ(b)).normalize()\n    n.setXYZ(a, t.x, t.y, t.z)\n    n.setXYZ(b, t.x, t.y, t.z)\n  }\n  k.add(mat, g, mat4(pos, axis))\n}\n\n/** ISO 14726 identification band (basic colour) around a pipe of outer radius r. */\nexport function serviceBand(k: Kit, service: Service, pos: V3, dir: V3, r: number) {\n  const w = Math.min(0.15, Math.max(0.05, r * 1.6 + 0.02))\n  k.cyl(paint(SERVICE[service].band), r + 0.0012, w, pos, dir, 40, undefined, true)\n  return w\n}\n\n/** typical: insulation thickness for hot / personnel-protection service, m. */\nexport const insulationThickness = (nps: number) => (nps <= 2 ? 0.04 : nps <= 6 ? 0.05 : nps <= 12 ? 0.065 : 0.075)\n\n/**\n * Aluminium-jacketed insulation from `start` along `dir` for length L on pipe NPS `nps`: bevelled weather end caps,\n * 0.9 m sheet laps and stainless bands at 229 mm. Returns the jacket radius.\n */\nexport function cladding(k: Kit, start: V3, dir: V3, L: number, nps: number) {\n  const ro = pipeOD(nps) / 2\n  const R = ro + insulationThickness(nps)\n  const cap = Math.min(insulationThickness(nps) * 0.9, L / 4)\n  if (L < 2 * cap + 0.02) return R\n  k.lathe('insulation', [[ro + 0.001, 0], [R, cap], [R, L - cap], [ro + 0.001, L]], start, dir, 48)\n  const at = (s: number) => add(start, dir, s)\n  for (let s = 0.9; s < L - cap - 0.05; s += 0.9) k.cyl('insulation', R + 0.0009, 0.05, at(s), dir, 48, undefined, true)\n  // bands at most 229 mm (9 in) on centre\n  const n = Math.max(1, Math.ceil((L - 2 * cap) / 0.229))\n  for (let i = 0; i <= n; i++) {\n    const s = cap + 0.04 + ((L - 2 * cap - 0.08) * i) / n\n    k.cyl('stainless', R + 0.0016, 0.019, at(s), dir, 48, undefined, true)\n  }\n  return R\n}\n\n/** Small quarter-turn ball valve centred at `pos`, bore along `dir`, lever lying along `lever` (unit, not parallel to dir). */\nexport function smallBallValve(k: Kit, nps: number, pos: V3, dir: V3, lever: V3, leverMat: MatKey = 'actuatorRed', bodyMat: MatKey = 'stainless') {\n  const d = pipeOD(nps)\n  const L = d * 2.6\n  const D = new THREE.Vector3(...dir).normalize()\n  const up = new THREE.Vector3(...lever).normalize()\n  const stem = new THREE.Vector3().crossVectors(up, D).normalize()\n  const S = stem.toArray() as V3\n  k.cyl(bodyMat, d * 0.62, L * 0.55, pos, dir, 16)\n  k.sphere(bodyMat, d * 0.85, pos, undefined, 16)\n  for (const s of [-1, 1]) k.hex(bodyMat, d * 1.5, L * 0.22, add(pos, dir, (s * L) / 2 - s * L * 0.11), dir)\n  const top = add(pos, S, d * 1.15)\n  k.rod(bodyMat, add(pos, S, d * 0.6), top, d * 0.18, 8)\n  k.hex('nut', d * 0.5, d * 0.25, top, S)\n  const ll = Math.max(0.1, d * 4.5)\n  const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(up, stem, up.clone().cross(stem)))\n  k.box(leverMat, [ll, d * 0.12, d * 0.4], add(add(top, S, d * 0.18), [up.x, up.y, up.z], ll / 2 - d * 0.2), q)\n  return L\n}\n\n/** ASME B16.20 spiral-wound gasket (approx dims): inner ring, 316L / graphite winding, colour-coded centring ring against the studs. */\nexport function spiralWound(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const wi = ro + 0.003\n  const wo = f.raisedFace / 2 - 0.0015\n  const co = f.boltCircle / 2 - f.boltDia / 2 - 0.001\n  const t = 0.0032\n  const ring = (mat: MatKey, r0: number, r1: number, h: number) => k.lathe(mat, [[r0, -h / 2], [r1, -h / 2], [r1, h / 2], [r0, h / 2], [r0, -h / 2]], centre, dir, 48)\n  ring('stainless', ro * 0.94, wi, t)\n  ring('gasket', wi, wo, GAP)\n  ring('galvanised', wo, co, t)\n  // periphery colour code: continuous green (316L winding) with grey stripes (flexible graphite filler)\n  k.cyl(paint('#2f8f46'), co + 0.0004, t * 0.9, centre, dir, 48, undefined, true)\n  const d = new THREE.Vector3(...dir).normalize()\n  const ref = Math.abs(d.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)\n  const u = new THREE.Vector3().crossVectors(d, ref).normalize()\n  const w = new THREE.Vector3().crossVectors(d, u)\n  for (let i = 0; i < 4; i++) {\n    const a = (i / 4) * Math.PI * 2 + 0.4\n    const p = new THREE.Vector3(...centre).addScaledVector(u, Math.cos(a) * (co + 0.0008)).addScaledVector(w, Math.sin(a) * (co + 0.0008))\n    const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(d, u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)), d.clone().cross(u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)))))\n    k.box(paint('#5a5d60'), [t * 0.95, 0.001, 0.012], p.toArray() as V3, q)\n  }\n}\n\n/** Laminated joint tag cable-tied to the outermost stud on the +Z side, hanging past the nut. */\nexport function jointTag(k: Kit, nps: number, rating: Rating, tag: string, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  let best = 0\n  let bz = -1\n  for (let i = 0; i < f.bolts; i++) {\n    const a = Math.PI / f.bolts + (i / f.bolts) * Math.PI * 2\n    if (Math.cos(a) > bz) [bz, best] = [Math.cos(a), a]\n  }\n  const R = f.boltCircle / 2\n  const y = -Math.sin(best) * R\n  const z = Math.cos(best) * R\n  const x = studL / 2 - f.boltDia * 0.1\n  k.torus('rubberBlack', f.boltDia * 0.62, 0.0012, [x, y, z], 'x', 4, 16)\n  const q = new THREE.Quaternion()\n  const p: V3 = [x + 0.001, y - f.boltDia * 0.6 - 0.025, z + f.boltDia * 0.2]\n  k.box('plasticGrey', [0.0015, 0.05, 0.034], p, q)\n  k.plane(textMaterial(tag, '#f4c400', '#111111', 256, 128), 0.032, 0.046, [p[0] + 0.0009, p[1], p[2]], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2))\n}\n\nexport interface Ring {\n  c: V3\n  e1: V3\n  e2: V3\n  r: number\n}\n\n/** Sheet through rings: point = c + r (cos a e1 + sin a e2) for each angle a. Radial normals, or computed with `out` picking the winding. */\nexport function ringSheet(k: Kit, mat: MatKey, rings: Ring[], angles: number[], normals: 'out' | 'in' | 'computed', out?: V3) {\n  const P = rings.map((g) => angles.map((a) => [0, 1, 2].map((i) => g.c[i] + g.r * (Math.cos(a) * g.e1[i] + Math.sin(a) * g.e2[i])) as V3))\n  const rad = (i: number, j: number): V3 => {\n    const g = rings[i]\n    const a = angles[j]\n    const sg = normals === 'in' ? -1 : 1\n    return [0, 1, 2].map((q) => sg * (Math.cos(a) * g.e1[q] + Math.sin(a) * g.e2[q])) as V3\n  }\n  if (normals === 'computed') sheet(k, mat, P, undefined, (i, j) => (out ? add(out, rad(i, j), 0.3) : rad(i, j)))\n  else sheet(k, mat, P, rad)\n}\n\n/** Uniform angles 0…2π for n segments. */\nexport const turn = (n: number) => Array.from({ length: n + 1 }, (_, j) => (j / n) * Math.PI * 2)\n\n/**\n * B16.25 weld prep on a ring-sampled end: bevel from the outer skin at `inner` (h back from the face) to the land at\n * the face `c`, then the land down to the bore. t points out of the end.\n */\nexport function bevelEnd(k: Kit, c: V3, t: V3, e1: V3, e2: V3, ro: number, ri: number, h: number, angles: number[]) {\n  const back = add(c, t, -h)\n  ringSheet(k, 'machined', [{ c: back, e1, e2, r: ro }, { c, e1, e2, r: ri + LAND }], angles, 'computed', t)\n  ringSheet(k, 'machined', [{ c, e1, e2, r: ri + LAND }, { c, e1, e2, r: ri }], angles, 'computed', t)\n}\n\nexport interface Hole {\n  /** half-width of the hole edge across the shell (z) */\n  A: number\n  /** half-length of the hole edge along the shell (x) */\n  B: number\n}\n\nconst NH = 24\n/** Angles shared by holed shells and the nozzles that fit them (2·NH + 1 samples from -π/2). */\nexport const nozzleAngles = Array.from({ length: 2 * NH + 1 }, (_, j) => -Math.PI / 2 + (j * Math.PI) / NH)\nconst ysq = (R: number, z: number) => Math.sqrt(Math.max(R * R - z * z, 0))\n\n/**\n * Cylinder of radius R along local X from x0 to x1 with optional holes centred at x = 0 on +Y (top) and -Y (bottom).\n * Hole edge: (B cos φ, ±√(R² − z²), z = ±A sin φ). Points are (x, R cos θ, R sin θ); returns the row angles θ.\n */\nexport function holedShell(k: Kit, mat: MatKey, R: number, x0: number, x1: number, top: Hole | null, bottom: Hole | null, outward: boolean) {\n  const rows: { th: number; xb: number }[] = []\n  const band = (h: Hole | null, c: number) => {\n    if (!h) return void rows.push({ th: c, xb: 0 })\n    for (let j = 0; j <= NH; j++) rows.push({ th: c + Math.asin((h.A * Math.sin(nozzleAngles[j])) / R), xb: h.B * Math.cos(nozzleAngles[j]) })\n  }\n  const fill = (a: number, b: number, n: number) => {\n    for (let i = 1; i < n; i++) rows.push({ th: a + ((b - a) * i) / n, xb: 0 })\n  }\n  const t1 = top ? Math.asin(Math.min(top.A / R, 1)) : 0\n  const t2 = bottom ? Math.asin(Math.min(bottom.A / R, 1)) : 0\n  band(top, 0)\n  fill(t1, Math.PI - t2, 20)\n  band(bottom, Math.PI)\n  fill(Math.PI + t2, 2 * Math.PI - t1, 20)\n  rows.push({ ...rows[0], th: rows[0].th + 2 * Math.PI })\n  const pt = (x: number, th: number): V3 => [x, R * Math.cos(th), R * Math.sin(th)]\n  const sg = outward ? 1 : -1\n  const n = (i: number): V3 => [0, sg * Math.cos(rows[i].th), sg * Math.sin(rows[i].th)]\n  sheet(k, mat, rows.map((r) => [pt(Math.max(r.xb, 0), r.th), pt(x1, r.th)]), (i) => n(i))\n  sheet(k, mat, rows.map((r) => [pt(x0, r.th), pt(-Math.max(r.xb, 0), r.th)]), (i) => n(i))\n  return rows.map((r) => r.th)\n}\n\n/** Nozzle skin of radius r (= hole A = B) from its intersection with a shell of radius R out to |y| = yEnd, on side ±1 of a holedShell. */\nexport function nozzleSkin(k: Kit, mat: MatKey, R: number, r: number, side: 1 | -1, yEnd: number, outward: boolean) {\n  const base = nozzleAngles.map((f) => [r * Math.cos(f), side * ysq(R, r * Math.sin(f)), side * r * Math.sin(f)] as V3)\n  const end = nozzleAngles.map((f) => [r * Math.cos(f), side * yEnd, side * r * Math.sin(f)] as V3)\n  const sg = outward ? 1 : -1\n  sheet(k, mat, [base, end], (_, j) => [sg * Math.cos(nozzleAngles[j]), 0, sg * side * Math.sin(nozzleAngles[j])])\n}\n"
    }
  ],
  "categories": [
    "piping"
  ],
  "meta": {
    "export": "Elbow",
    "anchor": "fitting",
    "standards": [
      "ASME B16.9",
      "ASME B16.25",
      "ASME B16.5",
      "MSS SP-75"
    ]
  }
}