{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "y-strainer",
  "type": "registry:component",
  "title": "Y-strainer",
  "description": "Flanged cast-steel Y-strainer: body with integral raised-face flanges, 45° screen leg pointing downstream, bolted screen cap with a quarter-turn blowdown valve, drain plug, cast flow arrows and nameplate. Face-to-face and screen-leg length follow typical vendor tables for class 150 / 300 (about 3 x NPS face-to-face at class 150), blow-off 1 in at NPS 2 up to 2 in from NPS 6.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "piping/y-strainer.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/piping/y-strainer.tsx",
      "content": "// Flanged cast Y-strainer: body with integral RF flanges, 45° screen leg, bolted screen cap and blowdown ball valve. ASME B16.5 / B16.34 body, typical globe face-to-face.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { inch, pipeOD, pipeWall, type Rating } from '../lib/standards'\nimport { add, flangeLength, portFlange, sheet, smallBallValve, turn } from './_piping'\n\nexport interface YStrainerProps {\n  nps?: number\n  rating?: Rating\n  /** screen mesh, shown on the nameplate */\n  mesh?: number\n  /** blowdown valve on the screen cap */\n  blowdown?: boolean\n  body?: MatKey\n}\n\nexport const defaults = { nps: 6, rating: 300 as Rating, mesh: 40, blowdown: true, body: 'valveBody' as MatKey }\n\n// typical: face-to-face A and centreline-to-cap B (inches) of flanged cast-steel Y-strainers in vendor tables\nconst A150: [number, number][] = [[0.5, 4.25], [1, 5.5], [1.5, 6.5], [2, 7.88], [3, 10], [4, 12.13], [6, 18.5], [8, 21.63], [10, 26], [12, 30.5], [16, 38]]\nconst A300: [number, number][] = [[0.5, 4.75], [1, 6], [1.5, 7.25], [2, 8.63], [3, 11.25], [4, 14.5], [6, 20], [8, 23.38], [10, 28.5], [12, 33], [16, 41]]\nconst B150: [number, number][] = [[0.5, 2.25], [1, 3.25], [2, 6], [4, 9.75], [6, 14.25], [8, 18], [10, 22.5], [16, 34]]\nconst lerp = (t: [number, number][], x: number) => {\n  if (x <= t[0][0]) return t[0][1]\n  for (let i = 1; i < t.length; i++) if (x <= t[i][0]) return t[i - 1][1] + ((t[i][1] - t[i - 1][1]) * (x - t[i - 1][0])) / (t[i][0] - t[i - 1][0])\n  const [a, b] = [t[t.length - 2], t[t.length - 1]]\n  return b[1] + ((b[1] - a[1]) * (x - b[0])) / (b[0] - a[0])\n}\nconst classK = (r: Rating) => (r <= 300 ? 1 : 1 + 0.12 * ([600, 900, 1500, 2500].indexOf(r) + 1))\nfunction length(nps: number, rating: Rating) {\n  const a = inch(rating <= 150 ? lerp(A150, nps) : lerp(A300, nps) * classK(rating))\n  // never shorter than the two flanges plus a body\n  return Math.max(a, 2 * flangeLength(nps, rating) + pipeOD(nps) * 1.4)\n}\nconst legLength = (nps: number, rating: Rating) => inch(lerp(B150, nps)) * (rating <= 150 ? 1 : 1.05 * classK(rating))\n\nexport function ports(p: YStrainerProps = {}): Port[] {\n  const { nps, rating } = { ...defaults, ...p }\n  const L = length(nps, rating)\n  return [port('A', 'Inlet', [-L / 2, 0, 0], [-1, 0, 0], nps, rating), port('B', 'Outlet', [L / 2, 0, 0], [1, 0, 0], nps, rating)]\n}\n\nexport function YStrainer({ nps = defaults.nps, rating = defaults.rating, mesh = defaults.mesh, blowdown = defaults.blowdown, body = defaults.body, ...group }: YStrainerProps & GroupProps) {\n  const data = useKit((k) => build(k, nps, rating, mesh, blowdown, body), [nps, rating, mesh, blowdown, body])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default YStrainer\n\nfunction build(k: Kit, nps: number, rating: Rating, mesh: number, blowdown: boolean, mat: MatKey) {\n  const D = pipeOD(nps)\n  const L = length(nps, rating)\n  const fl = flangeLength(nps, rating)\n  const bore = D / 2 - pipeWall(nps, '40')\n  for (const s of [-1, 1]) portFlange(k, nps, rating, [(s * L) / 2, 0, 0], [s, 0, 0], { mat, bore })\n  // through-bore between the flanges (the body castings around it are closed shells)\n  k.lathe('pipeBore', [[bore, L / 2 - fl], [bore, -L / 2 + fl]], [0, 0, 0], 'x', 32)\n  k.cyl(mat, D * 0.56, L - 2 * fl + 0.01, [0, 0, 0], 'x', 32, undefined, true)\n  // bulb: the part of an ellipsoid of revolution that stands proud of the neck, so nothing intrudes into the bore\n  const rx = Math.min(L / 2 - fl - 0.004, D * 1.05)\n  const ry = D * 0.68\n  const rz = ry\n  const rn = D * 0.56\n  const x0 = rx * Math.sqrt(1 - (rn / ry) ** 2)\n  const prof: [number, number][] = []\n  for (let i = 0; i <= 24; i++) {\n    const x = -x0 + (2 * x0 * i) / 24\n    prof.push([ry * Math.sqrt(Math.max(0, 1 - (x / rx) ** 2)), x])\n  }\n  k.lathe(mat, prof, [0, 0, 0], 'x', 36)\n\n  // screen leg, downstream and down at 45°\n  const c = Math.SQRT1_2\n  const d: V3 = [c, -c, 0]\n  const rl = D * 0.6\n  const tc = Math.max(0.012, D * 0.13)\n  const g = 0.003\n  const Ll = Math.max(D * 1.2, legLength(nps, rating) - g - tc - 0.012)\n  // leg skin starts where its wall leaves the bore cylinder\n  const e1: V3 = [c, c, 0]\n  const legAng = turn(36)\n  const s0 = (a: number) => {\n    const q = bore * bore - (rl * Math.sin(a)) ** 2\n    return q <= 0 ? 0 : Math.max(0, rl * Math.cos(a) + Math.sqrt(q) / c + 0.002)\n  }\n  const legPt = (sv: number, a: number): V3 => [0, 1, 2].map((i) => sv * d[i] + rl * (Math.cos(a) * e1[i] + Math.sin(a) * (i === 2 ? 1 : 0))) as V3\n  sheet(k, mat, [legAng.map((a) => legPt(s0(a), a)), legAng.map((a) => legPt(Ll, a))], (_, j) => [c * Math.cos(legAng[j]), c * Math.cos(legAng[j]), Math.sin(legAng[j])])\n  k.cyl(mat, rl * 1.08, Ll * 0.25, add([0, 0, 0], d, Ll * 0.86), d, 32, rl * 1.12, true)\n  const rc = rl + Math.max(0.022, D * 0.27)\n  const e = add([0, 0, 0], d, Ll)\n  k.cyl(mat, rc, tc, add(e, d, -tc / 2), d, 40)\n  k.lathe('gasket', [[rl * 0.85, 0], [rc - 0.004, 0], [rc - 0.004, g], [rl * 0.85, g], [rl * 0.85, 0]], e, d, 40)\n  k.cyl(mat, rc, tc, add(e, d, g + tc / 2), d, 40)\n  const bd = Math.max(0.008, Math.min(0.03, D * 0.075))\n  const n = nps >= 8 ? 12 : nps >= 3 ? 8 : 4\n  const studL = 2 * tc + g + bd * 2.2\n  k.boltCircle('boltXylan', 'nut', n, rl + (rc - rl) * 0.55, add(e, d, -tc - bd * 1.1), d, bd, studL, [bd * 0.45, studL - bd * 0.45])\n\n  // cover boss with blowdown nipple, quarter-turn valve and plug\n  // typical blow-off: 1 in at NPS 2, 1-1/2 in at NPS 4, 2 in from NPS 6\n  const bn = nps < 2 ? 0.5 : nps < 4 ? 1 : nps < 6 ? 1.5 : 2\n  const bD = pipeOD(bn)\n  const c0 = add(e, d, g + tc)\n  k.cyl(mat, bD * 0.9, 0.012, add(c0, d, 0.006), d, 20)\n  if (blowdown) {\n    const nip = bD * 1.4 + 0.02\n    k.cyl('galvanised', bD / 2, nip, add(c0, d, 0.012 + nip / 2), d, 16)\n    const vl = bD * 2.6\n    const vc = add(c0, d, 0.012 + nip + vl / 2)\n    smallBallValve(k, bn, vc, d, [0, 0, 1])\n    const tail = add(vc, d, vl / 2)\n    k.cyl('galvanised', bD / 2, bD * 1.2, add(tail, d, bD * 0.6), d, 16)\n    k.hex('nut', bD * 1.25, bD * 0.6, add(tail, d, bD * 1.4), d)\n  } else k.hex('nut', bD * 1.25, bD * 0.6, add(c0, d, 0.012 + bD * 0.3), d)\n\n  // drain boss with plug on the inlet side of the bulb\n  const dr: V3 = [-rx * 0.45, -ry * 0.88, 0]\n  k.cyl(mat, D * 0.11, D * 0.12, dr, 'y', 16)\n  k.hex('nut', D * 0.16, D * 0.06, [dr[0], dr[1] - D * 0.08, 0], 'y')\n\n  // cast flow arrow on both sides and a stamped nameplate on top\n  for (const s of [-1, 1]) {\n    const z = s * rz * 0.97\n    k.box(mat, [rx * 0.8, D * 0.07, D * 0.06], [-rx * 0.1, 0, z])\n    const head = new THREE.Shape()\n    const hw = D * 0.14\n    head.moveTo(0, -hw / 2)\n    head.lineTo(hw * 0.9, 0)\n    head.lineTo(0, hw / 2)\n    head.closePath()\n    k.extrude(mat, head, D * 0.06, new THREE.Matrix4().makeTranslation(rx * 0.3, 0, z - D * 0.03), 2)\n  }\n  tagPlate(k, `${nps}\" CL${rating} ${mesh} MESH`, [0, ry * 0.98, 0], 'y', Math.max(0.05, D * 0.55))\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": "YStrainer",
    "anchor": "inline",
    "standards": [
      "ASME B16.34",
      "ASME B16.5"
    ]
  }
}