{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "centrifugal-fan",
  "type": "registry:component",
  "title": "Centrifugal fan (belt driven)",
  "description": "Single-width single-inlet centrifugal fan for HVAC and process extract: hollow spiral scroll (you see the backward-curved wheel through the bellmouth and down the outlet) with flanged side sheets, inspection door, drain, rotation arrow and nameplate; shaft in two regreasable pillow-block bearings on a pedestal; V-belt drive under a mesh guard; IEC motor on slide rails; steel base frame on open spring isolators; optional flexible duct connectors with companion flanges. Outlet up-blast or top-horizontal; wheel diameter and kW scale the set. Ports are duct flanges (kind utility, NPS = equivalent round duct).",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "rotating/centrifugal-fan.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/rotating/centrifugal-fan.tsx",
      "content": "// Belt-driven SWSI centrifugal fan (AMCA arrangement 9 style): scroll housing, inlet bellmouth, pillow-block bearings, V-belt drive under a guard, IEC motor, spring isolators.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, uvScale, type MatKey, type V3 } from '../lib/kit'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { paint } from '../lib/materials'\nimport { liftingLug, member, motor, tagPlate } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { Rotors, type RotorSpec } from '../lib/rotors'\nimport { meshGuardMaterial } from '../lib/textures'\nimport { anchorBolt, castExtrude, clamp, motorEnvelope, voluteShape } from './_pumps'\n\nexport type FanOutlet = 'up' | 'horizontal'\n\nexport interface CentrifugalFanProps {\n  /** impeller diameter, m */\n  wheelDiameter?: number\n  /** motor rated power, kW */\n  kw?: number\n  outlet?: FanOutlet\n  /** flexible duct connectors with companion flanges at inlet and outlet */\n  flexible?: boolean\n  color?: string\n  tag?: string\n  running?: boolean\n}\n\nexport const defaults = {\n  wheelDiameter: 0.8,\n  kw: 15,\n  outlet: 'up' as FanOutlet,\n  flexible: true,\n  color: '#8f9a9e',\n  running: true,\n}\n\ntype P = Omit<typeof defaults, 'running'> & { tag?: string }\n\nconst ISO = 0.16 // spring isolator height\nconst FRAME = 'UPN 140'\n\nfunction layout(p: P) {\n  const D = clamp(p.wheelDiameter, 0.3, 2.5)\n  const r0 = D * 0.54\n  const r1 = D * 0.88\n  const out = D * 0.95\n  const W = D * 0.62 // housing width (SWSI)\n  const Di = D * 0.72 // inlet diameter\n  const Lin = 0.08 + D * 0.2\n  const hb = ISO + 0.14 + 0.008 // base frame top\n  const ys = hb + r1 + 0.08\n  const xh = 0\n  const xIn = xh + W / 2 + Lin\n  const xP = xh - W / 2 - 0.25 - D * 0.35 // pulley plane\n  const m = motorEnvelope(clamp(p.kw, 0.37, 400), 4)\n  const zM = r1 * 0.75 + m.R + 0.2\n  const ysM = hb + m.H\n  const xMf = xP + 0.12 + m.R * 0.2 // motor DE face (motor faces -X)\n  const x0 = xP - 0.18\n  const x1 = Math.max(xIn, xMf + m.L + Math.max(0.12, m.R * 0.6)) + 0.1\n  const z0 = -r1 - 0.12\n  const z1 = zM + m.R + 0.25\n  const cx = (x0 + x1) / 2\n  const cz = (z0 + z1) / 2\n  const oa = r1 - r0\n  // outlet face centre and direction in the unshifted frame\n  const outC: V3 = p.outlet === 'up' ? [xh, ys + out, -(r0 + r1) / 2] : [xh, ys + (r0 + r1) / 2, out]\n  const outD: V3 = p.outlet === 'up' ? [0, 1, 0] : [0, 0, 1]\n  return { D, r0, r1, out, W, Di, Lin, hb, ys, xh, xIn, xP, m, zM, ysM, xMf, x0, x1, z0, z1, cx, cz, oa, outC, outD }\n}\n\nconst flangeOut = 0.035 // duct companion-angle flange depth\nconst FLEX = 0.12 // flexible connector length, typical\nconst ext = (p: { flexible?: boolean }) => flangeOut + (p.flexible ? FLEX + flangeOut : 0)\n\n// equivalent round duct diameter of a rectangle (ASHRAE), in inches\nconst ductNps = (a: number, b: number) => Math.round((1.3 * Math.pow(a * b, 0.625)) / Math.pow(a + b, 0.25) / 0.0254)\n\nexport function ports(props: CentrifugalFanProps = {}): Port[] {\n  const p = { ...defaults, ...props }\n  const L = layout(p)\n  const o = L.outC\n  const d = L.outD\n  return [\n    port('inlet', 'Inlet', [L.xIn + ext(p) - L.cx, L.ys, -L.cz], [1, 0, 0], Math.round(L.Di / 0.0254), undefined, 'utility'),\n    port('outlet', 'Outlet', [o[0] - L.cx + d[0] * ext(p), o[1] + d[1] * ext(p), o[2] - L.cz + d[2] * ext(p)], d, ductNps(L.oa, L.W), undefined, 'utility'),\n  ]\n}\n\nexport function CentrifugalFan({ tag, ...rest }: CentrifugalFanProps & GroupProps) {\n  const { wheelDiameter, kw, outlet, flexible, color, running, ...group } = { ...defaults, ...rest }\n  const { built, rotors } = useMemo(() => build({ wheelDiameter, kw, outlet, flexible, color, tag }), [wheelDiameter, kw, outlet, flexible, color, tag])\n  useEffect(() => () => [built, ...rotors.map((r) => r.built)].forEach((b) => b.geometry.dispose()), [built, rotors])\n  return (\n    <group {...group}>\n      <KitMesh data={built} />\n      <Rotors rotors={rotors} running={running} />\n    </group>\n  )\n}\n\nexport default CentrifugalFan\n\nfunction build(p: P) {\n  const k = new Kit()\n  const L = layout(p)\n  const rotors: RotorSpec[] = []\n  k.at([-L.cx, 0, -L.cz], undefined, () => draw(k, p, L, rotors))\n  for (const r of rotors) r.pos = [r.pos[0] - L.cx, r.pos[1], r.pos[2] - L.cz]\n  return { built: k.build(), rotors }\n}\n\nfunction draw(k: Kit, p: P, L: ReturnType<typeof layout>, rotors: RotorSpec[]) {\n  const H: MatKey = paint(p.color, { metalness: 0.4, roughness: 0.45, clearcoat: 0.3, clearcoatRoughness: 0.4, weather: 0.8 })\n  const { D, r0, r1, out, W, Di, Lin, hb, ys, xh, xIn, xP, m, zM, ysM, xMf, x0, x1, z0, z1, oa } = L\n  const up = p.outlet === 'up'\n\n  // scroll: wrapper + flanged side sheets\n  const u = up ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(0, 1, 0)\n  const v = up ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(0, 0, 1)\n  const basis = (x: number) => new THREE.Matrix4().makeBasis(u, v, new THREE.Vector3().crossVectors(u, v)).setPosition(x, ys, 0)\n  // hollow wrapper band (open at the outlet end), then side sheets: inlet side with the eye cut out\n  const t = 0.006\n  const band = scrollBand(r0, r1, out, t)\n  k.extrude(H, band, W, basis(xh).multiply(new THREE.Matrix4().makeTranslation(0, 0, -W / 2)), 4)\n  const back = voluteShape(r0, r1 + 0.012, out)\n  const front = voluteShape(r0, r1 + 0.012, out)\n  const eye = new THREE.Path()\n  eye.absarc(0, 0, Di / 2, 0, Math.PI * 2, true)\n  front.holes.push(eye)\n  // side sheet normal = u x v; +X for 'up' and 'horizontal' alike\n  const nx = new THREE.Vector3().crossVectors(u, v).x\n  for (const [sh, s] of [[front, 1], [back, -1]] as [THREE.Shape, number][]) castExtrude(k, H, sh, 0.008, 0.002, basis(xh + s * nx * (W / 2 + 0.004)), 32)\n  // outlet companion flange (angle frame) with bolts\n  const oc = L.outC\n  const od = new THREE.Vector3(...L.outD)\n  const at = (a: number, b: number, c: number) => new THREE.Vector3(...oc).addScaledVector(od, c).addScaledVector(new THREE.Vector3(1, 0, 0), a).addScaledVector(up ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0), b).toArray() as V3\n  const fq = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), od)\n  const ow = W + 0.06\n  const oh = oa + 0.06\n  for (const s of [-1, 1]) {\n    k.box(H, [ow + 0.06, 0.04, flangeOut], at(0, s * (oh / 2 + 0.01), flangeOut / 2 - 0.004), fq)\n    k.box(H, [0.04, oh + 0.06, flangeOut], at(s * (ow / 2 + 0.01), 0, flangeOut / 2 - 0.004), fq)\n  }\n  const nb = Math.max(3, Math.round(ow / 0.15))\n  const c1 = p.flexible ? flangeOut + FLEX + flangeOut : flangeOut\n  if (p.flexible) {\n    // fabric connector (rectangular sleeve) and the duct-side companion frame\n    for (const s of [-1, 1]) {\n      k.box('rubberBlack', [W + 0.03, 0.008, FLEX], at(0, s * (oa / 2 + 0.012), flangeOut + FLEX / 2), fq)\n      k.box('rubberBlack', [0.008, oa + 0.03, FLEX], at(s * (W / 2 + 0.012), 0, flangeOut + FLEX / 2), fq)\n      k.box(H, [ow + 0.06, 0.04, flangeOut], at(0, s * (oh / 2 + 0.01), flangeOut * 1.5 + FLEX), fq)\n      k.box(H, [0.04, oh + 0.06, flangeOut], at(s * (ow / 2 + 0.01), 0, flangeOut * 1.5 + FLEX), fq)\n    }\n  }\n  for (let i = 0; i < nb; i++) for (const s of [-1, 1]) k.hex('nut', 0.018, 0.01, at(-ow / 2 + (ow * (i + 0.5)) / nb, s * (oh / 2 + 0.01), c1), L.outD)\n  // inlet bellmouth + duct flange ring with bolts\n  const ri = Di / 2\n  const xs = xh + W / 2\n  const bell: [number, number][] = [[ri * 1.0, 0], [ri * 1.3, 0.008], [ri * 1.32, 0.02], [ri * 1.08, 0.06], [ri, 0.12], [ri, Lin - 0.005]]\n  k.lathe(H, bell, [xs, ys, 0], 'x', 48)\n  k.lathe(H, bell.map(([r, y]) => [r - 0.003, y] as [number, number]).reverse(), [xs, ys, 0], 'x', 48)\n  k.lathe(H, [[ri - 0.003, Lin - 0.006], [ri + 0.05, Lin - 0.006], [ri + 0.05, Lin + flangeOut], [ri - 0.003, Lin + flangeOut], [ri - 0.003, Lin - 0.006]], [xs, ys, 0], 'x', 48)\n  if (p.flexible) {\n    const xf = xs + Lin + flangeOut\n    k.lathe('rubberBlack', [[ri + 0.004, 0], [ri + 0.02, FLEX / 2], [ri + 0.004, FLEX]], [xf, ys, 0], 'x', 40)\n    k.lathe('rubberBlack', [[ri - 0.002, FLEX], [ri - 0.002, 0]], [xf, ys, 0], 'x', 40)\n    k.lathe(H, [[ri - 0.003, FLEX], [ri + 0.05, FLEX], [ri + 0.05, FLEX + flangeOut], [ri - 0.003, FLEX + flangeOut], [ri - 0.003, FLEX]], [xf, ys, 0], 'x', 48)\n  }\n  k.around(Math.max(8, Math.round((Math.PI * Di) / 0.15)), ri + 0.03, [xIn + ext(p) + 0.005, ys, 0], 'x', (q) => k.hex('nut', 0.018, 0.01, q, 'x'))\n  // inspection door, drain, lifting lugs, rotation arrow, nameplate\n  const dz = up ? r1 * 0.2 : r1 * 0.75\n  const dy = up ? -r1 * 0.55 : -r1 * 0.2\n  k.rbox(H, [0.02, D * 0.3, D * 0.22], 0.01, [xs + 0.012, ys + dy, dz])\n  for (const a of [-1, 1]) k.cyl('handwheel', 0.012, 0.04, [xs + 0.04, ys + dy + a * D * 0.1, dz], 'x', 8)\n  k.cyl(H, 0.015, 0.06, [xh, ys - r1 * 0.98, 0], 'y', 10)\n  k.hex('nut', 0.03, 0.02, [xh, ys - r1 * 0.98 - 0.035, 0])\n  for (const s of [-1, 1]) liftingLug(k, [xh + s * (W / 2 + 0.006), ys + r1 * (up ? 0.3 : 0.9) - 0.02, up ? r0 * 0.5 : -r1 * 0.2], 'z', 0.8, H)\n  k.box('actuatorRed', [0.004, 0.03, D * 0.18], [xs + 0.012, ys + r0 * 0.7, up ? 0.2 * r0 : -0.3 * r0])\n  k.box('nameplate', [0.003, 0.08, 0.13], [xs + 0.011, ys - r0 * 0.6, -r0 * 0.6])\n  if (p.tag) tagPlate(k, p.tag, [xs + 0.012, ys - r0 * 0.2, -r0 * 0.75], 'x', 0.14)\n  // housing support legs to the base frame\n  for (const s of [-1, 1]) {\n    const zl = s * r0 * 0.75\n    const yTop = ys - Math.sqrt(Math.max(0, r1 * r1 * 0.85 - zl * zl)) + 0.02\n    k.box(H, [W + 0.02, Math.max(0.02, yTop - hb), 0.05], [xh, (yTop + hb) / 2, zl])\n    k.box(H, [W + 0.1, 0.012, 0.14], [xh, hb + 0.006, zl])\n  }\n\n  // shaft, pillow-block bearings on a pedestal\n  const rs = clamp(0.02 + D * 0.025, 0.025, 0.07)\n  const xb0 = xh - W / 2 - 0.15\n  const xb1 = xP + 0.15\n  k.cyl('flangeSteel', rs, xs - xP + 0.06, [(xs + xP) / 2, ys, 0], 'x', 16)\n  const ped = 0.1\n  k.box(H, [xb0 - xb1 + 0.2, 0.02, ped * 2.6], [(xb0 + xb1) / 2, ys - rs * 2.6, 0])\n  for (const sz of [-1, 1]) k.box(H, [xb0 - xb1 + 0.2, ys - rs * 2.6 - hb, 0.012], [(xb0 + xb1) / 2, (ys - rs * 2.6 + hb) / 2, sz * ped * 1.2])\n  for (const xb of [xb0, xb1]) {\n    k.box('castIron', [rs * 3, rs * 1.6, rs * 7], [xb, ys - rs * 1.8, 0])\n    k.cyl('castIron', rs * 2.2, rs * 3, [xb, ys, 0], 'x', 24)\n    k.cyl('brass', 0.006, 0.02, [xb, ys + rs * 2.3, 0], 'y', 8)\n    k.sphere('brass', 0.009, [xb, ys + rs * 2.3 + 0.014, 0], undefined, 8)\n    for (const sz of [-1, 1]) k.hex('nut', rs * 0.9, rs * 0.5, [xb, ys - rs * 0.95, sz * rs * 2.8])\n  }\n\n  // V-belt drive: pulleys, belts, guard (mesh faces, yellow rim)\n  const Ra = clamp(D * 0.2, 0.08, 0.4)\n  const Rb = Ra * 0.6\n  const A = new THREE.Vector2(0, ys) // (z, y)\n  const B = new THREE.Vector2(zM, ysM)\n  const nB = Math.max(2, Math.min(5, Math.round(p.kw / 8) + 1))\n  const bw = 0.025\n  const n2 = new THREE.Vector2().subVectors(B, A).normalize()\n  const nn = new THREE.Vector2(-n2.y, n2.x)\n  for (let i = 0; i < nB; i++) {\n    const x = xP + (i - (nB - 1) / 2) * bw\n    k.torus('rubberBlack', Ra + 0.004, 0.008, [x, A.y, A.x], 'x', 6, 40)\n    k.torus('rubberBlack', Rb + 0.004, 0.008, [x, B.y, B.x], 'x', 6, 32)\n    for (const s of [-1, 1]) k.rod('rubberBlack', [x, A.y + nn.y * s * (Ra + 0.004), A.x + nn.x * s * (Ra + 0.004)], [x, B.y + nn.y * s * (Rb + 0.004), B.x + nn.x * s * (Rb + 0.004)], 0.008, 6)\n  }\n  const gm = 0.06\n  const shape = new THREE.Shape()\n  const ang = Math.atan2(n2.y, n2.x)\n  shape.absarc(A.x, A.y, Ra + gm, ang + Math.PI / 2, ang + (3 * Math.PI) / 2, false)\n  shape.absarc(B.x, B.y, Rb + gm, ang - Math.PI / 2, ang + Math.PI / 2, false)\n  shape.closePath()\n  const gq = new THREE.Matrix4().makeBasis(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0), new THREE.Vector3(-1, 0, 0))\n  const gd = nB * bw + 0.08\n  const rim = new THREE.Shape(shape.getPoints(48))\n  const hole = new THREE.Path(shape.getPoints(48).map((q) => q.clone().sub(new THREE.Vector2((A.x + B.x) / 2, (A.y + B.y) / 2)).multiplyScalar(0.97).add(new THREE.Vector2((A.x + B.x) / 2, (A.y + B.y) / 2))))\n  rim.holes.push(hole)\n  k.extrude('safetyYellow', rim, gd, gq.clone().setPosition(xP + gd / 2, 0, 0), 4)\n  for (const xf of [xP - gd / 2, xP + gd / 2]) {\n    const g = new THREE.ShapeGeometry(shape, 32)\n    uvScale(g, 8, 8)\n    k.add(meshGuardMaterial(), g, gq.clone().setPosition(xf, 0, 0))\n  }\n  k.rod('safetyYellow', [xP, A.y - Ra - gm, A.x], [xP, hb, A.x], 0.015, 8)\n\n  // motor (faces -X), slide rails for belt tension\n  k.at([xMf, ysM, zM], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI), () => motor(k, { R: m.R, L: m.L, H: ysM - hb, tbox: 'top' }))\n  for (const s of [-1, 1]) {\n    k.box('galvanised', [m.L * 1.1, 0.03, 0.06], [xMf + m.L * 0.5, hb + 0.015, zM + s * m.R * 0.78])\n    k.cyl('boltXylan', 0.008, 0.12, [xMf + m.L * 1.08, hb + 0.03, zM + s * m.R * 0.78], 'x', 6)\n  }\n\n  // turning parts: fan wheel (visible through the inlet) + shaft + fan pulley; motor pulley\n  {\n    const r = new Kit()\n    const bk = -W * 0.42\n    r.cyl('galvanised', D / 2, 0.008, [bk, 0, 0], 'x', 40)\n    r.lathe('galvanised', [[ri * 0.98, W * 0.25], [D / 2 * 0.98, bk + W * 0.62], [D / 2, bk + W * 0.6]], [0, 0, 0], 'x', 40)\n    const nBl = 12\n    for (let i = 0; i < nBl; i++) {\n      const a = (i / nBl) * Math.PI * 2\n      const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), a).multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0.5))\n      const rr = D * 0.4\n      r.box('galvanised', [W * 0.55, D * 0.18, 0.006], [bk + W * 0.3, Math.cos(a) * rr, Math.sin(a) * rr], q)\n    }\n    r.cyl('castIron', rs * 2.2, W * 0.4, [bk + W * 0.2, 0, 0], 'x', 16)\n    r.cyl('castIron', Ra, nB * bw + 0.02, [xP - xh, 0, 0], 'x', 32)\n    for (let i = 0; i < 4; i++) r.box('nut', [nB * bw + 0.022, 0.02, Ra * 0.5], [xP - xh, Ra * 0.55 * Math.cos(i * 1.57), Ra * 0.55 * Math.sin(i * 1.57)], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), i * 1.57))\n    rotors.push({ built: r.build(), axis: 'x', pos: [xh, ys, 0], rpm: 120 })\n    const r2 = new Kit()\n    r2.cyl('castIron', Rb, nB * bw + 0.02, [0, 0, 0], 'x', 28)\n    r2.box('nut', [nB * bw + 0.022, Rb * 0.3, 0.02], [0, Rb * 0.6, 0])\n    rotors.push({ built: r2.build(), axis: 'x', pos: [xP, ysM, zM], rpm: 200 })\n  }\n\n  // base frame on spring isolators\n  const bx = x1 - x0\n  const bz = z1 - z0\n  const xm0 = (x0 + x1) / 2\n  const zm0 = (z0 + z1) / 2\n  const sec = 0.14\n  for (const s of [-1, 1]) member(k, [x0, ISO + sec / 2, zm0 + s * (bz / 2 - 0.03)], [x1, ISO + sec / 2, zm0 + s * (bz / 2 - 0.03)], FRAME, 'structSteel')\n  for (const xx of [x0 + 0.04, xh, xP, x1 - 0.04]) member(k, [xx, ISO + sec / 2, z0 + 0.06], [xx, ISO + sec / 2, z1 - 0.06], FRAME, 'structSteel')\n  k.box('structSteel', [m.L * 1.25, 0.008, Math.min(m.R * 2.4, z1 - zM - 0.02 + m.R)], [xMf + m.L * 0.5, hb - 0.004, zM])\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) isolator(k, [xm0 + sx * (bx / 2 - 0.12), 0, zm0 + sz * (bz / 2 - 0.03)])\n}\n\n// open spring isolator: base plate with anchors, coil, top plate and levelling bolt\nfunction isolator(k: Kit, p: V3) {\n  const [x, , z] = p\n  k.box('galvanised', [0.18, 0.012, 0.12], [x, 0.006, z])\n  for (const s of [-1, 1]) anchorBolt(k, [x + s * 0.07, 0.012, z], 0.012)\n  k.cyl('galvanised', 0.045, 0.02, [x, 0.022, z], 'y', 20)\n  const turns = 5\n  const pts: V3[] = []\n  for (let i = 0; i <= turns * 16; i++) {\n    const a = (i / 16) * Math.PI * 2\n    pts.push([x + Math.cos(a) * 0.035, 0.034 + (i / (turns * 16)) * (ISO - 0.06), z + Math.sin(a) * 0.035])\n  }\n  k.spline('actuatorYellow', pts, 0.006, 6, turns * 24)\n  k.cyl('galvanised', 0.045, 0.02, [x, ISO - 0.02, z], 'y', 20)\n  k.cyl('boltXylan', 0.008, 0.06, [x, ISO, z], 'y', 8)\n  k.hex('nut', 0.025, 0.014, [x, ISO - 0.006, z])\n}\n\n// C-section of the scroll wrapper sheet: outer spiral + outlet walls, thickness t, open at the outlet end\nfunction scrollBand(r0: number, r1: number, out: number, t: number, n = 72) {\n  const sh = new THREE.Shape()\n  const r = (th: number, d = 0) => r0 + ((r1 - r0) * th) / (Math.PI * 2) - d\n  sh.moveTo(r1, out)\n  sh.lineTo(r1, 0)\n  for (let i = n - 1; i >= 0; i--) {\n    const th = (i / n) * Math.PI * 2\n    sh.lineTo(Math.cos(th) * r(th), Math.sin(th) * r(th))\n  }\n  sh.lineTo(r0, out)\n  sh.lineTo(r0 + t, out)\n  sh.lineTo(r0 + t, 0)\n  sh.lineTo(r0 + t, -t)\n  for (let i = 0; i <= n; i++) {\n    const th = (i / n) * Math.PI * 2\n    sh.lineTo(Math.cos(th) * r(th, t), Math.sin(th) * r(th, t))\n  }\n  sh.lineTo(r1 - t, out)\n  sh.closePath()\n  return sh\n}\n"
    },
    {
      "path": "rotating/_pumps.ts",
      "type": "registry:file",
      "target": "components/deck-twin/rotating/_pumps.ts",
      "content": "// Shared pump, motor and fan geometry: plates, guards, vertical motor, drain-rim baseplate, flanged branches, control stations.\nimport * as THREE from 'three'\nimport { Kit, mat4, uvScale, type MatKey, type Orient, type V3 } from '../lib/kit'\nimport { flange, gauge, motorSize, tagPlate } from '../lib/parts'\nimport { fanGuardMaterial, meshGuardMaterial } from '../lib/textures'\nimport { flange as flangeDims, pipeOD, pipeWall, type Rating } from '../lib/standards'\n\nexport const TAU = Math.PI * 2\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\n/** Quaternion turning local +Z onto n (plates facing n). */\nexport const faceQ = (n: V3) => new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(...n).normalize())\n\n/** Flat ring (rIn = 0: disc) between y0 and y1 along `axis` from `pos`. */\nexport function annulus(k: Kit, mat: MatKey, rIn: number, rOut: number, y0: number, y1: number, pos: V3 = [0, 0, 0], axis: Orient = 'y', seg = 40) {\n  k.lathe(mat, [[rIn, y0], [rOut, y0], [rOut, y1], [rIn, y1], [rIn, y0]], pos, axis, seg)\n}\n\n/** Open (uncapped) cylinder between two points. */\nexport function openTube(k: Kit, mat: MatKey, a: V3, b: V3, r: number, seg = 24, r2?: number) {\n  const va = new THREE.Vector3(...a)\n  const d = new THREE.Vector3(...b).sub(va)\n  const L = d.length()\n  if (L < 1e-6) return\n  const mid = va.addScaledVector(d, 0.5)\n  k.add(mat, new THREE.CylinderGeometry(r2 ?? r, r, L, seg, 1, true), mat4([mid.x, mid.y, mid.z], d.toArray() as V3))\n}\n\n/** Expanded-metal guard tube between two points. */\nexport function meshTube(k: Kit, a: V3, b: V3, r: number, r2?: number, color?: string) {\n  const va = new THREE.Vector3(...a)\n  const d = new THREE.Vector3(...b).sub(va)\n  const L = d.length()\n  const g = new THREE.CylinderGeometry(r2 ?? r, r, L, 28, 1, true)\n  uvScale(g, TAU * Math.max(r, r2 ?? r) * 8, L * 8)\n  const mid = va.addScaledVector(d, 0.5)\n  k.add(meshGuardMaterial(color), g, mat4([mid.x, mid.y, mid.z], d.toArray() as V3))\n}\n\n/** Expanded-metal flat panel w × h facing +normal. */\nexport function meshPanel(k: Kit, w: number, h: number, pos: V3, normal: V3, color?: string) {\n  const g = new THREE.PlaneGeometry(w, h)\n  uvScale(g, w * 8, h * 8)\n  const m = new THREE.Matrix4().compose(new THREE.Vector3(...pos), faceQ(normal), new THREE.Vector3(1, 1, 1))\n  k.add(meshGuardMaterial(color), g, m)\n}\n\n/** Plate from a to b: width w along `side` (made perpendicular), thickness t. */\nexport function strut(k: Kit, mat: MatKey, a: V3, b: V3, w: number, t: number, side: V3) {\n  const A = new THREE.Vector3(...a)\n  const Y = new THREE.Vector3(...b).sub(A)\n  const L = Y.length()\n  if (L < 1e-6) return\n  Y.normalize()\n  const X = new THREE.Vector3(...side)\n  X.addScaledVector(Y, -X.dot(Y)).normalize()\n  const Z = new THREE.Vector3().crossVectors(X, Y)\n  const m = new THREE.Matrix4().makeBasis(X, Y, Z).setPosition(A.addScaledVector(Y, L / 2))\n  k.add(mat, new THREE.BoxGeometry(w, L, t), m)\n}\n\n/** Triangular gusset in the radial plane at angle `ang` about +Y, r0 → r1 at yPlate, height h (h < 0 hangs below). */\nexport function gusset(k: Kit, mat: MatKey, ang: number, r0: number, r1: number, yPlate: number, h: number, t: number) {\n  const s = new THREE.Shape()\n  s.moveTo(r0, yPlate)\n  s.lineTo(r1, yPlate)\n  s.lineTo(r0, yPlate + h)\n  s.closePath()\n  const m = new THREE.Matrix4().makeRotationY(ang).multiply(new THREE.Matrix4().makeTranslation(0, 0, -t / 2))\n  k.extrude(mat, s, t, m, 1)\n}\n\n/** Toothed (cooling-fin) TEFC frame section in the XY plane. */\nexport function finnedShape(R: number, finH: number, nFin: number) {\n  const sh = new THREE.Shape()\n  const step = TAU / nFin\n  const finW = Math.min(0.012, step * R * 0.28)\n  const da = finW / 2 / R\n  const pts: [number, number][] = []\n  for (let i = 0; i < nFin; i++) {\n    const a = i * step\n    for (let s = 0; s < 3; s++) {\n      const aa = a - step / 2 + (s / 3) * (step / 2 - da)\n      pts.push([Math.cos(aa) * R, Math.sin(aa) * R])\n    }\n    pts.push([Math.cos(a - da) * R, Math.sin(a - da) * R])\n    pts.push([Math.cos(a - da * 0.6) * (R + finH), Math.sin(a - da * 0.6) * (R + finH)])\n    pts.push([Math.cos(a + da * 0.6) * (R + finH), Math.sin(a + da * 0.6) * (R + finH)])\n    pts.push([Math.cos(a + da) * R, Math.sin(a + da) * R])\n  }\n  pts.forEach(([x, y], i) => (i ? sh.lineTo(x, y) : sh.moveTo(x, y)))\n  sh.closePath()\n  return sh\n}\n\n/** Weld-neck flange length (weld end to raised face). */\nexport const flangeLen = (nps: number, rating: Rating) => {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\n/** Branch pipe from `from` along `dir` to a WN flange (with studs) whose raised face is at `face`. */\nexport function flangedBranch(k: Kit, mat: MatKey, from: V3, face: V3, nps: number, rating: Rating, schedule: '40' | '80' | 'STD' = '80') {\n  const d = new THREE.Vector3(...face).sub(new THREE.Vector3(...from)).normalize()\n  const L = flange(k, nps, rating, face, d.toArray() as V3, true)\n  const end = new THREE.Vector3(...face).addScaledVector(d, -L).toArray() as V3\n  const ro = pipeOD(nps) / 2\n  k.pipe(mat, from, end, pipeOD(nps), pipeWall(nps, schedule), nps >= 6 ? 32 : 20)\n  // bore through the flange hub up to the face, and a dark end where the branch opens into the casing\n  openTube(k, 'pipeBore', end, new THREE.Vector3(...face).addScaledVector(d, -0.0005).toArray() as V3, ro * 0.88, nps >= 6 ? 32 : 20)\n  k.disc('pipeBore', ro - pipeWall(nps, schedule) + 0.001, from, d.toArray() as V3, 24)\n}\n\n/** IEC motor envelope from kW; above the IEC 60072 range (1 MW) the frame grows with the cube root of power. */\nexport function motorEnvelope(kw: number, poles: 2 | 4 | 6 = 4) {\n  const m = motorSize(kw, poles)\n  const s = kw > 1000 ? Math.cbrt(kw / 1000) : 1\n  return { ...m, R: m.R * s, L: m.L * s, H: m.H * s, frame: kw > 1000 ? 'HV' : m.frame }\n}\n\nexport interface VerticalMotorOpts {\n  /** frame radius (without fins), m */\n  R: number\n  /** frame length flange to fan end, m */\n  H: number\n  /** D-flange underside height, m */\n  yF: number\n  mat?: MatKey\n  /** text on the nameplate, e.g. \"IEC 315M 160 kW\" */\n  plate?: string\n  /** height of the deck the cables run down to */\n  deck?: number\n  canopy?: boolean\n}\n\n/** Vertical TEFC motor (IM V1): D-flange, finned frame, end shields, fan cowl, drip canopy, terminal + heater boxes. Returns the top height. */\nexport function verticalMotor(k: Kit, o: VerticalMotorOpts) {\n  const { R: Rm, H: Hm, yF } = o\n  const drv = o.mat ?? 'motorPaint'\n  const deck = o.deck ?? 0\n  const finH = Math.max(0.012, Rm * 0.1)\n  const shL = Math.max(0.05, Rm * 0.22)\n  annulus(k, drv, 0, Rm * 1.12, yF, yF + 0.05, [0, 0, 0], 'y', 48)\n  k.boltCircle('boltXylan', 'nut', 8, Rm * 1.04, [0, yF - 0.04, 0], 'y', Math.max(0.012, Rm * 0.04), 0.12, [0.105], Math.PI / 8)\n  const y0f = yF + 0.05 + shL\n  const yM1 = yF + 0.05 + Hm\n  const y1f = yM1 - shL\n  k.lathe(drv, [[Rm * 0.6, yF + 0.05], [Rm + finH * 0.6, yF + 0.05 + shL * 0.55], [Rm + finH * 0.6, y0f]], [0, 0, 0], 'y', 48)\n  const nFin = Math.max(28, Math.round(Rm * 100))\n  k.extrude(drv, finnedShape(Rm, finH, nFin), y1f - y0f, new THREE.Matrix4().makeTranslation(0, y0f, 0).multiply(new THREE.Matrix4().makeRotationX(-Math.PI / 2)), 2)\n  k.lathe(drv, [[Rm + finH * 0.6, y1f], [Rm + finH * 0.6, y1f + shL * 0.4], [Rm * 0.7, yM1]], [0, 0, 0], 'y', 48)\n  k.boltCircle('nut', 'nut', 8, Rm + finH * 0.3, [0, yF + 0.05 + shL * 0.7, 0], 'y', Math.max(0.008, Rm * 0.03), 0.03, [0.02])\n  const cR = Rm + finH + 0.015\n  const cowlH = Math.max(0.12, Rm * 0.45)\n  k.lathe(drv, [\n    [cR - 0.005, y1f - shL * 0.3],\n    [cR, y1f - shL * 0.3],\n    [cR, yM1 + cowlH * 0.8],\n    [cR * 0.94, yM1 + cowlH],\n    [cR * 0.94 - 0.004, yM1 + cowlH],\n  ], [0, 0, 0], 'y', 48)\n  k.disc('rubberBlack', cR * 0.9, [0, yM1 + cowlH - 0.02, 0], 'y', 32)\n  k.disc(fanGuardMaterial(), cR * 0.92, [0, yM1 + cowlH + 0.001, 0], 'y', 32)\n  let top = yM1 + cowlH\n  if (o.canopy !== false) {\n    const yCan = yM1 + cowlH + Math.max(0.06, Rm * 0.14)\n    for (let i = 0; i < 4; i++) {\n      const a = (i / 4) * TAU\n      k.rod(drv, [Math.cos(a) * cR * 0.88, yM1 + cowlH, Math.sin(a) * cR * 0.88], [Math.cos(a) * cR * 0.88, yCan, Math.sin(a) * cR * 0.88], 0.01, 6)\n    }\n    const hc = Math.max(0.04, Rm * 0.1)\n    k.lathe(drv, [[cR * 1.12, yCan - 0.04], [cR * 1.12, yCan], [cR * 0.4, yCan + hc], [0, yCan + hc]], [0, 0, 0], 'y', 48)\n    k.lathe(drv, [[0, yCan - 0.005], [cR * 1.1, yCan - 0.04]], [0, 0, 0], 'y', 48)\n    top = yCan + hc\n  }\n  // terminal box (+Z), anti-condensation heater / RTD box (-Z), cables to the deck\n  const tb = clamp(Rm * 0.75, 0.14, 0.5)\n  const yT = yF + Hm * 0.45\n  const zT = Rm + finH + tb * 0.28\n  k.box(drv, [tb * 0.5, tb * 0.5, finH + 0.03], [0, yT, Rm + finH * 0.5])\n  k.rbox(drv, [tb * 1.1, tb * 0.9, tb * 0.55], tb * 0.06, [0, yT, zT])\n  k.rbox(drv, [tb * 1.0, tb * 0.8, tb * 0.06], tb * 0.03, [0, yT, zT + tb * 0.3])\n  k.around(6, tb * 0.42, [0, yT, zT + tb * 0.335], 'z', (p) => k.hex('nut', 0.016, 0.01, p, 'z'), Math.PI / 6)\n  for (const dx of [-tb * 0.25, tb * 0.25]) {\n    k.cyl('brass', tb * 0.08, tb * 0.14, [dx, yT - tb * 0.5, zT], 'y', 12)\n    k.spline('cable', [[dx, yT - tb * 0.56, zT], [dx, yT - tb * 1.4, zT + 0.04], [dx * 1.4, (yT + deck) / 2, zT + 0.1], [dx * 1.6, deck + 0.03, zT + 0.12]], tb * 0.065, 8, 30)\n  }\n  const ta = tb * 0.5\n  const zA = -(Rm + finH + ta * 0.22)\n  k.rbox(drv, [ta, ta * 0.8, ta * 0.45], ta * 0.08, [0, yT + tb * 0.2, zA])\n  k.cyl('brass', ta * 0.1, ta * 0.18, [0, yT + tb * 0.2 - ta * 0.45, zA], 'y', 10)\n  k.spline('cable', [[0, yT + tb * 0.2 - ta * 0.5, zA], [0, yT - 0.4, zA - 0.08], [0.1, deck + 0.03, zA - 0.1]], 0.012, 6, 24)\n  // lifting lugs, nameplate, earth boss, bearing vibration probes\n  for (const s of [-1, 1]) {\n    const x = s * (Rm + finH + 0.03)\n    k.box(drv, [0.08, 0.16, 0.035], [x, y1f - 0.06, 0])\n    k.torus('nut', 0.035, 0.009, [x + s * 0.02, y1f + 0.03, 0], 'z', 6, 14)\n  }\n  const pn = new THREE.Vector3(0.707, 0, 0.707)\n  const pr = Rm + finH + 0.006\n  const pw = Math.max(0.1, Rm * 0.5)\n  k.box(drv, [pw * 0.6, pw * 0.4, finH + 0.01], [pn.x * (Rm + finH * 0.5), yF + Hm * 0.68, pn.z * (Rm + finH * 0.5)], faceQ([pn.x, 0, pn.z]))\n  if (o.plate) tagPlate(k, o.plate, [pn.x * pr, yF + Hm * 0.68, pn.z * pr], [pn.x, 0, pn.z], pw)\n  else k.box('nameplate', [pw, pw * 0.6, 0.003], [pn.x * pr, yF + Hm * 0.68, pn.z * pr], faceQ([pn.x, 0, pn.z]))\n  k.cyl('brass', 0.012, 0.03, [-(Rm + finH * 0.5) * 0.7, yF + 0.2, (Rm + finH * 0.5) * 0.7], [-0.7, 0, 0.7], 8)\n  for (const a of [Math.PI / 2 + 0.6, Math.PI / 2 - 0.6]) {\n    const u: V3 = [Math.cos(a), 0, Math.sin(a)]\n    k.rod('stainless', [u[0] * (Rm * 0.75), yF + 0.05 + shL * 0.5, u[2] * (Rm * 0.75)], [u[0] * (Rm * 0.75 + 0.08), yF + 0.05 + shL * 0.5 + 0.04, u[2] * (Rm * 0.75 + 0.08)], 0.012, 8)\n  }\n  return top\n}\n\n/** Drain-rim baseplate (API 610): channels, sloped deck, rim, grout holes, anchor bolts, lifting lugs, drain. Top at y = hb. */\nexport function drainRimBaseplate(k: Kit, L: number, W: number, hb: number, mat: MatKey = 'equipmentPaintDark', x0 = 0) {\n  const t = 0.012\n  k.at([x0, 0, 0], undefined, () => {\n    k.box(mat, [L, 0.016, W + 0.08], [0, 0.008, 0])\n    for (const s of [-1, 1]) {\n      k.box(mat, [L, hb - 0.016, t], [0, hb / 2 + 0.008, s * (W / 2 - t / 2)])\n      k.box(mat, [t, hb - 0.016, W], [s * (L / 2 - t / 2), hb / 2 + 0.008, 0])\n      const n = Math.round(L / 0.45)\n      for (let i = 1; i < n; i++) k.box(mat, [0.01, hb - 0.03, 0.04], [-L / 2 + (i * L) / n, hb / 2 + 0.008, s * (W / 2 + 0.02)])\n    }\n    k.box(mat, [L - 2 * t, 0.01, W - 2 * t], [0, hb - 0.03, 0])\n    for (const s of [-1, 1]) {\n      k.box(mat, [L, 0.045, 0.02], [0, hb - 0.005, s * (W / 2 - 0.01)])\n      k.box(mat, [0.02, 0.045, W], [s * (L / 2 - 0.01), hb - 0.005, 0])\n    }\n    for (const s of [-1, 1]) k.box(mat, [L - 0.1, hb - 0.05, 0.12], [0, (hb - 0.05) / 2 + 0.016, s * W * 0.28])\n    const nG = Math.max(2, Math.round(L / 0.9))\n    for (let i = 0; i < nG; i++) {\n      const gx = -L / 2 + (L * (i + 0.5)) / nG\n      k.torus(mat, 0.05, 0.008, [gx, hb - 0.022, 0], 'y', 6, 24)\n      k.disc('rubberBlack', 0.048, [gx, hb - 0.0245, 0], 'y', 24)\n    }\n    const nA = Math.max(3, Math.round(L / 0.7) + 1)\n    for (let i = 0; i < nA; i++) {\n      const ax = -L / 2 + 0.08 + ((L - 0.16) * i) / (nA - 1)\n      for (const s of [-1, 1]) anchorBolt(k, [ax, 0.016, s * (W / 2 + 0.035)])\n    }\n    for (const sx of [-1, 1])\n      for (const sz of [-1, 1]) {\n        k.box(mat, [0.1, 0.1, 0.02], [sx * (L / 2 - 0.12), hb * 0.55, sz * (W / 2 + 0.01)])\n        k.torus('nut', 0.025, 0.006, [sx * (L / 2 - 0.12), hb * 0.55 + 0.02, sz * (W / 2 + 0.022)], 'z', 6, 14)\n      }\n    k.cyl(mat, 0.022, 0.12, [L / 2 + 0.05, hb - 0.04, -W / 2 + 0.15], 'x', 12)\n    k.hex('nut', 0.05, 0.03, [L / 2 + 0.11, hb - 0.04, -W / 2 + 0.15], 'x')\n    k.cyl('brass', 0.015, 0.03, [-L / 2 + 0.3, hb * 0.5, W / 2 + 0.015], 'z', 10)\n  })\n}\n\n/** Anchor bolt with washer and nut on a plate whose top is at p[1]. */\nexport function anchorBolt(k: Kit, p: V3, d = 0.022) {\n  k.cyl('stainless', d * 0.9, 0.004, [p[0], p[1] + 0.002, p[2]], 'y', 16)\n  k.hex('nut', d * 1.9, d * 1.1, [p[0], p[1] + 0.004 + d * 0.55, p[2]])\n  k.cyl('boltXylan', d / 2, d * 3, [p[0], p[1] + d * 1.5, p[2]], 'y', 8)\n}\n\n/** Machined mounting pad with jack-screw block, top at y. */\nexport function mountPad(k: Kit, x: number, y: number, z: number, sx = 0.22, sz = 0.2, jack = 1, mat: MatKey = 'equipmentPaintDark') {\n  k.box('flangeSteel', [sx, 0.025, sz], [x, y - 0.0125, z])\n  if (!jack) return\n  k.box(mat, [0.04, 0.06, 0.06], [x + jack * (sx / 2 + 0.05), y + 0.02, z])\n  k.cyl('boltXylan', 0.008, 0.08, [x + jack * (sx / 2 + 0.03), y + 0.03, z], 'x', 6)\n}\n\n/** Local control station on a post (start / stop, E-stop, ammeter), facing +Z; base at `foot`. */\nexport function controlStation(k: Kit, foot: V3, face: V3 = [0, 0, 1], h = 1.2) {\n  const [lx, y0, lz] = foot\n  const q = faceQ(face)\n  const n = new THREE.Vector3(...face).normalize()\n  const P = (u: number, v: number, w: number): V3 => new THREE.Vector3(u, v, w).applyQuaternion(q).add(new THREE.Vector3(lx, 0, lz)).toArray() as V3\n  k.rod('structSteel', [lx, y0, lz], [lx, h + 0.25, lz], 0.03, 12)\n  k.box('structSteel', [0.15, 0.012, 0.15], [lx, y0 + 0.006, lz])\n  k.box('structSteel', [0.04, 0.3, 0.02], P(0, h, 0.04), q)\n  k.rbox('stainless', [0.24, 0.32, 0.13], 0.012, P(0, h, 0.115), q)\n  const tilt = q.clone().multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0.15))\n  k.box('stainless', [0.3, 0.012, 0.2], P(0, h + 0.18, 0.12), tilt)\n  const f = 0.18\n  k.cyl('safetyGreen', 0.016, 0.02, P(-0.055, h + 0.05, f), n.toArray() as V3, 16)\n  k.cyl('actuatorRed', 0.016, 0.02, P(0.055, h + 0.05, f), n.toArray() as V3, 16)\n  k.cyl('actuatorYellow', 0.04, 0.004, P(0, h - 0.07, f - 0.002), n.toArray() as V3, 24)\n  k.cyl('actuatorRed', 0.012, 0.03, P(0, h - 0.07, f + 0.012), n.toArray() as V3, 12)\n  k.dome('actuatorRed', 0.028, P(0, h - 0.07, f + 0.025), n.toArray() as V3, Math.PI / 2, 0.6)\n  gauge(k, P(0, h + 0.11, f - 0.01), n.toArray() as V3, 0.06, 'bar', 600)\n  k.hex('brass', 0.03, 0.02, P(0, h - 0.17, 0.11))\n  k.spline('cable', [P(0, h - 0.18, 0.11), P(0, h - 0.35, 0.06), P(0, y0 + 0.2, 0.04), P(0.1, y0 + 0.02, 0)], 0.008, 6, 24)\n}\n\n/** Pressure gauge on a root valve + siphon above a pipe top at `p` (dial facing +normal). */\nexport function pipeGauge(k: Kit, p: V3, normal: V3 = [0, 0, 1], max = 25, d = 0.1) {\n  const [x, y, z] = p\n  k.cyl('stainless', 0.012, 0.1, [x, y + 0.05, z], 'y', 8)\n  k.hex('stainless', 0.04, 0.035, [x, y + 0.09, z])\n  k.torus('stainless', 0.025, 0.006, [x, y + 0.14, z], [normal[0], 0, normal[2]], 6, 18)\n  k.cyl('stainless', 0.01, 0.05, [x, y + 0.19, z], 'y', 8)\n  gauge(k, [x + normal[0] * 0.02, y + 0.25, z + normal[2] * 0.02], normal, d, 'bar', max)\n}\n\n/** Spiral volute outline (Archimedean, r0 at the tongue to r1) with a straight tangential outlet of length `out` along +Y at u ∈ [r0, r1]. */\nexport function voluteShape(r0: number, r1: number, out: number, n = 48) {\n  const s = new THREE.Shape()\n  s.moveTo(r0, 0)\n  for (let i = 1; i <= n; i++) {\n    const th = (i / n) * TAU\n    const r = r0 + ((r1 - r0) * i) / n\n    s.lineTo(Math.cos(th) * r, Math.sin(th) * r)\n  }\n  s.lineTo(r1, out)\n  s.lineTo(r0, out)\n  s.closePath()\n  return s\n}\n\n/** Extruded shape with rounded (bevelled) edges, depth along local +Z centred on z = 0, placed by m. */\nexport function castExtrude(k: Kit, mat: MatKey, shape: THREE.Shape, depth: number, bevel: number, m: THREE.Matrix4, seg = 12) {\n  const g = new THREE.ExtrudeGeometry(shape, { depth: Math.max(depth - 2 * bevel, 0.001), bevelEnabled: true, bevelThickness: bevel, bevelSize: bevel, bevelSegments: 3, curveSegments: seg })\n  g.translate(0, 0, -(depth - 2 * bevel) / 2)\n  k.add(mat, g, m)\n}\n\n/** Top-mounted air-to-air cooler (IC611) on a horizontal HV motor whose DE face is at xm, shaft height ys. */\nexport function topCooler(k: Kit, xm: number, ys: number, Rm: number, Lm: number, mat: MatKey = 'motorPaint') {\n  const ch = Rm * 0.7\n  const y = ys + Rm * 1.12 + ch / 2\n  k.box(mat, [Lm * 0.3, Rm * 0.25, Rm * 0.9], [xm - Lm * 0.5, ys + Rm * 1.05, 0])\n  k.rbox(mat, [Lm * 0.75, ch, Rm * 1.7], 0.03, [xm - Lm * 0.5, y, 0])\n  for (let i = 0; i < 6; i++) k.box('rubberBlack', [Lm * 0.7, 0.01, Rm * 1.71], [xm - Lm * 0.5, y - ch * 0.35 + i * ch * 0.14, 0])\n  k.cyl(mat, ch * 0.45, 0.1, [xm - Lm * 0.875 - 0.05, y, 0], 'x', 28)\n  k.disc(fanGuardMaterial(), ch * 0.42, [xm - Lm * 0.875 - 0.101, y, 0], '-x', 24)\n}\n\n/** Constant-level oiler: arm from the bearing-housing surface point p along n, glass reservoir above. */\nexport function oiler(k: Kit, p: V3, n: V3 = [0, 0, 1], s = 1) {\n  const o = new THREE.Vector3(...p).addScaledVector(new THREE.Vector3(...n).normalize(), 0.07 * s)\n  k.rod('stainless', p, o.toArray() as V3, 0.008 * s, 8)\n  k.cyl('stainless', 0.03 * s, 0.03 * s, [o.x, o.y + 0.015 * s, o.z], 'y', 16)\n  k.cyl('glass', 0.032 * s, 0.09 * s, [o.x, o.y + 0.075 * s, o.z], 'y', 16)\n  k.dome('glass', 0.032 * s, [o.x, o.y + 0.12 * s, o.z], 'y', Math.PI / 2, 1)\n}\n\n/** Small valve (forged body, bonnet, handwheel) on a vertical line at p, handwheel up. */\nexport function smallValve(k: Kit, p: V3, s = 1, dir: Orient = 'y') {\n  k.at(p, dir, () => {\n    k.box('valveBody', [0.06 * s, 0.07 * s, 0.05 * s], [0, 0, 0])\n    k.cyl('valveBody', 0.016 * s, 0.07 * s, [0, 0.07 * s, 0], 'y', 10)\n    k.handwheel('handwheel', 0.07 * s, [0, 0.11 * s, 0], 'y')\n  })\n}\n\n/** API 682 Plan 23 seal cooler on a post: shell with bolted heads and cooling-water nozzles. Returns the top and bottom process connection points. */\nexport function sealCooler(k: Kit, foot: V3, yTop: number, r = 0.055) {\n  const [x, y0, z] = foot\n  const h = Math.max(0.3, r * 6)\n  const yc = yTop - h / 2\n  k.rod('structSteel', [x, y0, z], [x, yc - h / 2 - 0.02, z], 0.022, 10)\n  k.box('structSteel', [0.12, 0.012, 0.12], [x, y0 + 0.006, z])\n  k.box('structSteel', [0.03, 0.12, 0.008], [x, yc - h / 2 + 0.02, z - r - 0.004])\n  k.cyl('stainless', r, h, [x, yc, z], 'y', 20)\n  for (const s of [-1, 1]) {\n    k.cyl('stainless', r * 1.3, 0.02, [x, yc + s * (h / 2 + 0.01), z], 'y', 20)\n    k.boltCircle('boltXylan', 'nut', 6, r * 1.15, [x, yc + s * (h / 2 + 0.01) - 0.015, z], 'y', 0.006, 0.03, [0.005, 0.025])\n  }\n  for (const s of [-1, 1]) {\n    k.rod('stainless', [x, yc + s * h * 0.35, z], [x + r + 0.05, yc + s * h * 0.35, z], 0.01, 8)\n    k.cyl('flangeSteel', 0.018, 0.012, [x + r + 0.05, yc + s * h * 0.35, z], 'x', 12)\n  }\n  return { top: [x, yc + h / 2 + 0.02, z] as V3, bottom: [x, yc - h / 2 - 0.02, z] as V3 }\n}\n"
    }
  ],
  "categories": [
    "rotating"
  ],
  "meta": {
    "export": "CentrifugalFan",
    "anchor": "base",
    "standards": [
      "AMCA 99 (arrangement 9)",
      "ISO 13349",
      "IEC 60034 / 60072"
    ]
  }
}