{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pressure-safety-valve",
  "type": "registry:component",
  "title": "Pressure safety valve (PSV)",
  "description": "Flanged spring-loaded relief valve with an angle body, bolted bonnet, closed or open-yoke spring housing with exposed coil, screwed cap with seal wire, optional packed lifting lever and bellows vent. Inlet and outlet sizes, classes and centre-to-face dimensions come from API 526 for the orifice letter and inlet class. Origin at the body centre; inlet faces down, outlet faces +X.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "valves/pressure-safety-valve.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/valves/pressure-safety-valve.tsx",
      "content": "// Spring-loaded pressure safety valve: flanged angle body, bolted bonnet, closed or open-yoke spring housing, cap, optional packed lifting lever. API 526 / API 520.\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 { flange, pipeOD, pipeWall, psv, type Orifice, type Rating } from '../lib/standards'\nimport { clamp, integralFlange, plug } from './_body'\n\nexport interface PressureSafetyValveProps {\n  /** API 526 orifice letter */\n  orifice?: Orifice\n  /** inlet flange class; the outlet class follows API 526 */\n  inletRating?: Rating\n  bonnet?: 'closed' | 'open'\n  /** packed lifting lever on the cap */\n  lever?: boolean\n  /** balanced bellows: vented bonnet */\n  bellows?: boolean\n  body?: MatKey\n  /** spring bonnet and cap paint */\n  color?: MatKey\n  tag?: string\n}\n\nexport const defaults = {\n  orifice: 'J' as Orifice,\n  inletRating: 300 as Rating,\n  bonnet: 'closed' as 'closed' | 'open',\n  lever: false,\n  bellows: false,\n  body: 'valveBody' as MatKey,\n  color: 'actuatorRed' as MatKey,\n  tag: '',\n}\n\nexport function ports(p: PressureSafetyValveProps = {}): Port[] {\n  const { orifice, inletRating } = { ...defaults, ...p }\n  const d = psv(orifice, inletRating)\n  return [\n    port('inlet', 'Inlet', [0, -d.centreToInlet, 0], [0, -1, 0], d.inletNps, d.inletRating),\n    port('outlet', 'Outlet', [d.centreToOutlet, 0, 0], [1, 0, 0], d.outletNps, d.outletRating, 'relief'),\n  ]\n}\n\nexport function PressureSafetyValve(props: PressureSafetyValveProps & GroupProps) {\n  const { orifice, inletRating, bonnet, lever, bellows, body, color, tag, ...group } = { ...defaults, ...props }\n  const data = useKit((k) => build(k, orifice, inletRating, bonnet, lever, bellows, body, color, tag), [orifice, inletRating, bonnet, lever, bellows, body, color, tag])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default PressureSafetyValve\n\nfunction build(k: Kit, orifice: Orifice, inletRating: Rating, bonnet: 'closed' | 'open', lever: boolean, bellows: boolean, M: MatKey, C: MatKey, tag: string) {\n  const d = psv(orifice, inletRating)\n  const A = d.centreToInlet\n  const B = d.centreToOutlet\n  const fi = flange(d.inletNps, d.inletRating)\n  const fo = flange(d.outletNps, d.outletRating)\n  const Di = pipeOD(d.inletNps)\n  const Do = pipeOD(d.outletNps)\n  const rni = Math.max(Di / 2 * 1.12, (fi.hubDia / 2) * 0.92)\n  const rno = Math.max(Do / 2 * 1.06, (fo.hubDia / 2) * 0.9)\n  const bodyR = Math.max(Do * 0.6, Di * 0.85, 0.05)\n  // inlet flange (−Y) and nozzle\n  integralFlange(k, fi, [0, -A, 0], '-y', rni, Di / 2 - pipeWall(d.inletNps, '80'), M)\n  const yi = -A + fi.raisedFaceHeight + fi.thickness\n  k.lathe(M, [[rni, yi - 0.004], [rni, -bodyR * 0.75], [bodyR * 0.85, -bodyR * 0.35], [bodyR * 0.85, 0]], [0, 0, 0], 'y', 32)\n  k.lathe('pipeBore', [[Di / 2 - pipeWall(d.inletNps, '80'), -A], [Di / 2 - pipeWall(d.inletNps, '80'), -A + fi.thickness * 1.6]], [0, 0, 0], 'y', 20)\n  k.disc('pipeBore', Di / 2 - pipeWall(d.inletNps, '80'), [0, -A + fi.thickness * 1.6, 0], '-y', 20)\n  // body bowl and outlet nozzle (+X)\n  k.sphere(M, bodyR, [0, 0, 0], [1.08, 1, 1], 36)\n  const xo = B - fo.raisedFaceHeight - fo.thickness\n  k.lathe(M, [[bodyR * 0.8, 0], [rno * 1.04, xo * 0.55], [rno, xo + 0.004]], [0, 0, 0], 'x', 32)\n  integralFlange(k, fo, [B, 0, 0], 'x', rno, Do / 2 - pipeWall(d.outletNps, 'STD'), M)\n  k.lathe('pipeBore', [[Do / 2 - pipeWall(d.outletNps, 'STD'), B], [Do / 2 - pipeWall(d.outletNps, 'STD'), B - fo.thickness * 1.6]], [0, 0, 0], 'x', 20)\n  k.disc('pipeBore', Do / 2 - pipeWall(d.outletNps, 'STD'), [B - fo.thickness * 1.6, 0, 0], 'x', 20)\n  // body drain, adjusting-ring lock screw, nameplate on the bowl\n  plug(k, M, [bodyR * 0.25, -bodyR * 0.92, 0], '-y', Math.max(0.012, bodyR * 0.16))\n  plug(k, M, [-bodyR * 0.2, -bodyR * 0.25, -bodyR * 0.93], '-z', Math.max(0.012, bodyR * 0.18))\n  // bonnet flange and studs\n  const yB0 = bodyR * 0.82\n  const rF = bodyR * 0.95\n  const tF = clamp(bodyR * 0.22, 0.014, 0.05)\n  k.cyl(M, rF * 0.82, yB0 * 0.4, [0, yB0 * 0.8, 0], 'y', 32)\n  k.cyl(M, rF, tF, [0, yB0 + tF / 2, 0], 'y', 36)\n  const bd = clamp(bodyR * 0.11, 0.01, 0.03)\n  const rB = Math.min(bodyR * 0.9, rF - bd * 3)\n  k.boltCircle('boltXylan', 'nut', 8, rF - bd * 1.4, [0, yB0 - bd * 0.6, 0], 'y', bd, tF + bd * 2, [tF + bd * 1.4], Math.PI / 8)\n  // spring housing up to the cap\n  const top = d.height - A\n  const sc = clamp(bodyR / 0.075, 0.6, 3)\n  const capH = 0.075 * sc\n  const y1 = yB0 + tF\n  const H = top - capH - y1\n  if (bonnet === 'closed') {\n    k.lathe(C, [\n      [rB, y1], [rB, y1 + H * 0.05], [rB * 0.9, y1 + H * 0.1], [rB * 0.66, y1 + H * 0.86],\n      [rB * 0.72, y1 + H * 0.88], [rB * 0.72, y1 + H * 0.97], [rB * 0.55, y1 + H], [0, y1 + H],\n    ], [0, 0, 0], 'y', 36)\n    for (let i = 0; i < 4; i++) {\n      const a = (i / 4) * Math.PI * 2 + Math.PI / 4\n      const at = (r: number, y: number): V3 => [Math.cos(a) * r, y, Math.sin(a) * r]\n      k.rod(C, at(rB * 0.86, y1 + H * 0.1), at(rB * 0.64, y1 + H * 0.86), rB * 0.06, 8)\n    }\n    // bonnet vent: plugged on a conventional valve, left open on a balanced-bellows valve\n    const vr = Math.max(0.008, rB * 0.11)\n    k.cyl(C, vr * 1.5, vr * 2, [rB * 0.86, y1 + H * 0.14, 0], 'x', 14)\n    if (bellows) {\n      k.disc('pipeBore', vr * 0.75, [rB * 0.86 + vr + 0.0005, y1 + H * 0.14, 0], 'x', 12)\n      k.torus('brass', vr * 0.85, vr * 0.15, [rB * 0.86 + vr, y1 + H * 0.14, 0], 'x', 6, 16)\n    } else k.hex('nut', vr * 2, vr * 0.8, [rB * 0.86 + vr * 1.3, y1 + H * 0.14, 0], 'x')\n  } else {\n    // open yoke: base plate, two posts, top plate, exposed coil spring with buttons\n    k.cyl(C, rB, H * 0.06, [0, y1 + H * 0.03, 0], 'y', 32)\n    for (const s of [-1, 1]) k.box(C, [rB * 0.28, H * 0.9, rB * 0.22], [0, y1 + H * 0.5, s * rB * 0.82])\n    k.box(C, [rB * 0.6, H * 0.08, rB * 1.9], [0, y1 + H * 0.96, 0])\n    k.cyl(C, rB * 0.5, H * 0.08, [0, y1 + H * 0.96, 0], 'y', 28)\n    const sb = y1 + H * 0.12\n    const st = y1 + H * 0.86\n    k.cyl('stainless', rB * 0.6, H * 0.04, [0, sb, 0], 'y', 28)\n    k.cyl('stainless', rB * 0.6, H * 0.04, [0, st, 0], 'y', 28)\n    k.cyl('stainless', rB * 0.1, H, [0, y1 + H / 2, 0], 'y', 10)\n    const turns = 7\n    const rs = rB * 0.5\n    const wire = rB * 0.09\n    const pts: V3[] = []\n    for (let i = 0; i <= turns * 16; i++) {\n      const t = i / 16\n      pts.push([Math.cos(t * Math.PI * 2) * rs, sb + 0.02 + ((st - sb - 0.04) * t) / turns, Math.sin(t * Math.PI * 2) * rs])\n    }\n    k.spline('flangeSteel', pts, wire, 8, turns * 24)\n  }\n  // adjusting-screw locknut and cap\n  k.hex('nut', rB * 0.5, capH * 0.2, [0, top - capH + capH * 0.1, 0])\n  if (lever) {\n    k.cyl(C, rB * 0.5, capH * 0.75, [0, top - capH * 0.45, 0], 'y', 28)\n    k.dome(C, rB * 0.5, [0, top - capH * 0.08, 0], 'y', Math.PI / 2, 0.25, 24)\n    const yl = top - capH * 0.45\n    k.cyl('nut', rB * 0.08, rB * 1.25, [0, yl, 0], 'z', 10)\n    k.hex('nut', rB * 0.2, rB * 0.1, [0, yl, rB * 0.6], 'z')\n    const arm = rB * 2.6\n    k.box(C, [rB * 0.12, rB * 0.18, rB * 0.1], [0, yl, rB * 0.68])\n    k.add(C, new THREE.BoxGeometry(arm, rB * 0.12, rB * 0.08), new THREE.Matrix4().makeRotationZ(-0.25).setPosition(arm / 2 * Math.cos(0.25), yl - (arm / 2) * Math.sin(0.25), rB * 0.68))\n    k.sphere('handwheel', rB * 0.13, [arm * Math.cos(0.25), yl - arm * Math.sin(0.25), rB * 0.68], undefined, 12)\n  } else {\n    k.cyl(C, rB * 0.42, capH * 0.72, [0, top - capH * 0.44, 0], 'y', 24)\n    k.dome(C, rB * 0.42, [0, top - capH * 0.08, 0], 'y', Math.PI / 2, 0.22, 24)\n  }\n  // seal wire and lead seal on the cap\n  k.torus('stainless', rB * 0.46, 0.0012, [0, top - capH * 0.75, 0], 'y', 4, 28)\n  k.sphere('nut', 0.006, [rB * 0.46, top - capH * 0.75 - 0.01, 0], undefined, 8)\n  tagPlate(k, tag || `${d.inletNps}${d.orifice}${d.outletNps}`, [0, -bodyR * 0.15, bodyR * 1.0 + 0.003], 'z', clamp(bodyR * 0.9, 0.06, 0.16))\n}\n"
    },
    {
      "path": "valves/_body.ts",
      "type": "registry:file",
      "target": "components/deck-twin/valves/_body.ts",
      "content": "// Shared valve bodies: integral RF end flanges, see-through bores, ball / globe / gate castings, bolted bonnets, OS&Y yokes, plates.\nimport * as THREE from 'three'\nimport { useEffect, useMemo, type DependencyList } from 'react'\nimport { Kit, mat4, type Built, type MatKey, type Profile, type V3 } from '../lib/kit'\nimport { flange as weldNeck, liftingLug, tagPlate } from '../lib/parts'\nimport { flange, GASKET_SPIRAL_WOUND, pipeOD, pipeWall, RATINGS, type FlangeDims, type Rating } from '../lib/standards'\n\nexport const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))\n/** typical: casting / operator growth with pressure class */\nexport const classK = (r: Rating) => 1 + 0.06 * Math.max(0, RATINGS.indexOf(r))\n\nconst FRAC: Record<number, string> = { 0.25: '1/4', 0.5: '1/2', 0.75: '3/4', 1.25: '1-1/4', 1.5: '1-1/2', 2.5: '2-1/2', 3.5: '3-1/2' }\n/** typical: ISO 5211 top pad width for a valve of pipe OD D */\nexport const isoPadSize = (D: number) => Math.max(0.07, D * 0.55)\n\nexport const npsLabel = (nps: number) => `${FRAC[nps] ?? nps}\"`\nexport const plateText = (nps: number, rating: Rating, extra = '') => `${npsLabel(nps)} CL${rating}${extra ? ' ' + extra : ''}`\n\n/** Fixed + moving Kit pair built together, disposed on change. */\nexport function useKit2(draw: (k: Kit, mv: Kit) => void, deps: DependencyList): [Built, Built] {\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  const built = useMemo(() => {\n    const k = new Kit()\n    const mv = new Kit()\n    draw(k, mv)\n    return [k.build(), mv.build()] as [Built, Built]\n  }, deps)\n  useEffect(() => () => built.forEach((b) => b.geometry.dispose()), [built])\n  return built\n}\n\nexport interface Dims {\n  D: number\n  f: FlangeDims\n  L: number\n  ri: number\n  /** flange back face, |x| */\n  xf: number\n  seg: number\n}\n\nexport function dims(nps: number, rating: Rating, L: number): Dims {\n  const f = flange(nps, rating)\n  const D = pipeOD(nps)\n  return { D, f, L, ri: D / 2 - pipeWall(nps, '80'), xf: L / 2 - f.raisedFaceHeight - f.thickness, seg: nps >= 6 ? 48 : 36 }\n}\n\n/** Integral RF flange with its raised face at `face`, facing `axis`; hub fillet back to neckR, machined face down to rb, dark bolt holes. */\nexport function integralFlange(k: Kit, f: FlangeDims, face: V3, axis: 'x' | '-x' | 'y' | '-y' | 'z' | '-z', neckR: number, rb: number, mat: MatKey, seg = 32) {\n  k.at(face, axis, () => {\n    const rf = f.raisedFaceHeight\n    const yb = -rf - f.thickness\n    const fil = Math.min(0.012, f.thickness * 0.4)\n    k.lathe(mat, [[neckR, yb - fil], [neckR + fil, yb], [f.od / 2, yb], [f.od / 2, -rf], [f.raisedFace / 2, -rf]], [0, 0, 0], 'y', seg)\n    k.lathe('machined', [[f.raisedFace / 2, -rf], [f.raisedFace / 2, 0], [rb, 0]], [0, 0, 0], 'y', seg)\n    k.around(f.bolts, f.boltCircle / 2, [0, 0, 0], 'y', (p) => {\n      k.disc('pipeBore', f.boltDia * 0.56, [p[0], yb - 0.0006, p[2]], '-y', 10)\n      k.disc('pipeBore', f.boltDia * 0.56, [p[0], -rf + 0.0006, p[2]], 'y', 10)\n    }, Math.PI / f.bolts)\n  })\n}\n\n/** Integral RF end flange with its face at x = s·L/2. */\nexport function endFlange(k: Kit, d: Dims, s: 1 | -1, neckR: number, rb: number, mat: MatKey) {\n  integralFlange(k, d.f, [(s * d.L) / 2, 0, 0], s > 0 ? 'x' : '-x', neckR, rb, mat, d.seg)\n}\n\n/** Geometry transformed by m with every triangle whose centroid lies within r of the X axis removed: opens a through-bore in a casting that crosses the axis. Tessellate finely. */\nexport function punchX(g: THREE.BufferGeometry, r: number, m?: THREE.Matrix4) {\n  const src = g.index ? g.toNonIndexed() : g\n  if (!src.getAttribute('normal')) src.computeVertexNormals()\n  if (m) src.applyMatrix4(m)\n  const p = src.getAttribute('position')\n  const n = src.getAttribute('normal')\n  const P: number[] = []\n  const N: number[] = []\n  for (let i = 0; i < p.count; i += 3) {\n    const cy = (p.getY(i) + p.getY(i + 1) + p.getY(i + 2)) / 3\n    const cz = (p.getZ(i) + p.getZ(i + 1) + p.getZ(i + 2)) / 3\n    if (cy * cy + cz * cz < r * r) continue\n    for (let j = 0; j < 3; j++) {\n      P.push(p.getX(i + j), p.getY(i + j), p.getZ(i + j))\n      N.push(n.getX(i + j), n.getY(i + j), n.getZ(i + j))\n    }\n  }\n  const out = new THREE.BufferGeometry()\n  out.setAttribute('position', new THREE.Float32BufferAttribute(P, 3))\n  out.setAttribute('normal', new THREE.Float32BufferAttribute(N, 3))\n  g.dispose()\n  if (src !== g) src.dispose()\n  return out\n}\n\n/** Dark bore lining along X between x0 and x1. */\nexport function bore(k: Kit, r: number, x0: number, x1: number, seg = 24, mat: MatKey = 'pipeBore') {\n  k.lathe(mat, [[r, Math.max(x0, x1)], [r, Math.min(x0, x1)]], [0, 0, 0], 'x', seg)\n}\n\n/** Pipe-side weld-neck companion flanges, spiral-wound gaskets, through-studs and short pipe stubs around a wafer of length L. */\nexport function companionFlanges(k: Kit, nps: number, rating: Rating, L: number) {\n  const f = flange(nps, rating)\n  const D = pipeOD(nps)\n  const ri = D / 2 - pipeWall(nps, 'STD')\n  const g = GASKET_SPIRAL_WOUND\n  for (const s of [-1, 1]) {\n    const face: V3 = [s * (L / 2 + g), 0, 0]\n    const hub = weldNeck(k, nps, rating, face, s > 0 ? '-x' : 'x')\n    k.lathe('gasket', [[f.raisedFace / 2 + 0.006, -g / 2], [f.raisedFace / 2 + 0.006, g / 2], [ri + 0.004, g / 2], [ri + 0.004, -g / 2]], [s * (L / 2 + g / 2), 0, 0], 'x', 32)\n    k.lathe('stainless', [[ri + 0.004, g / 2], [ri + 0.004, -g / 2]], [s * (L / 2 + g / 2), 0, 0], 'x', 32)\n    const x0 = s * (L / 2 + g + hub)\n    k.pipe('pipePaint', [x0, 0, 0], [x0 + s * Math.max(0.12, D * 0.6), 0, 0], D, pipeWall(nps, 'STD'), nps >= 6 ? 32 : 20)\n    bore(k, ri, s * (L / 2 + g), x0)\n  }\n  const span = L + 2 * g + 2 * (f.thickness + f.raisedFaceHeight)\n  const len = span + f.boltDia * 2.6\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, [-len / 2, 0, 0], 'x', f.boltDia, len, [f.boltDia * 0.65, len - f.boltDia * 0.65])\n}\n\n/** Casting of revolution about X from an outer (r, x) profile, closed down to rb at both ends (bore stays open). */\nexport function revolveX(k: Kit, mat: MatKey, rb: number, outer: Profile, seg = 40) {\n  k.lathe(mat, [[rb, outer[0][1]], ...outer, [rb, outer[outer.length - 1][1]]], [0, 0, 0], 'x', seg)\n}\n\n/** Ellipsoidal barrel r(x) = R·sqrt(1 − (x/a)²), sampled between x0 and x1, floored at rMin. */\nexport function barrel(R: number, a: number, x0: number, x1: number, rMin: number, n = 22): Profile {\n  return Array.from({ length: n + 1 }, (_, i) => {\n    const x = x0 + ((x1 - x0) * i) / n\n    return [Math.max(rMin, R * Math.sqrt(Math.max(0, 1 - (x / a) ** 2))), x] as [number, number]\n  })\n}\n\n/** Ball with a through port of radius rb along `axis`. */\nexport function portedBall(k: Kit, mat: MatKey, R: number, rb: number, axis: 'x' | 'y' = 'x', seg = 36) {\n  const xs = Math.sqrt(Math.max(R * R - rb * rb, 1e-6))\n  const t0 = Math.asin(xs / R)\n  const p: Profile = [[rb, xs], [rb, -xs]]\n  for (let i = 0; i <= 18; i++) {\n    const t = -t0 + (2 * t0 * i) / 18\n    p.push([R * Math.cos(t), R * Math.sin(t)])\n  }\n  k.lathe(mat, p, [0, 0, 0], axis, seg)\n  return xs\n}\n\n/** Oval bolted joint (bonnet / cover) two plates thick t each, joint plane at y, studs with nuts both ends. */\nexport function ovalJoint(k: Kit, mat: MatKey, y: number, rx: number, rz: number, t: number, n: number, bd: number) {\n  k.add(mat, new THREE.CylinderGeometry(1, 1, 2 * t, 40), mat4([0, y, 0], 'y', [rx, 1, rz]))\n  for (let i = 0; i < n; i++) {\n    const a = ((i + 0.5) / n) * Math.PI * 2\n    const x = Math.cos(a) * (rx - bd * 1.6)\n    const z = Math.sin(a) * (rz - bd * 1.6)\n    const y0 = y - t - bd * 0.9\n    const len = 2 * t + bd * 2.2\n    k.cyl('boltXylan', bd / 2, len, [x, y0 + len / 2, z], 'y', 6)\n    k.hex('nut', bd * 1.85, bd * 0.9, [x, y0 + len - bd * 0.45, z])\n    k.hex('nut', bd * 1.85, bd * 0.9, [x, y0 + bd * 0.45, z])\n  }\n}\n\n/** Ring of studs parallel to X through a body joint ring at x (width w), nuts both sides. */\nexport function jointStuds(k: Kit, n: number, R: number, x: number, w: number, bd: number) {\n  const len = w + bd * 2.4\n  k.boltCircle('boltXylan', 'nut', n, R, [x - len / 2, 0, 0], 'x', bd, len, [bd * 0.45, len - bd * 0.45])\n}\n\n/** Raised cast flow arrow on a +Z facing surface, pointing +X. */\nexport function flowArrow(k: Kit, mat: MatKey, c: V3, len: number) {\n  const w = len * 0.13\n  const s = new THREE.Shape()\n  s.moveTo(-len / 2, -w / 2)\n  s.lineTo(len * 0.18, -w / 2)\n  s.lineTo(len * 0.18, -w * 1.5)\n  s.lineTo(len / 2, 0)\n  s.lineTo(len * 0.18, w * 1.5)\n  s.lineTo(len * 0.18, w / 2)\n  s.lineTo(-len / 2, w / 2)\n  s.closePath()\n  k.extrude(mat, s, 0.003, mat4([c[0], c[1], c[2] - 0.001]))\n}\n\n/** Hex pipe plug in a small boss pointing along `dir` from `at`. */\nexport function plug(k: Kit, mat: MatKey, at: V3, dir: 'x' | '-x' | 'y' | '-y' | 'z' | '-z', size: number) {\n  const v = new THREE.Vector3(...({ x: [1, 0, 0], '-x': [-1, 0, 0], y: [0, 1, 0], '-y': [0, -1, 0], z: [0, 0, 1], '-z': [0, 0, -1] } as Record<string, V3>)[dir])\n  const p = new THREE.Vector3(...at)\n  k.cyl(mat, size * 0.75, size * 0.9, p.clone().addScaledVector(v, size * 0.35).toArray() as V3, dir, 14)\n  k.hex('nut', size * 1.15, size * 0.45, p.clone().addScaledVector(v, size * 0.95).toArray() as V3, dir)\n}\n\n// ---------------------------------------------------------------- ball body\n\nexport interface BallBodyOpts {\n  nps: number\n  rating: Rating\n  L: number\n  reduced: boolean\n  trunnion: boolean\n  mat: MatKey\n  text: string\n}\n\n/** Ball valve body; ball and stem go into mv (open = port along X). Returns the operator mounting face height. */\nexport function ballBody(k: Kit, mv: Kit, o: BallBodyOpts) {\n  const d = dims(o.nps, o.rating, o.L)\n  const { D, f, xf, ri } = d\n  const rb = o.reduced ? ri * 0.76 : ri\n  const Rball = rb * 1.7 + 0.004\n  const Rb = Math.min(Rball * 1.13 + Math.max(0.008, D * 0.05), f.od / 2 * (o.trunnion ? 1.05 : 0.95))\n  const rn = Math.max(D / 2 * 1.1, (f.hubDia / 2) * 0.94)\n  const bd = clamp(f.boltDia * 0.85, 0.01, 0.04)\n  const M = o.mat\n  for (const s of [-1, 1] as const) endFlange(k, d, s, rn, rb, M)\n  const stemR = Math.max(0.008, D * 0.075)\n  const rs = Math.max(0.026, D * 0.26)\n  let yTop: number\n  let plateW = 1\n  if (!o.trunnion) {\n    // two-piece end-entry body: main body + bolted end connector on +X\n    const a = Rb * 1.55\n    const xj = Math.min(Rb * 0.5, xf * 0.5)\n    const jt = Math.max(0.012, D * 0.09)\n    revolveX(k, M, rb, [[rn, -xf - 0.004], ...barrel(Rb, a, -Math.min(xf, a), xj - jt / 2, rn)], d.seg)\n    revolveX(k, M, rb, [...barrel(Rb * 0.96, a, xj + jt / 2, Math.min(xf, a), rn), [rn, xf + 0.004]], d.seg)\n    const bs = bd * 0.75\n    const Rsc = Rb * Math.sqrt(1 - (xj / a) ** 2) + bs * 1.3\n    const Rj = Rsc + bs * 1.5\n    revolveX(k, M, rb, [[Rj, xj - jt / 2], [Rj, xj + jt / 2]], d.seg)\n    jointStuds(k, o.nps >= 3 ? 8 : 4, Rsc, xj, jt, bs)\n    yTop = Rb + Math.max(0.022, D * 0.24)\n    k.cyl(M, rs, yTop - Rb * 0.6, [0, (yTop + Rb * 0.6) / 2, 0], 'y', 24)\n    k.cyl(M, rs * 1.25, 0.008, [0, yTop - 0.004, 0], 'y', 24)\n    // packing gland nut and body drain\n    k.hex('nut', rs * 1.1, 0.01, [0, yTop - 0.013, rs * 0.0])\n    plug(k, M, [-Rb * 0.3, -Rb * 0.86, 0], '-y', Math.max(0.01, D * 0.06))\n  } else {\n    // three-piece side-entry trunnion body\n    const xb = Math.min(xf * 0.6, Rb * 1.0)\n    const jt = Math.max(0.016, D * 0.09)\n    const Rj = Rb + bd * 2.6\n    plateW = 2 * xb - jt - 3.2 * bd\n    revolveX(k, M, rb, [[Rb * 0.94, -xb], [Rb, -xb + 0.006], [Rb, xb - 0.006], [Rb * 0.94, xb]], d.seg + 8)\n    for (const s of [-1, 1] as const) {\n      revolveX(k, M, rb, s < 0 ? [[rn, -xf - 0.004], [rn, -xf + (xf - xb) * 0.35], [Rb * 0.86, -xb - jt * 1.5], [Rb * 0.86, -xb]] : [[Rb * 0.86, xb], [Rb * 0.86, xb + jt * 1.5], [rn, xf - (xf - xb) * 0.35], [rn, xf + 0.004]], d.seg)\n      revolveX(k, M, rb, [[Rj, s * xb - jt / 2], [Rj, s * xb + jt / 2]], d.seg + 8)\n      jointStuds(k, o.nps >= 10 ? 20 : o.nps >= 6 ? 16 : 12, Rj - bd * 1.35, s * xb, jt, bd)\n      // seat sealant injection fittings\n      const xs = s * (xb + jt * 1.6)\n      const ys = Rb * 0.84\n      k.cyl(M, Math.max(0.01, D * 0.05), 0.02, [xs, ys + 0.004, 0], 'y', 12)\n      k.hex('brass', Math.max(0.016, D * 0.07), 0.016, [xs, ys + 0.02, 0])\n      k.sphere('brass', Math.max(0.008, D * 0.035), [xs, ys + 0.03, 0], undefined, 10)\n    }\n    yTop = Rb + Math.max(0.03, D * 0.32)\n    k.cyl(M, rs, yTop - Rb * 0.7, [0, (yTop + Rb * 0.7) / 2, 0], 'y', 28)\n    const tf = Math.max(0.014, D * 0.08)\n    k.cyl(M, rs * 1.55, tf, [0, yTop - tf / 2, 0], 'y', 32)\n    k.boltCircle('boltXylan', 'nut', 8, rs * 1.3, [0, yTop - tf - 0.006, 0], 'y', bd * 0.6, tf + 0.012 + bd, [0.004])\n    // emergency stem sealant fitting on the stem housing\n    k.cyl(M, Math.max(0.009, D * 0.04), rs * 0.4, [0, yTop - tf - rs * 0.5, rs * 1.0], 'z', 12)\n    k.hex('brass', Math.max(0.014, D * 0.06), 0.012, [0, yTop - tf - rs * 0.5, rs * 1.25], 'z')\n    k.sphere('brass', Math.max(0.007, D * 0.03), [0, yTop - tf - rs * 0.5, rs * 1.25 + 0.01], undefined, 10)\n    // trunnion cover underneath\n    const yb = -Rb - Math.max(0.02, D * 0.2)\n    k.cyl(M, rs * 0.9, -yb - Rb * 0.7, [0, (yb - Rb * 0.7) / 2, 0], 'y', 24)\n    k.cyl(M, rs * 1.25, tf, [0, yb + tf / 2, 0], 'y', 28)\n    k.boltCircle('boltXylan', 'nut', 6, rs * 1.05, [0, yb - bd * 0.6, 0], 'y', bd * 0.6, tf + bd * 1.2, [bd * 0.3])\n    plug(k, M, [0, yb, 0], '-y', Math.max(0.014, D * 0.08))\n    // body vent on the front, drain under the end adapter\n    plug(k, M, [0, Rb * 0.45, Rb * 0.88], 'z', Math.max(0.014, D * 0.08))\n    plug(k, M, [-(xb + jt * 2), -Rb * 0.8, 0], '-y', Math.max(0.014, D * 0.07))\n    if (o.nps >= 6) for (const s of [-1, 1]) liftingLug(k, [s * xb * 0.62, Rb * 0.96, 0], 'x', clamp(D * 2.4, 0.35, 1.6), M)\n    // support feet down to the flange rim\n    if (o.nps >= 8) {\n      const yf = -f.od / 2\n      const w = Math.max(0.012, D * 0.05)\n      for (const s of [-1, 1]) {\n        k.box(M, [w, -yf - Rb * 0.6, Rb * 1.1], [s * xb * 0.55, (yf - Rb * 0.6) / 2, 0])\n        k.box(M, [Rb * 0.5, w * 1.2, Rb * 1.3], [s * xb * 0.55, yf + w * 0.6, 0])\n      }\n    }\n  }\n  tagPlate(k, o.text, o.trunnion ? [0, -Rb * 0.25, Rb + 0.002] : [-Rb * 0.35, -Rb * 0.2, Rb * 0.9 + 0.003], 'z', o.trunnion ? clamp(Math.min(Rb * 1.1, plateW), 0.04, 0.16) : clamp(Rb * 0.75, 0.06, 0.16))\n  // seats + ball + stem (moving)\n  const xs = portedBall(mv, 'stainless', Rball, rb, 'x', d.seg)\n  bore(k, rb, -o.L / 2, -xs + 0.0005)\n  bore(k, rb, xs - 0.0005, o.L / 2)\n  mv.cyl('stainless', stemR, yTop - Rball * 0.8 + 0.03, [0, (yTop + Rball * 0.8 + 0.03) / 2, 0], 'y', 12)\n  if (o.trunnion) k.cyl('stainless', stemR * 1.4, Rball * 0.4, [0, -Rball * 1.0, 0], 'y', 12)\n  return { yTop, stemR, rs, Rb, D }\n}\n\n// ---------------------------------------------------------------- globe body\n\n/** Globe body (bulb, seat-bridge bulge, bolted bonnet). Returns the bonnet top height and radii. */\nexport function globeBody(k: Kit, nps: number, rating: Rating, L: number, mat: MatKey, control: boolean, text: string, bonnet = true) {\n  const d = dims(nps, rating, L)\n  const { D, f, xf, ri } = d\n  const rn = Math.max(D / 2 * 1.1, (f.hubDia / 2) * 0.94)\n  for (const s of [-1, 1] as const) endFlange(k, d, s, rn, ri, mat)\n  const Rg = D * (control ? 0.92 : 0.82) + 0.016\n  const a = Math.min(Rg * 1.3, xf + Rg * 0.1)\n  revolveX(k, mat, ri, [[rn, -xf - 0.004], ...barrel(Rg, a, -Math.min(xf, a * 0.97), Math.min(xf, a * 0.97), rn, 28), [rn, xf + 0.004]], d.seg + 8)\n  bore(k, ri, -L / 2, -Rg * 0.55)\n  bore(k, ri, Rg * 0.55, L / 2)\n  for (const s of [-1, 1]) k.disc('pipeBore', ri, [s * Rg * 0.55, 0, 0], s > 0 ? 'x' : '-x', 20)\n  // seat bridge bulge underneath, drain boss\n  const yS = -Math.max(Rg * 0.5, ri * 1.08)\n  k.cyl(mat, Rg * 0.7, Rg + yS, [0, (yS - Rg) / 2, 0], 'y', 28)\n  k.sphere(mat, Rg * 0.7, [0, -Rg * 1.0, 0], [1, 0.32, 1], 28)\n  plug(k, mat, [Rg * 0.3, -Rg * 1.15, 0], '-y', Math.max(0.012, D * 0.07))\n  const yTop = Rg * 0.95\n  const yN = Math.max(Rg * 0.5, ri * 1.08)\n  k.cyl(mat, Rg * 0.66, yTop - yN, [0, (yTop + yN) / 2, 0], 'y', 32)\n  const t = Math.max(0.01, D * 0.1)\n  const bd = clamp(f.boltDia * 0.85, 0.01, 0.032)\n  const rj = Rg * 0.72 + bd * 2.4\n  ovalJoint(k, mat, yTop, rj, rj, t, nps >= 6 ? 12 : 8, bd)\n  const stemR = Math.max(0.007, D * 0.075)\n  const hB = control ? Math.max(0.08, D * 0.9 + 0.04) : Math.max(0.05, D * 0.7)\n  const yb0 = yTop + t\n  if (bonnet) k.cyl(mat, Math.max(stemR * 2.4, Rg * 0.3), hB, [0, yb0 + hB / 2, 0], 'y', 28, Math.max(stemR * 2.6, Rg * 0.46))\n  tagPlate(k, text, [0, Rg * 0.22, Rg * 0.98 + 0.004], 'z', clamp(Rg * 0.9, 0.06, 0.16))\n  flowArrow(k, 'stainless', [0, -Rg * 0.45, Rg * 0.9], clamp(Rg * 1.0, 0.05, 0.3))\n  return { yB: bonnet ? yb0 + hB : yb0, stemR, Rg, D, f, rj }\n}\n\n// ---------------------------------------------------------------- gate body\n\n/** Flexible wedge centred at yc (moving with the stem), across the bore, with its stem T-slot stub. */\nexport function gateWedge(mv: Kit, D: number, ri: number, yc: number) {\n  const r = ri * 1.06\n  const t = Math.max(0.012, D * 0.14)\n  mv.add('stainless', new THREE.CylinderGeometry(r, r * 0.97, t, 36, 1), new THREE.Matrix4().makeRotationZ(Math.PI / 2).setPosition(0, yc, 0))\n  mv.box('stainless', [t * 0.7, r * 0.5, t * 1.4], [0, yc + r * 0.95, 0])\n}\n\n/** Wedge-gate body with oval bolted bonnet; bore open right through. Returns the bonnet top. */\nexport function gateBody(k: Kit, nps: number, rating: Rating, L: number, mat: MatKey, text: string) {\n  const d = dims(nps, rating, L)\n  const { D, f, xf, ri } = d\n  const neck = Math.max(D / 2 * 1.12, (f.hubDia / 2) * 0.94)\n  for (const s of [-1, 1] as const) endFlange(k, d, s, neck, ri, mat)\n  revolveX(k, mat, ri, [[neck, -xf - 0.004], [neck, xf + 0.004]], d.seg)\n  const rx = Math.min(D * 0.55 + 0.012, xf)\n  const rz = D * 0.66 + 0.014\n  const yTop = D * 0.85 + 0.02\n  const yBot = -D * 0.5\n  // wedge chamber, opened where the bore passes through (the neck covers the cut edge)\n  const hole = (ri + neck) / 2\n  k.add(mat, punchX(new THREE.CylinderGeometry(1, 1, yTop - yBot, 72, 36), hole, mat4([0, (yTop + yBot) / 2, 0], 'y', [rx, 1, rz])))\n  k.add(mat, punchX(new THREE.SphereGeometry(1, 40, 24), hole, mat4([0, yBot, 0], undefined, [rx, D * 0.25, rz])))\n  for (const s of [-1, 1]) {\n    k.box(mat, [rx * 0.4, yTop - neck, 0.006 + D * 0.03], [s * rx * 0.75, (yTop + neck) / 2, 0])\n    k.box(mat, [rx * 0.4, -neck - yBot, 0.006 + D * 0.03], [s * rx * 0.75, (yBot - neck) / 2, 0])\n  }\n  bore(k, ri, -L / 2, L / 2)\n  plug(k, mat, [0, yBot - D * 0.2, 0], '-y', Math.max(0.012, D * 0.07))\n  const t = Math.max(0.01, D * 0.11)\n  const bd = clamp(f.boltDia * 0.9, 0.01, 0.032)\n  ovalJoint(k, mat, yTop, rx + f.boltDia * 2.4, rz + f.boltDia * 2.4, t, nps >= 8 ? 16 : nps >= 4 ? 12 : 8, bd)\n  const stemR = Math.max(0.008, D * 0.085)\n  const hB = Math.max(0.06, D * 1.2 + 0.03)\n  const yb0 = yTop + t\n  k.add(mat, new THREE.CylinderGeometry(1, 1, hB, 36), mat4([0, yb0 + hB / 2, 0], 'y', [Math.max(stemR * 2.2, rx * 0.7), 1, Math.max(stemR * 2.2, rz * 0.75)]))\n  tagPlate(k, text, [0, yTop * 0.62, rz + 0.003], 'z', clamp(rx * 1.2, 0.06, 0.16))\n  return { yB: yb0 + hB, stemR, D, rx, rz, ri }\n}\n\n// ---------------------------------------------------------------- OS&Y yoke\n\n/** Packing gland, gland studs, two cast legs in the XY plane and the yoke head with its nut boss. Returns the head top. */\nexport function osyYoke(k: Kit, mat: MatKey, yB: number, D: number, stemR: number, H: number) {\n  const legX = stemR * 2.4 + Math.max(0.02, D * 0.22)\n  const legW = Math.max(0.014, D * 0.16)\n  const legT = Math.max(0.01, D * 0.09)\n  k.cyl(mat, stemR * 2.6, Math.max(0.012, D * 0.1), [0, yB + Math.max(0.006, D * 0.05), 0], 'y', 20)\n  k.box(mat, [stemR * 7, Math.max(0.008, D * 0.05), stemR * 3], [0, yB + Math.max(0.016, D * 0.13), 0])\n  for (const s of [-1, 1]) k.rod('boltXylan', [s * stemR * 2.8, yB, 0], [s * stemR * 2.8, yB + Math.max(0.03, D * 0.25), 0], Math.max(0.003, stemR * 0.35), 6)\n  for (const s of [-1, 1]) {\n    k.box(mat, [legW, H, legT], [s * legX, yB + H / 2, 0])\n    k.box(mat, [legX - stemR * 1.5, legT * 1.2, legT * 1.4], [s * (legX / 2 + stemR * 0.75), yB + legT * 0.6, 0])\n  }\n  const yT = yB + H\n  k.box(mat, [legX * 2 + legW, legT * 1.6, legT * 2.2], [0, yT - legT * 0.8, 0])\n  // yoke sleeve (stem nut) boss with its grease nipple\n  k.cyl(mat, stemR * 2.5, legT * 3, [0, yT, 0], 'y', 20)\n  k.cyl('brass', Math.max(0.003, stemR * 0.3), legT * 1.2, [stemR * 2.5 + legT * 0.5, yT, 0], 'x', 8)\n  k.sphere('brass', Math.max(0.004, stemR * 0.4), [stemR * 2.5 + legT * 1.15, yT, 0], undefined, 8)\n  return { top: yT + legT * 1.5, legX, legW, legT }\n}\n\nexport { pipeOD }\n"
    }
  ],
  "categories": [
    "valves"
  ],
  "meta": {
    "export": "PressureSafetyValve",
    "anchor": "fitting",
    "standards": [
      "API 526",
      "API 520",
      "ASME VIII UG-125",
      "ASME B16.5"
    ]
  }
}