{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "control-valve",
  "type": "registry:component",
  "title": "Control valve (globe)",
  "description": "Flanged globe control valve with a multi-spring diaphragm actuator on a two-leg cast yoke. Includes a digital valve positioner with gauges, an air filter-regulator with gauge and bowl, stainless tubing to the diaphragm casing, a stem connector with travel pointer and scale. `open` lifts the stem through the rated travel. Face-to-face per ISA 75.08.01.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "valves/control-valve.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/valves/control-valve.tsx",
      "content": "// Globe control valve: cage-guided globe body, spring-diaphragm actuator, digital positioner, filter-regulator, tubing, travel indicator. IEC 60534 / ISA 75.08.01.\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport type { Kit, MatKey } from '../lib/kit'\nimport { port, type Port } from '../lib/ports'\nimport { faceToFace, handwheelDia, type Rating } from '../lib/standards'\nimport { clamp, globeBody, plateText, useKit2 } from './_body'\nimport { airInlet, diaphragmActuator, filterRegulator, positioner, smallJB } from './_operators'\n\nexport interface ControlValveProps {\n  /** nominal pipe size, inches */\n  nps?: number\n  rating?: Rating\n  /** plug travel, 0 closed … 1 open */\n  open?: number\n  body?: MatKey\n  actuator?: MatKey\n  tag?: string\n}\n\nexport const defaults = { nps: 4, rating: 300 as Rating, open: 0.6, body: 'valveBody' as MatKey, actuator: 'actuatorBlue' as MatKey, tag: '' }\n\n/** typical: rated travel for a cage-guided globe valve of this size, m */\nexport const travel = (nps: number) => clamp(0.0095 * nps + 0.01, 0.019, 0.102)\n\nexport function ports(p: ControlValveProps = {}): Port[] {\n  const { nps, rating } = { ...defaults, ...p }\n  const L = faceToFace('control-globe', 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 ControlValve(props: ControlValveProps & GroupProps) {\n  const { nps, rating, open, body, actuator, tag, ...group } = { ...defaults, ...props }\n  const [fixed, moving] = useKit2((k, mv) => build(k, mv, nps, rating, body, actuator, tag), [nps, rating, body, actuator, tag])\n  return (\n    <group {...group}>\n      <KitMesh data={fixed} />\n      <KitMesh data={moving} position-y={clamp(open, 0, 1) * travel(nps)} />\n    </group>\n  )\n}\n\nexport default ControlValve\n\nfunction build(k: Kit, mv: Kit, nps: number, rating: Rating, body: MatKey, A: MatKey, tag: string) {\n  const L = faceToFace('control-globe', nps, rating)\n  const g = globeBody(k, nps, rating, L, body, true, plateText(nps, rating))\n  const { yB, stemR, D } = g\n  const T = travel(nps)\n  const Da = handwheelDia('control-globe', nps, rating)\n  const H = clamp(0.15 + D * 0.6, 0.22, 0.42)\n  // packing flange + studs, yoke lock nut\n  k.cyl(body, stemR * 3.4, 0.012, [0, yB + 0.006, 0], 'y', 20)\n  for (const s of [-1, 1]) k.rod('boltXylan', [s * stemR * 2.8, yB, 0], [s * stemR * 2.8, yB + 0.035, 0], Math.max(0.0035, stemR * 0.4), 6)\n  const rY = Math.max(stemR * 4.2, D * 0.36, 0.045)\n  k.hex('nut', rY * 2.3, H * 0.08, [0, yB + 0.012 + H * 0.04, 0])\n  // cast yoke: base ring, two legs at ±Z (window faces ±X), top boss\n  const legZ = rY * 1.35\n  const legW = Math.max(0.03, rY * 0.55)\n  const legT = Math.max(0.014, rY * 0.24)\n  const y1 = yB + 0.012 + H * 0.08\n  k.cyl(A, rY * 1.1, H * 0.1, [0, y1 + H * 0.05, 0], 'y', 28)\n  k.box(A, [legW, H * 0.1, legZ * 2 + legT], [0, y1 + H * 0.05, 0])\n  for (const s of [-1, 1]) k.box(A, [legW, H * 0.82, legT], [0, y1 + H * 0.1 + H * 0.41, s * legZ])\n  const y0 = y1 + H\n  k.cyl(A, rY * 1.15, H * 0.12, [0, y0 - H * 0.06, 0], 'y', 28)\n  k.box(A, [legW, H * 0.12, legZ * 2 + legT], [0, y0 - H * 0.06, 0])\n  const act = diaphragmActuator(k, y0, { Da, A, text: tag || 'AIR TO OPEN' })\n  // stem, stem connector, travel pointer and magnet array (moving, drawn closed)\n  const yc = y1 + H * 0.35\n  mv.cyl('stainless', stemR, yc - yB + 0.04, [0, (yc + yB - 0.04) / 2, 0], 'y', 12)\n  mv.cyl('stainless', stemR * 1.3, y0 - yc + 0.02, [0, (y0 + yc) / 2, 0], 'y', 12)\n  mv.rbox('stainless', [stemR * 4.5, Math.max(0.03, H * 0.14), stemR * 3.2], 0.003, [0, yc, 0])\n  for (const s of [-1, 1]) mv.cyl('nut', stemR * 0.45, stemR * 3.6, [s * stemR * 1.4, yc, 0], 'z', 6)\n  mv.box('safetyYellow', [0.004, 0.004, legZ - legT / 2 - stemR], [legW / 2 + 0.004, yc, -(legZ - legT / 2 + stemR) / 2])\n  mv.box('safetyYellow', [legW / 2 + 0.004, 0.004, 0.004], [legW / 4 + 0.002, yc, -stemR * 1.6])\n  mv.box('nut', [stemR * 2.4, H * 0.12, 0.01], [0, yc, stemR * 1.6 + 0.005])\n  // travel scale on the −Z leg\n  k.box('stainless', [0.002, T * 1.4, legT * 0.9], [legW / 2 + 0.001, yc + T / 2, -legZ])\n  for (let i = 0; i <= 4; i++) k.box('nut', [0.002, 0.0015, legT * (i % 2 ? 0.4 : 0.7)], [legW / 2 + 0.0025, yc + (T * i) / 4, -legZ + legT * 0.1])\n  // positioner on the +Z leg, filter-regulator below-left, tubing, cable\n  const zBack = legZ + legT / 2\n  const py = y1 + H * 0.55\n  k.box(body, [legW * 1.3, 0.05, 0.03], [0, py, zBack + 0.015])\n  const pos = positioner(k, [0, py, zBack + 0.03])\n  const zc = pos.zc\n  const ax = -pos.w / 2 - 0.07\n  const ay = py - pos.h * 0.55\n  k.box(body, [0.03, 0.12, 0.012], [ax, ay + 0.07, zBack + 0.006])\n  k.box(body, [0.03, 0.03, zc - zBack], [ax, ay + 0.12, (zc + zBack) / 2])\n  filterRegulator(k, [ax, ay, zc], 0.18)\n  const tr = 0.0045\n  k.tube('tubing', [[ax, ay + 0.1, zc], [ax, py + 0.02, zc], [-pos.w / 2, py + 0.02, zc]], tr)\n  const xo = pos.w * 0.3\n  const zf = act.R * 0.8\n  k.cyl('brass', 0.008, 0.03, [xo, act.yLower, zf], 'z', 8)\n  k.tube('tubing', [[xo, py + pos.h / 2, zc], [xo, act.yLower - 0.04, zc], [xo, act.yLower - 0.04, zf + 0.04], [xo, act.yLower, zf + 0.04], [xo, act.yLower, zf + 0.012]], tr)\n  k.tube('tubing', [[ax + 0.03, ay + 0.04, zc], [ax + 0.06, ay + 0.04, zc], [ax + 0.06, ay - 0.06, zc]], tr)\n  airInlet(k, [ax + 0.06, ay - 0.06, zc])\n  // positioner cable to a junction box on the back yoke leg\n  const jz = -legZ - legT / 2 - 0.035\n  k.box(body, [0.05, 0.03, 0.012], [0, py, -legZ - legT / 2 - 0.006])\n  smallJB(k, [0, py, jz], 0.11, 0.12, 0.06, 2)\n  k.spline('cable', [[pos.w / 2 + 0.03, py - pos.h * 0.25, zc], [pos.w / 2 + 0.08, py - pos.h * 0.45, zc - 0.02], [legW + 0.06, py - 0.12, 0], [0.022, py - 0.12, jz], [0.022, py - 0.072, jz]], 0.006, 6, 40)\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"
    },
    {
      "path": "valves/_operators.ts",
      "type": "registry:file",
      "target": "components/deck-twin/valves/_operators.ts",
      "content": "// Shared valve operators: wrench lever, notch-plate lever, quarter-turn worm gear, scotch-yoke spring-return, spring-diaphragm + positioner, multi-turn electric.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { gauge, tagPlate } from '../lib/parts'\nimport { lcdMaterial, textMaterial } from '../lib/textures'\n\n/** Instrument-air supply termination at p (tube arrives from +Y): isolation ball valve with lever and a bulkhead union below. */\nexport function airInlet(k: Kit, p: V3, s = 1) {\n  const [x, y, z] = p\n  k.hex('brass', 0.016 * s, 0.012 * s, [x, y - 0.006 * s, z])\n  k.box('stainless', [0.024 * s, 0.03 * s, 0.024 * s], [x, y - 0.027 * s, z])\n  k.box('actuatorRed', [0.05 * s, 0.004 * s, 0.01 * s], [x + 0.025 * s, y - 0.02 * s, z])\n  k.hex('brass', 0.016 * s, 0.012 * s, [x, y - 0.048 * s, z])\n  k.cyl('stainless', 0.0045 * s, 0.03 * s, [x, y - 0.069 * s, z], 'y', 8)\n  k.hex('brass', 0.02 * s, 0.008 * s, [x, y - 0.088 * s, z])\n}\n\n/** Small stainless field junction box, back at `back`, door facing +normal (z), glands underneath. */\nexport function smallJB(k: Kit, c: V3, w = 0.12, h = 0.14, d = 0.07, glands = 2) {\n  k.rbox('stainless', [w, h, d], 0.008, c)\n  k.box('stainless', [w * 0.92, h * 0.92, 0.004], [c[0], c[1], c[2] + d / 2 + 0.002])\n  for (let i = 0; i < glands; i++) k.cyl('brass', 0.011, 0.03, [c[0] + (i - (glands - 1) / 2) * w * 0.4, c[1] - h / 2 - 0.012, c[2]], 'y', 10)\n}\n\n/** ISO 5211 top pad + travel-stop plate at y. Returns the pad top. */\nexport function isoPad(k: Kit, mat: MatKey, y: number, pad: number) {\n  const t = Math.max(0.01, pad * 0.12)\n  k.box(mat, [pad, t, pad], [0, y + t / 2, 0])\n  k.around(4, pad * 0.36, [0, y + t, 0], 'y', (p) => k.hex('nut', pad * 0.11, pad * 0.06, [p[0], p[1] + pad * 0.03, p[2]]), Math.PI / 4)\n  return y + t\n}\n\n/** Wrench lever on a square stem head at y, handle along +X (open). Stops and lock tab fixed; head, bar and grip in mv. */\nexport function wrenchLever(k: Kit, mv: Kit, y: number, stemR: number, Lh: number, grip: MatKey = 'actuatorRed') {\n  const w = Math.max(0.02, Lh * 0.075)\n  const t = Math.max(0.006, Lh * 0.022)\n  // stop plate with open / closed stops and padlock tab\n  k.box('valveBody', [stemR * 7, 0.004, stemR * 7], [0, y + 0.002, 0])\n  k.box('valveBody', [stemR * 1.4, 0.014, stemR * 1.4], [stemR * 3, y + 0.009, -stemR * 2.4])\n  k.box('valveBody', [stemR * 1.4, 0.014, stemR * 1.4], [-stemR * 2.4, y + 0.009, -stemR * 3])\n  // flat padlock ears on the stop plate at the open (+X) and closed (+Z) positions; the lever ear lies on top\n  for (const [ex, ez] of [[1, 0], [0, 1]]) {\n    k.box('valveBody', [ex ? stemR * 2.6 : stemR * 1.8, 0.004, ez ? stemR * 2.6 : stemR * 1.8], [ex * stemR * 4.6, y + 0.002, ez * stemR * 4.6])\n    k.disc('pipeBore', stemR * 0.4, [ex * stemR * 5.2, y + 0.0042, ez * stemR * 5.2], 'y', 10)\n  }\n  const h = Math.max(0.018, stemR * 2.4)\n  mv.box('handwheel', [stemR * 4.4, h, stemR * 4.4], [0, y + 0.004 + h / 2, 0])\n  mv.hex('nut', stemR * 2.6, stemR * 1.1, [0, y + 0.004 + h + stemR * 0.55, 0])\n  mv.box('handwheel', [stemR * 2, t, w], [stemR * 3.2, y + 0.004 + h - t / 2, 0])\n  const yb = y + 0.004 + h - t / 2 + t * 0.6\n  const x0 = stemR * 3.8\n  mv.add('handwheel', new THREE.BoxGeometry(Lh - x0, t, w), new THREE.Matrix4().makeRotationZ(0.06).setPosition((Lh + x0) / 2, yb + (Lh - x0) * 0.03, 0))\n  const gl = Lh * 0.38\n  mv.rbox(grip, [gl, t * 2.6, w * 1.35], t * 1.1, [Lh - gl / 2 - 0.004, yb + (Lh - x0) * 0.055, 0])\n  mv.box('handwheel', [stemR * 2.4, 0.003, stemR * 1.6], [stemR * 4.9, y + 0.0058, 0])\n  mv.box('handwheel', [0.003, h - t - 0.002, stemR * 1.6], [stemR * 3.8, y + 0.004 + (h - t) / 2, 0])\n  mv.disc('pipeBore', stemR * 0.4, [stemR * 5.2, y + 0.0075, 0], 'y', 10)\n}\n\n/** Butterfly lever with squeeze trigger over a 10-position notch quadrant (fixed); handle along +X when open. */\nexport function notchLever(k: Kit, mv: Kit, y: number, stemR: number, Lh: number) {\n  const R = Lh * 0.32\n  // notch quadrant from +X (open) to +Z (closed)\n  k.lathe('valveBody', [[stemR * 2, 0], [R, 0], [R, 0.004], [stemR * 2, 0.004]], [0, y, 0], 'y', 20, 0, Math.PI / 2)\n  for (let i = 0; i <= 9; i++) {\n    const a = (i / 9) * (Math.PI / 2)\n    k.box('nut', [R * 0.12, 0.007, 0.004], [Math.cos(a) * R * 0.9, y + 0.006, Math.sin(a) * R * 0.9], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a))\n  }\n  const w = Math.max(0.02, Lh * 0.08)\n  const t = Math.max(0.007, Lh * 0.03)\n  const yl = y + 0.03\n  mv.cyl('handwheel', stemR * 2.4, 0.03, [0, y + 0.018, 0], 'y', 16)\n  mv.box('handwheel', [Lh, t, w], [Lh / 2, yl, 0])\n  mv.box('handwheel', [Lh * 0.5, t * 0.8, w * 0.6], [Lh * 0.62, yl - t * 1.4, 0])\n  mv.rbox('actuatorRed', [Lh * 0.34, t * 2.6, w * 1.3], t, [Lh * 0.8, yl + t * 0.2, 0])\n  mv.box('stainless', [t * 0.8, yl - y, t * 0.8], [R * 0.9, (yl + y) / 2, 0])\n  mv.hex('nut', stemR * 2.4, stemR, [0, yl + t, 0])\n}\n\n/** Quarter-turn worm gearbox on the stem at y (handwheel on +Z). Position pointer in mv. Returns the top. */\nexport function wormGear(k: Kit, mv: Kit, y: number, g: number, hwD: number, mat: MatKey = 'valveBody', reach = 0) {\n  const ys = y + g * 0.3\n  const zw = Math.max(g * 1.1, reach)\n  k.cyl(mat, g * 0.24, g * 0.3, [0, y + g * 0.15, 0], 'y', 20)\n  k.cyl(mat, g * 0.3, 0.012, [0, y + 0.006, 0], 'y', 24)\n  const yc = ys + g * 0.3\n  k.rbox(mat, [g * 1.05, g * 0.6, g * 0.9], g * 0.12, [0, yc, -g * 0.05])\n  k.cyl(mat, g * 0.48, g * 0.62, [0, yc, -g * 0.05], 'y', 32)\n  const zh = zw - g * 0.25\n  k.cyl(mat, g * 0.17, zh + g * 0.25, [g * 0.3, yc - g * 0.04, (zh - g * 0.25) / 2], 'z', 20)\n  k.cyl(mat, g * 0.2, g * 0.04, [g * 0.3, yc - g * 0.04, zh], 'z', 20)\n  k.cyl('stainless', Math.max(0.008, g * 0.05), zw - zh + 0.02, [g * 0.3, yc - g * 0.04, (zw + zh) / 2], 'z', 10)\n  k.handwheel('handwheel', hwD, [g * 0.3, yc - g * 0.04, zw], 'z', hwD > 0.35 ? 5 : 4)\n  const yt = yc + g * 0.31\n  k.cyl(mat, g * 0.4, 0.008, [0, yt + 0.004, -g * 0.05], 'y', 28)\n  k.around(8, g * 0.42, [0, yt, -g * 0.05], 'y', (p) => k.hex('nut', g * 0.06, g * 0.03, [p[0], p[1] + g * 0.015, p[2]]))\n  k.box('valveBody', [g * 0.5, 0.004, 0.012], [0, yt + 0.01, -g * 0.05 - g * 0.12])\n  k.box('safetyYellow', [g * 0.08, 0.004, g * 0.08], [g * 0.25, yt + 0.01, -g * 0.05])\n  k.dome('glass', g * 0.26, [0, yt + 0.008, -g * 0.05], 'y', Math.PI / 2, 0.45, 28)\n  mv.box('safetyYellow', [g * 0.42, 0.012, g * 0.06], [0, yt + 0.02, 0])\n  mv.box('safetyYellow', [g * 0.08, 0.012, g * 0.12], [g * 0.2, yt + 0.02, 0])\n  return yt + g * 0.12\n}\n\nexport interface ScotchYokeOpts {\n  /** housing size, m */\n  s: number\n  /** paint */\n  A: MatKey\n  text: string\n  /** lowest point of the valve, cables and supply tubing run down past it */\n  yLow: number\n}\n\n/** Spring-return scotch-yoke actuator on the stem at y0, axis along X (cylinder −X, spring can +X), with LSB, solenoid, QEV, AFR, jackscrew. */\nexport function scotchYoke(k: Kit, mv: Kit, y0: number, o: ScotchYokeOpts) {\n  const { s, A } = o\n  const yA = y0 + s * 0.62\n  // mounting bracket + coupling spool\n  k.cyl('valveBody', s * 0.22, yA - y0 - s * 0.36, [0, (y0 + yA - s * 0.36) / 2, 0], 'y', 20)\n  k.cyl('valveBody', s * 0.3, s * 0.03, [0, y0 + s * 0.015, 0], 'y', 24)\n  k.cyl('valveBody', s * 0.3, s * 0.03, [0, yA - s * 0.37, 0], 'y', 24)\n  // yoke housing\n  k.rbox(A, [s * 1.0, s * 0.74, s * 0.78], s * 0.1, [0, yA, 0])\n  k.cyl(A, s * 0.42, s * 0.82, [0, yA, 0], 'z', 32)\n  k.cyl(A, s * 0.3, s * 0.08, [0, yA + s * 0.39, 0], 'y', 24)\n  k.around(6, s * 0.36, [0, yA, s * 0.41], 'z', (p) => k.hex('nut', s * 0.05, s * 0.02, p, 'z'))\n  // pneumatic cylinder (−X): end plates, tie rods\n  const cr = s * 0.34\n  const cl = s * 1.25\n  const xc0 = -s * 0.5\n  const xc1 = xc0 - cl\n  k.cyl(A, cr, cl, [(xc0 + xc1) / 2, yA, 0], 'x', 36)\n  for (const x of [xc0, xc1]) k.rbox(A, [s * 0.09, cr * 2.3, cr * 2.3], s * 0.02, [x, yA, 0])\n  k.around(4, cr * 1.13, [xc0, yA, 0], '-x', (p) => {\n    k.rod('stainless', p, [p[0] - cl - s * 0.1, p[1], p[2]], Math.max(0.005, s * 0.022), 8)\n    k.hex('nut', s * 0.07, s * 0.035, [p[0] - cl - s * 0.11, p[1], p[2]], 'x')\n  }, Math.PI / 4)\n  // jackscrew manual override on the cylinder end\n  const xj = xc1 - s * 0.1\n  k.cyl(A, s * 0.07, s * 0.2, [xj - s * 0.1, yA, 0], 'x', 16)\n  k.cyl('stainless', s * 0.025, s * 0.25, [xj - s * 0.3, yA, 0], 'x', 10)\n  k.handwheel('handwheel', s * 0.75, [xj - s * 0.42, yA, 0], '-x', 5)\n  // spring cartridge (+X) with dished end, lifting lug, stroke stop\n  const sr = s * 0.46\n  const sl = s * 1.8\n  const xs0 = s * 0.56\n  k.cyl(A, sr * 0.82, s * 0.12, [xs0, yA, 0], 'x', 32)\n  k.cyl(A, sr * 1.06, s * 0.05, [xs0 + s * 0.08, yA, 0], 'x', 36)\n  k.cyl(A, sr, sl, [xs0 + s * 0.1 + sl / 2, yA, 0], 'x', 40)\n  k.around(12, sr * 0.95, [xs0 + s * 0.08, yA, 0], 'x', (p) => k.hex('nut', s * 0.045, s * 0.03, [p[0] - s * 0.04, p[1], p[2]], 'x'))\n  const xe = xs0 + s * 0.1 + sl\n  k.lathe(A, [[sr, 0], [sr * 1.05, 0], [sr * 1.05, s * 0.05], [sr * 0.7, s * 0.13], [s * 0.08, s * 0.16], [0, s * 0.16]], [xe, yA, 0], 'x', 40)\n  k.cyl('nut', s * 0.045, s * 0.14, [xe + s * 0.2, yA, 0], 'x', 6)\n  k.hex('nut', s * 0.1, s * 0.05, [xe + s * 0.14, yA, 0], 'x')\n  k.box(A, [s * 0.12, s * 0.1, s * 0.03], [xs0 + s * 0.1 + sl * 0.5, yA + sr + s * 0.04, 0])\n  k.torus(A, s * 0.035, s * 0.012, [xs0 + s * 0.1 + sl * 0.5, yA + sr + s * 0.1, 0], 'z', 6, 14)\n  tagPlate(k, o.text, [xs0 + s * 0.1 + sl * 0.5, yA, sr + 0.002], 'z', Math.min(0.16, s * 0.5))\n  // limit switch box with domed indicator (indicator turns with the valve)\n  const ly = yA + s * 0.43\n  k.box('valveBody', [s * 0.2, s * 0.08, s * 0.2], [0, ly + s * 0.04, 0])\n  k.rbox('stainless', [s * 0.46, s * 0.26, s * 0.36], s * 0.04, [0, ly + s * 0.08 + s * 0.13, 0])\n  k.cyl('stainless', s * 0.14, s * 0.03, [0, ly + s * 0.355, 0], 'y', 24)\n  k.dome('glass', s * 0.13, [0, ly + s * 0.37, 0], 'y', Math.PI / 2, 0.75, 28)\n  mv.box('safetyGreen', [s * 0.22, s * 0.05, s * 0.05], [0, ly + s * 0.4, 0])\n  mv.box('actuatorRed', [s * 0.05, s * 0.05, s * 0.2], [0, ly + s * 0.395, 0])\n  k.cyl('brass', s * 0.03, s * 0.07, [s * 0.26, ly + s * 0.16, 0], 'x', 8)\n  const jz = -s * 0.39 - 0.035\n  k.box('valveBody', [0.06, 0.03, 0.02], [s * 0.15, yA, -s * 0.39 - 0.01])\n  smallJB(k, [s * 0.15, yA, jz], 0.12, 0.14, 0.07, 2)\n  k.spline('cable', [[s * 0.3, ly + s * 0.16, 0], [s * 0.42, ly + s * 0.12, -s * 0.1], [s * 0.32, yA - 0.07 - 0.07, jz - 0.02], [s * 0.15 + 0.024, yA - 0.07 - 0.024, jz]], 0.007, 6, 36)\n  // solenoid valve, quick exhaust, filter regulator with gauge, tubing (front, +Z)\n  const z = cr + s * 0.1\n  const sx = xc0 - cl * 0.55\n  const sy = yA - cr * 0.3\n  k.box('valveBody', [s * 0.07, s * 0.07, z - cr * 0.6], [sx, sy, (z + cr * 0.6) / 2 - s * 0.04])\n  k.box('stainless', [s * 0.16, s * 0.1, s * 0.1], [sx, sy, z])\n  k.cyl('handwheel', s * 0.065, s * 0.16, [sx, sy + s * 0.13, z], 'y', 20)\n  k.cyl('handwheel', s * 0.07, s * 0.02, [sx, sy + s * 0.22, z], 'y', 20)\n  k.cyl('actuatorRed', s * 0.02, s * 0.04, [sx - s * 0.1, sy, z], 'x', 10)\n  k.cyl('brass', s * 0.025, s * 0.07, [sx, sy + s * 0.26, z], 'y', 8)\n  k.spline('cable', [[sx, sy + s * 0.3, z], [sx + s * 0.02, sy + s * 0.42, z + 0.02], [s * 0.12, ly + s * 0.12, s * 0.3], [s * 0.12, ly + s * 0.17, s * 0.2]], 0.005, 6, 30)\n  k.cyl('brass', s * 0.025, s * 0.04, [s * 0.12, ly + s * 0.17, s * 0.18 + s * 0.02], 'z', 8)\n  const qx = xc0 - cl * 0.12\n  k.box('stainless', [s * 0.1, s * 0.1, s * 0.08], [qx, sy, z])\n  k.cyl('brass', s * 0.035, s * 0.05, [qx, sy - s * 0.07, z], 'y', 12)\n  const tr = Math.max(0.004, s * 0.016)\n  k.tube('tubing', [[sx + s * 0.08, sy, z], [qx - s * 0.05, sy, z]], tr)\n  k.tube('tubing', [[qx, sy + s * 0.05, z], [qx, yA + cr * 0.75, z], [qx, yA + cr * 0.75, cr * 0.5], [qx, yA + cr * 0.55, cr * 0.5]], tr)\n  const ax = sx - s * 0.3\n  const ay = y0 - s * 0.05\n  k.tube('tubing', [[sx, sy - s * 0.05, z], [sx, ay + s * 0.12, z], [ax, ay + s * 0.12, z]], tr)\n  k.box('valveBody', [s * 0.03, ay + s * 0.12 - (yA - cr), s * 0.06], [ax, (ay + s * 0.12 + yA - cr) / 2, z - s * 0.06])\n  filterRegulator(k, [ax, ay, z], s * 0.75)\n  k.tube('tubing', [[ax + s * 0.12, ay + s * 0.09, z], [ax + s * 0.2, ay + s * 0.09, z], [ax + s * 0.2, ay - s * 0.15, z]], tr)\n  airInlet(k, [ax + s * 0.2, ay - s * 0.15, z])\n}\n\n/** Air filter-regulator with bowl, drain, adjusting knob and outlet gauge; body centre at p, front +Z. */\nexport function filterRegulator(k: Kit, p: V3, s = 0.2) {\n  const [x, y, z] = p\n  k.rbox('aluminium', [s * 0.32, s * 0.3, s * 0.3], s * 0.04, [x, y + s * 0.12, z])\n  k.cyl('aluminium', s * 0.14, s * 0.2, [x, y + s * 0.37, z], 'y', 16)\n  k.cyl('handwheel', s * 0.1, s * 0.08, [x, y + s * 0.5, z], 'y', 16)\n  k.cyl('aluminium', s * 0.15, s * 0.04, [x, y - s * 0.05, z], 'y', 18)\n  k.cyl('glass', s * 0.12, s * 0.3, [x, y - s * 0.22, z], 'y', 18)\n  k.cyl('brass', s * 0.025, s * 0.08, [x, y - s * 0.41, z], 'y', 8)\n  gauge(k, [x, y + s * 0.15, z + s * 0.21], 'z', s * 0.26, 'bar', 10)\n  k.cyl('brass', s * 0.03, s * 0.08, [x, y + s * 0.15, z + s * 0.17], 'z', 8)\n}\n\nexport interface DiaphragmOpts {\n  /** casing diameter, m */\n  Da: number\n  A: MatKey\n  text: string\n}\n\n/** Multi-spring diaphragm actuator from its bottom boss at y0 upward. Returns the lower casing mid-height and top. */\nexport function diaphragmActuator(k: Kit, y0: number, { Da, A, text }: DiaphragmOpts) {\n  const R = Da / 2\n  const hL = Da * 0.2\n  const hU = Da * 0.26\n  const rim = Math.max(0.012, Da * 0.028)\n  const boss = Math.max(0.035, Da * 0.13)\n  k.cyl(A, boss, Da * 0.09, [0, y0 + Da * 0.045, 0], 'y', 24)\n  const yRim = y0 + Da * 0.09 + hL\n  k.lathe(A, [\n    [boss, y0 + Da * 0.09],\n    [R * 0.55, y0 + Da * 0.09 + hL * 0.22],\n    [R * 0.88, yRim - hL * 0.4],\n    [R * 0.98, yRim - rim],\n    [R + rim * 1.6, yRim - rim],\n    [R + rim * 1.6, yRim],\n  ], [0, 0, 0], 'y', 56)\n  k.lathe(A, [\n    [R + rim * 1.6, yRim],\n    [R + rim * 1.6, yRim + rim],\n    [R * 0.98, yRim + rim],\n    [R * 0.93, yRim + hU * 0.5],\n    [R * 0.66, yRim + hU * 0.92],\n    [R * 0.2, yRim + hU],\n    [0, yRim + hU],\n  ], [0, 0, 0], 'y', 56)\n  const nb = Math.round(16 + Da * 24)\n  const bd = Math.max(0.007, rim * 0.6)\n  k.boltCircle('boltXylan', 'nut', nb, R + rim * 0.8, [0, yRim - rim - bd * 1.2, 0], 'y', bd, 2 * rim + bd * 2.6, [bd * 0.5, 2 * rim + bd * 2.1])\n  const yTop = yRim + hU\n  k.torus('stainless', Da * 0.05, Da * 0.009, [0, yTop + Da * 0.06, 0], 'z', 6, 20)\n  k.cyl('stainless', Da * 0.025, Da * 0.03, [0, yTop + 0.006, 0], 'y', 12)\n  k.cyl('nut', Da * 0.012, Da * 0.03, [R * 0.5, yTop - hU * 0.12, 0], 'y', 8)\n  k.cyl('brass', Da * 0.012, Da * 0.04, [-R * 0.55, yRim - hL * 0.4, 0], 'x', 8)\n  tagPlate(k, text, [0, yRim + rim + (hU * 0.5 - rim) * 0.5, R * 0.958 + 0.0015], [0, 0.11, 1], Math.min(0.12, (hU * 0.5 - rim) * 2.6))\n  return { yLower: yRim - hL * 0.5, yRim, yTop, R }\n}\n\n/** Digital valve positioner on a bracket at p (back face), front +Z, with gauges, cable gland, feedback arm. */\nexport function positioner(k: Kit, p: V3, s = 1) {\n  const [x, y, z] = p\n  const w = 0.17 * s\n  const h = 0.2 * s\n  const d = 0.11 * s\n  const zc = z + d / 2\n  k.rbox('actuatorBlue', [w, h, d], 0.018 * s, [x, y, zc])\n  k.rbox('handwheel', [w * 0.92, h * 0.88, 0.02 * s], 0.008 * s, [x, y, zc + d / 2 + 0.008 * s])\n  k.plane(lcdMaterial(), w * 0.42, h * 0.22, [x, y + h * 0.18, zc + d / 2 + 0.0185 * s])\n  for (const s2 of [-1, 1]) gauge(k, [x + s2 * w * 0.24, y - h / 2 - 0.028 * s, zc + 0.02 * s], 'z', 0.044 * s, 'bar', 10)\n  for (const s2 of [-1, 1]) k.cyl('brass', 0.005 * s, 0.03 * s, [x + s2 * w * 0.24, y - h / 2 - 0.006 * s, zc + 0.02 * s], 'y', 6)\n  k.cyl('brass', 0.011 * s, 0.03 * s, [x + w / 2 + 0.012 * s, y - h * 0.25, zc], 'x', 8)\n  return { w, h, d, zc }\n}\n\n/** Multi-turn electric actuator (IQ style) on its thrust base at y0: gearcase, motor, display cover, local controls, terminal box, handwheel. */\nexport function electricActuator(k: Kit, y0: number, s: number, A: MatKey, text: string) {\n  k.cyl('valveBody', 0.085 * s, 0.035 * s, [0, y0 + 0.0175 * s, 0], 'y', 28)\n  k.around(4, 0.07 * s, [0, y0 + 0.035 * s, 0], 'y', (p) => k.hex('nut', 0.018 * s, 0.01 * s, [p[0], p[1] + 0.005 * s, p[2]]), Math.PI / 4)\n  const yg = y0 + 0.035 * s + 0.13 * s\n  k.rbox(A, [0.28 * s, 0.26 * s, 0.26 * s], 0.05 * s, [0, yg, 0], undefined, 3)\n  // electrical housing, display window and selector knobs on the front (+Z)\n  const ye = yg + 0.04 * s\n  k.cyl(A, 0.125 * s, 0.11 * s, [0, ye, 0.17 * s], 'z', 40)\n  k.cyl(A, 0.135 * s, 0.03 * s, [0, ye, 0.235 * s], 'z', 40)\n  k.around(6, 0.12 * s, [0, ye, 0.25 * s], 'z', (p) => k.hex('nut', 0.014 * s, 0.008 * s, p, 'z'))\n  k.cyl('handwheel', 0.07 * s, 0.006 * s, [0, ye + 0.015 * s, 0.252 * s], 'z', 32)\n  k.disc(textMaterial('REMOTE', '#9fb59a', '#1c2a1d', 256, 256), 0.055 * s, [0, ye + 0.015 * s, 0.2555 * s], 'z', 32)\n  k.disc('glass', 0.06 * s, [0, ye + 0.015 * s, 0.257 * s], 'z', 32)\n  k.cyl('actuatorRed', 0.022 * s, 0.035 * s, [-0.07 * s, ye - 0.07 * s, 0.26 * s], 'z', 12)\n  k.box('actuatorRed', [0.034 * s, 0.01 * s, 0.012 * s], [-0.07 * s, ye - 0.07 * s, 0.28 * s])\n  k.cyl('handwheel', 0.022 * s, 0.035 * s, [0.07 * s, ye - 0.07 * s, 0.26 * s], 'z', 12)\n  k.box('handwheel', [0.01 * s, 0.034 * s, 0.012 * s], [0.07 * s, ye - 0.07 * s, 0.28 * s])\n  // motor housing (−X) with cooling ribs\n  k.cyl(A, 0.09 * s, 0.24 * s, [-0.24 * s, yg - 0.01 * s, 0], 'x', 32)\n  k.cyl(A, 0.095 * s, 0.025 * s, [-0.37 * s, yg - 0.01 * s, 0], 'x', 32)\n  for (const x of [-0.14, -0.17, -0.31, -0.34]) k.torus(A, 0.092 * s, 0.005 * s, [x * s, yg - 0.01 * s, 0], 'x', 6, 28)\n  // terminal box on top-back with conduit entries and cables\n  const yt = yg + 0.13 * s + 0.04 * s\n  k.rbox(A, [0.2 * s, 0.08 * s, 0.16 * s], 0.02 * s, [0.02 * s, yt, -0.04 * s])\n  for (const dz of [-0.09, 0.0]) {\n    k.cyl('brass', 0.016 * s, 0.05 * s, [0.145 * s, yt - 0.01 * s, dz * s], 'x', 10)\n    k.hex('brass', 0.03 * s, 0.015 * s, [0.175 * s, yt - 0.01 * s, dz * s], 'x')\n    k.cyl('nut', 0.012 * s, 0.025 * s, [0.195 * s, yt - 0.01 * s, dz * s], 'x', 10)\n  }\n  // handwheel on +X with hand/auto lever\n  k.cyl(A, 0.05 * s, 0.07 * s, [0.175 * s, yg, 0], 'x', 20)\n  k.handwheel('handwheel', 0.34 * s, [0.24 * s, yg, 0], 'x', 4)\n  k.box('actuatorRed', [0.02 * s, 0.12 * s, 0.02 * s], [0.12 * s, yg + 0.1 * s, -0.1 * s])\n  k.sphere('handwheel', 0.016 * s, [0.12 * s, yg + 0.165 * s, -0.1 * s], undefined, 10)\n  tagPlate(k, text, [-0.24 * s, yg - 0.01 * s, 0.092 * s], 'z', 0.1 * s)\n  return { top: yg + 0.13 * s }\n}\n"
    }
  ],
  "categories": [
    "valves"
  ],
  "meta": {
    "export": "ControlValve",
    "anchor": "inline",
    "standards": [
      "IEC 60534",
      "ISA 75.08.01",
      "ASME B16.34",
      "ASME B16.5"
    ]
  }
}