# Centrifugal pump (API 610 OH2)

Overhung, centreline-mounted end-suction pump with volute casing, bearing housing, spacer coupling under a mesh guard and a TEFC motor on a drain-rim baseplate. Includes an API Plan 53A seal reservoir and a local control station. Motor frame from IEC 60072 for the rated power.

**Standards:** API 610 (OH2), API 682 Plan 53A, IEC 60034 / 60072, ASME B16.5

## Installation

```bash
npx -y https://decktwin.com/cli/deck-twin-0.2.2.tgz add centrifugal-pump
```

Copies `rotating/centrifugal-pump.tsx`, any helper files it imports and the shared `lib/` into `src/components/deck-twin/`. Needs `three`, `@react-three/fiber` and `@react-three/drei`.

## Usage

```tsx
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { Stage } from './components/deck-twin/lib/stage'
import { CentrifugalPump } from './components/deck-twin/rotating/centrifugal-pump'

export default function Scene() {
  return (
    <Canvas shadows camera={{ position: [8, 5, 9], fov: 35 }}>
      <Stage>
        <CentrifugalPump tag="P-101A" />
      </Stage>
      <OrbitControls makeDefault />
    </Canvas>
  )
}
```

## Props

| Prop | Type | Default | Range |
| --- | --- | --- | --- |
| `suctionNps` | `number (NPS, in)` | `6` |  |
| `dischargeNps` | `number (NPS, in)` | `4` |  |
| `rating` | `150 \| 300 \| 600 \| 900 \| 1500 \| 2500` | `300` |  |
| `kw` | `number (kW)` | `110` | 5 – 1500 |
| `stages` | `number` | `1` | 1 – 4 |
| `baseLength` | `number (m)` | `2.8` | 1.6 – 5 |
| `baseWidth` | `number (m)` | `1.1` | 0.7 – 2 |
| `running` | `boolean` | `true` |  |
| `tag` | `string` | `null` |  |
| `...group` | position, rotation, scale… |  |  |

## Ports

Flange faces in the component's local frame, metres. Origin: centre of the footprint at deck level (y = 0).

| Id | Label | Position | Direction | Size | Class |
| --- | --- | --- | --- | --- | --- |
| `suction` | Suction | 1.118, 0.730, 0.000 | 1, 0, 0 | 6" | 300 |
| `discharge` | Discharge | 0.532, 1.330, 0.000 | 0, 1, 0 | 4" | 300 |

## P&ID

Circle with the discharge leaving tangentially from the top, suction entering the centre from the side. Often a small triangle inside pointing to the discharge, and a base line or two short legs under it.

tag P · ISO 10628-2 / PIP PIC001

P&IDs rarely show the API 610 type. OH2, OH3, BB3 and VS pumps can all appear as this circle: read the datasheet or the title (e.g. "multistage", "vertical inline", "caisson"). A circle with a piston or crank inside is a reciprocating pump; an arrow through the symbol means variable capacity (metering pump).

API 610 centrifugal pump datasheet fields and the props they set:

| Field | Prop |
| --- | --- |
| Suction nozzle size | `suctionNps` |
| Discharge nozzle size | `dischargeNps` |
| Flange rating | `rating` |
| Driver rated power | `kw` |
| Number of stages | `stages` |
| Tag number | `tag` |

## Typical suppliers

Flowserve · Sulzer · KSB · Ruhrpumpen · Baker Hughes · ITT Goulds. The geometry is generic and sized from public standards. It is not vendor CAD.

## Source

`rotating/centrifugal-pump.tsx`. This is the component file only: it imports shared code, so install it with the command above rather than pasting it alone. https://decktwin.com/r/centrifugal-pump.json has every file.

```tsx
// API 610 OH2 centrifugal pump package: end suction, top discharge, flexible coupling, TEFC motor, drain-rim baseplate, Plan 53A seal pot.
import * as THREE from 'three'
import { useEffect, useMemo } from 'react'
import { Kit, sweepGeometry, type MatKey, type V3 } from '../lib/kit'
import { KitMesh, type GroupProps } from '../lib/mesh'
import { flange, gauge, motor, tagPlate } from '../lib/parts'
import { port, type Port } from '../lib/ports'
import { Rotors, cowlFan, guardedCoupling, type RotorSpec } from '../lib/rotors'
import { pipeOD, pipeWall, type Rating } from '../lib/standards'

export interface CentrifugalPumpProps {
  suctionNps?: number
  dischargeNps?: number
  rating?: Rating
  /** motor rated power, kW */
  kw?: number
  stages?: number
  baseLength?: number
  baseWidth?: number
  casing?: MatKey
  tag?: string
  /** spin the coupling and motor fan */
  running?: boolean
}

export const defaults = {
  suctionNps: 6,
  dischargeNps: 4,
  rating: 300 as Rating,
  kw: 110,
  stages: 1,
  baseLength: 2.8,
  baseWidth: 1.1,
  casing: 'machineryGreen' as MatKey,
  running: true,
}

const NECK = 0.25

function nozzleBases(p: Omit<typeof defaults, 'running'>) {
  const sy = 0.55 + 0.03 * p.suctionNps
  return {
    suction: { base: [p.baseLength * 0.31, sy, 0] as V3, nps: p.suctionNps },
    discharge: { base: [p.baseLength * 0.19, sy + 0.35, 0] as V3, nps: p.dischargeNps },
  }
}

export function ports(props: CentrifugalPumpProps = {}): Port[] {
  const p = { ...defaults, ...props }
  const { suction, discharge } = nozzleBases(p)
  return [
    port('suction', 'Suction', [suction.base[0] + NECK, suction.base[1], 0], [1, 0, 0], p.suctionNps, p.rating),
    port('discharge', 'Discharge', [discharge.base[0], discharge.base[1] + NECK, 0], [0, 1, 0], p.dischargeNps, p.rating),
  ]
}

export function CentrifugalPump({ tag, ...rest }: CentrifugalPumpProps & GroupProps) {
  const { suctionNps, dischargeNps, rating, kw, stages, baseLength, baseWidth, casing, running, ...group } = { ...defaults, ...rest }
  const p = { suctionNps, dischargeNps, rating, kw, stages, baseLength, baseWidth, casing }
  const { built, rotors, necks } = useMemo(() => {
    const out = build({ ...p, tag })
    const nk = new Kit()
    const { suction, discharge } = nozzleBases(p)
    for (const [n, dir] of [[suction, [1, 0, 0]], [discharge, [0, 1, 0]]] as const) {
      const face: V3 = [n.base[0] + dir[0] * NECK, n.base[1] + dir[1] * NECK, 0]
      const L = flange(nk, n.nps, rating, face, dir as unknown as V3, true)
      nk.pipe(casing, n.base, [face[0] - dir[0] * L, face[1] - dir[1] * L, 0], pipeOD(n.nps), pipeWall(n.nps, '80'))
    }
    return { ...out, necks: nk.build() }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [suctionNps, dischargeNps, rating, kw, stages, baseLength, baseWidth, casing, tag])
  useEffect(() => () => [built, necks, ...rotors.map((r) => r.built)].forEach((b) => b.geometry.dispose()), [built, necks, rotors])
  return (
    <group {...group}>
      <KitMesh data={built} />
      <KitMesh data={necks} />
      <Rotors rotors={rotors} running={running} />
    </group>
  )
}

export default CentrifugalPump

const BASE: MatKey = 'equipmentPaintDark'

const THE = Math.PI / 4

// Volute scroll in the YZ plane, centred on the shaft.
class ScrollCurve extends THREE.Curve<THREE.Vector3> {
  xc: number
  ys: number
  zc: number
  Rimp: number
  r0: number
  r1: number
  th0: number
  constructor(xc: number, ys: number, zc: number, Rimp: number, r0: number, r1: number, th0: number) {
    super()
    this.xc = xc
    this.ys = ys
    this.zc = zc
    this.Rimp = Rimp
    this.r0 = r0
    this.r1 = r1
    this.th0 = th0
  }
  r(t: number) {
    return Math.sqrt(this.r0 * this.r0 + (this.r1 * this.r1 - this.r0 * this.r0) * t)
  }
  getPoint(t: number, target = new THREE.Vector3()) {
    const th = THE - 2 * Math.PI + this.th0 + t * (2 * Math.PI - this.th0)
    const R = this.Rimp + this.r(t)
    return target.set(this.xc, this.ys + Math.sin(th) * R, this.zc + Math.cos(th) * R)
  }
}

function build(p: Omit<typeof defaults, 'running'> & { tag?: string }) {
  const k = new Kit()
  const BL = p.baseLength
  const BW = p.baseWidth
  const { suction, discharge } = nozzleBases(p)
  const C = p.casing
  const ys = suction.base[1]
  const xs = suction.base[0]
  const xc = discharge.base[0]
  const zc = 0
  const H = discharge.base[1] - ys
  const dNps = discharge.nps
  const sNps = suction.nps
  const hb = Math.min(0.3, ys - 0.3) // baseplate top height

  // volute casing
  const r1 = Math.max((pipeOD(dNps) / 2) * 1.08, 0.05)
  const r0 = r1 * 0.32
  const Rimp = Math.min(Math.max(0.5 * H, 0.1), 0.5)
  const Rend = Rimp + r1
  const scroll = new ScrollCurve(xc, ys, zc, Rimp, r0, r1, 0.5)
  k.add(C, sweepGeometry(scroll, (t) => [scroll.r(t), scroll.r(t) * 0.88], 72, 24, true))
  // throat: S-bend from the scroll end up into the discharge nozzle base
  const P0 = new THREE.Vector3(xc, ys + Math.sin(THE) * Rend, zc + Math.cos(THE) * Rend)
  const P3 = new THREE.Vector3(xc, ys + H + 0.02, zc)
  const rise = P3.y - P0.y
  const T0 = new THREE.Vector3(0, Math.cos(THE), -Math.sin(THE))
  const throat = new THREE.CubicBezierCurve3(P0, P0.clone().addScaledVector(T0, rise * 0.55), P3.clone().add(new THREE.Vector3(0, -rise * 0.5, 0)), P3)
  const rTop = (pipeOD(dNps) / 2) * 1.06
  k.add(C, sweepGeometry(throat, (t) => {
    const r = r1 + (rTop - r1) * t
    return [r, r * (0.88 + 0.12 * t)]
  }, 24, 24, false))
  // hub (impeller chamber) + front / back covers
  const wHub = r1 * 0.85
  const Rhub = Rimp + r0 * 1.2
  k.cyl(C, Rhub, wHub * 2, [xc, ys, zc], 'x', 40)
  // web joining throat to hub so there is no visual gap
  k.box(C, [wHub * 1.4, Rhub * 0.5, Rhub * 0.9], [xc, ys + Rhub * 0.75, zc + Rhub * 0.25])
  // discharge boss
  k.cyl(C, rTop * 1.12, 0.04, [xc, ys + H - 0.0, zc], 'y', 28)
  // cover joint flanges with studs (front and back)
  const Rj = Rhub + 0.035
  for (const s of [-1, 1]) {
    const xj = xc + s * wHub
    k.cyl(C, Rj, 0.03, [xj + s * 0.015, ys, zc], 'x', 40)
    k.boltCircle('boltXylan', 'nut', 12, Rj - 0.018, [xj - (s > 0 ? -0.0 : 0.05), ys, zc], 'x', 0.016, 0.05, s > 0 ? [0.045] : [0.005])
  }
  // casing vent (top) + drain (bottom) plugs
  k.hex('nut', 0.03, 0.02, [xc - wHub * 0.5, ys + Rhub + 0.005, zc - Rhub * 0.3])
  k.cyl(C, 0.025, 0.06, [xc, ys - Rend - r1 * 0.6, zc], 'y', 14)
  k.hex('nut', 0.034, 0.02, [xc, ys - Rend - r1 * 0.6 - 0.035, zc], 'y')

  // stage rings + suction bell
  const front = xc + wHub + 0.03
  let x = front
  const extra = Math.max(0, p.stages - 1)
  const sR = (pipeOD(sNps) / 2) * 1.08
  const avail = xs - front
  const ringW = extra ? Math.min(0.14, (avail * 0.6) / extra) : 0
  for (let i = 0; i < extra; i++) {
    k.lathe(C, [
      [Rhub * 0.85, x],
      [Rhub * 0.98, x + ringW * 0.15],
      [Rhub * 0.98, x + ringW * 0.85],
      [Rhub * 0.85, x + ringW],
      [Rhub * 0.4, x + ringW],
    ], [0, ys, zc], 'x', 36)
    x += ringW
  }
  if (extra) {
    // tie bolts across the stage rings
    k.around(6, Rhub * 0.98, [0, ys, zc], 'x', (p) => {
      k.rod('boltXylan', [front - 0.01, p[1], p[2]], [x + 0.03, p[1], p[2]], 0.009, 6)
      k.hex('nut', 0.03, 0.02, [x + 0.02, p[1], p[2]], 'x')
    }, Math.PI / 6)
  }
  // suction bell (lathe along +X) up to the suction nozzle base
  const bellEnd = Math.max(xs + 0.05, x + 0.06)
  k.lathe(C, [
    [Rhub * 0.8, x - 0.02],
    [Rhub * 0.72, x + 0.02],
    [sR * 1.15, x + (bellEnd - x) * 0.6],
    [sR, bellEnd],
    [0, bellEnd],
  ], [0, ys, zc], 'x', 36)

  // seal chamber + bearing frame
  const back = xc - wHub - 0.03
  const sealL = 0.14
  const sealR = Math.max(0.07, Rhub * 0.42)
  k.cyl(C, sealR, sealL, [back - sealL / 2, ys, zc], 'x', 32)
  // seal gland plate with 4 studs + plan 53 ports
  const xg = back - sealL - 0.015
  k.cyl('stainless', sealR * 1.15, 0.03, [xg, ys, zc], 'x', 28)
  k.boltCircle('boltXylan', 'nut', 4, sealR * 0.95, [xg - 0.035, ys, zc], 'x', 0.012, 0.06, [0.006], Math.PI / 4)
  const portIn: V3 = [xg, ys + sealR * 1.15 + 0.02, zc - 0.03]
  const portOut: V3 = [xg, ys + sealR * 1.15 + 0.02, zc + 0.03]
  for (const p of [portIn, portOut]) k.cyl('stainless', 0.011, 0.04, [p[0], p[1] - 0.02, p[2]], 'y', 8)
  // bearing bracket (lantern with windows) then bearing housing
  const brL = 0.18
  const xbr = xg - 0.015
  k.lathe(C, [
    [sealR * 1.1, xbr - brL],
    [Rhub * 0.55, xbr - brL * 0.1],
    [Rhub * 0.62, xbr],
  ], [0, ys, zc], 'x', 32)
  const bhL = Math.max(0.36, Rhub * 1.4)
  const bhR = Math.max(0.1, Rhub * 0.48)
  const xbh = xbr - brL
  k.lathe(C, [
    [bhR * 0.75, xbh - bhL],
    [bhR, xbh - bhL + 0.03],
    [bhR, xbh - 0.03],
    [bhR * 0.9, xbh],
  ], [0, ys, zc], 'x', 36)
  // cooling fins on bearing housing
  for (let i = 0; i < 6; i++) k.cyl(C, bhR * 1.12, 0.008, [xbh - bhL * 0.2 - i * bhL * 0.11, ys, zc], 'x', 32)
  // bearing end cover + shaft
  k.cyl(C, bhR * 0.7, 0.03, [xbh - bhL - 0.015, ys, zc], 'x', 28)
  // oil sight glass (bullseye), constant-level oiler, breather
  k.cyl('brass', bhR * 0.22, 0.012, [xbh - bhL * 0.5, ys - bhR * 0.35, zc + bhR * 0.96], 'z', 18)
  k.disc('glass', bhR * 0.17, [xbh - bhL * 0.5, ys - bhR * 0.35, zc + bhR * 0.96 + 0.0065], 'z', 18)
  k.rod('stainless', [xbh - bhL * 0.35, ys - bhR * 0.5, zc + bhR * 0.9], [xbh - bhL * 0.35, ys - bhR * 0.5, zc + bhR + 0.07], 0.008, 8)
  k.cyl('stainless', 0.03, 0.03, [xbh - bhL * 0.35, ys - bhR * 0.5 + 0.015, zc + bhR + 0.08], 'y', 16)
  k.cyl('glass', 0.032, 0.09, [xbh - bhL * 0.35, ys - bhR * 0.5 + 0.075, zc + bhR + 0.08], 'y', 16)
  k.dome('glass', 0.032, [xbh - bhL * 0.35, ys - bhR * 0.5 + 0.12, zc + bhR + 0.08], 'y', Math.PI / 2, 1)
  k.cyl('brass', 0.01, 0.04, [xbh - bhL * 0.6, ys + bhR + 0.02, zc], 'y', 8)
  k.sphere('brass', 0.018, [xbh - bhL * 0.6, ys + bhR + 0.045, zc], undefined, 10)
  // bearing-housing support foot (OH2)
  k.box(C, [0.05, ys - hb - bhR * 0.6, bhR * 1.2], [xbh - bhL * 0.55, (ys - bhR * 0.6 + hb) / 2, zc])
  k.box('flangeSteel', [0.12, 0.02, bhR * 1.5], [xbh - bhL * 0.55, hb + 0.01, zc])
  // vibration probes on bearing housing
  for (const a of [Math.PI / 4, (3 * Math.PI) / 4]) {
    const p: V3 = [xbh - bhL * 0.25, ys + Math.sin(a) * bhR, zc + Math.cos(a) * bhR]
    k.rod('stainless', p, [p[0], ys + Math.sin(a) * (bhR + 0.06), zc + Math.cos(a) * (bhR + 0.06)], 0.012, 10)
  }
  const shaftR = Math.max(0.025, bhR * 0.28)
  const xShaftEnd = xbh - bhL - 0.12
  k.cyl('stainless', shaftR, 0.12, [xShaftEnd + 0.06, ys, zc], 'x', 16)

  // centreline-mounted casing feet + pedestals
  const zFoot = Rend + r1 * 0.6
  for (const s of [-1, 1]) {
    k.box(C, [0.1, 0.05, zFoot - Rhub * 0.7], [xc, ys, zc + s * (Rhub * 0.7 + (zFoot - Rhub * 0.7) / 2)])
    k.box(C, [0.16, 0.035, 0.12], [xc, ys - 0.04, zc + s * zFoot])
    k.box(BASE, [0.14, ys - 0.06 - hb, 0.11], [xc, (ys - 0.06 + hb) / 2, zc + s * zFoot])
    k.box(BASE, [0.2, 0.02, 0.17], [xc, hb + 0.01, zc + s * zFoot])
    k.box('stainless', [0.17, 0.006, 0.13], [xc, ys - 0.06 + 0.003 - 0.0, zc + s * zFoot])
    for (const dx of [-0.045, 0.045]) k.hex('nut', 0.03, 0.02, [xc + dx, ys - 0.012, zc + s * zFoot])
  }

  // coupling + motor
  const coupL = 0.26
  const xCoupEnd = xShaftEnd
  const xMotorShaft = xCoupEnd - coupL
  const finFrac = 0.12
  let Rm = 0.12 + 0.03 * Math.pow(Math.max(p.kw, 1), 0.4)
  Rm = Math.min(Rm, (ys - hb - 0.04) / (1 + finFrac) - 0.02, BW / 2 - 0.12)
  const stub = 0.1 + Rm * 0.2
  const xm = xMotorShaft - stub + 0.02 // motor local origin (DE shield face)
  const cowl = Math.max(0.12, Rm * 0.6)
  const Lwant = 2.6 * Rm + 0.35
  const Lm = Math.max(0.5, Math.min(Lwant, xm - (-BL / 2 + 0.06) - cowl))
  k.at([xm, ys, zc], undefined, () => motor(k, { R: Rm, L: Lm, H: ys - hb, tbox: 'side', cable: true }))
  // motor sole plates (shims) visible under feet already; coupling guard with the coupling (and
  // shaft ends) turning inside it, and the motor's cooling fan behind the cowl grille, all on one shaft
  const rpm = 165
  const rotors: RotorSpec[] = [
    guardedCoupling(k, xMotorShaft - 0.02, xCoupEnd + 0.04, ys, Math.max(0.16, shaftR * 4.2), hb, 'safetyYellow', rpm, zc, Math.max(shaftR, 0.02, Rm * 0.13)),
    cowlFan(Rm, Lm, rpm, [xm, ys, zc]),
  ]

  // baseplate (drain-rim, grout holes, anchor bolts)
  const t = 0.012
  // bottom flange (anchor flange) wider than the frame
  k.box(BASE, [BL, 0.016, BW + 0.08], [0, 0.008, 0])
  // side + end channels
  for (const s of [-1, 1]) {
    k.box(BASE, [BL, hb - 0.016, t], [0, hb / 2 + 0.008, s * (BW / 2 - t / 2)])
    k.box(BASE, [t, hb - 0.016, BW], [s * (BL / 2 - t / 2), hb / 2 + 0.008, 0])
    // stiffeners on the side channels
    for (let i = 1; i < Math.round(BL / 0.45); i++) {
      const xx = -BL / 2 + (i * BL) / Math.round(BL / 0.45)
      k.box(BASE, [0.01, hb - 0.03, 0.04], [xx, hb / 2 + 0.008, s * (BW / 2 + 0.02)])
    }
  }
  // drain-rim deck: top plate slightly below the rim, rim lip around the edge
  k.box(BASE, [BL - 2 * t, 0.01, BW - 2 * t], [0, hb - 0.03, 0])
  for (const s of [-1, 1]) {
    k.box(BASE, [BL, 0.045, 0.02], [0, hb - 0.005, s * (BW / 2 - 0.01)])
    k.box(BASE, [0.02, 0.045, BW], [s * (BL / 2 - 0.01), hb - 0.005, 0])
  }
  // longitudinal sub-beams under pump/motor pads
  for (const s of [-1, 1]) k.box(BASE, [BL - 0.1, hb - 0.05, 0.12], [0, (hb - 0.05) / 2 + 0.016, s * BW * 0.28])
  // motor pads + jack-screw blocks
  for (const fx of [xm - Lm * 0.82, xm - Lm * 0.18])
    for (const s of [-1, 1]) {
      k.box('flangeSteel', [0.22, 0.025, 0.2], [fx, hb - 0.012, zc + s * Rm * 0.78])
      k.box(BASE, [0.04, 0.06, 0.06], [fx + 0.16, hb + 0.02, zc + s * Rm * 0.78])
      k.cyl('boltXylan', 0.008, 0.08, [fx + 0.14, hb + 0.03, zc + s * Rm * 0.78], 'x', 6)
    }
  // grout / vent holes along the centreline
  const nG = Math.max(2, Math.round(BL / 0.9))
  for (let i = 0; i < nG; i++) {
    const gx = -BL / 2 + (BL * (i + 0.5)) / nG
    for (const gz of [-BW * 0.12, BW * 0.12]) {
      k.torus(BASE, 0.05, 0.008, [gx, hb - 0.022, gz], 'y', 6, 24)
      k.disc('rubberBlack', 0.048, [gx, hb - 0.0245, gz], 'y', 24)
    }
  }
  // anchor bolts with washers + nuts along both long sides and ends
  const nA = Math.max(3, Math.round(BL / 0.7) + 1)
  for (let i = 0; i < nA; i++) {
    const ax = -BL / 2 + 0.08 + ((BL - 0.16) * i) / (nA - 1)
    for (const s of [-1, 1]) {
      const az = s * (BW / 2 + 0.035)
      k.cyl('stainless', 0.02, 0.004, [ax, 0.018, az], 'y', 16)
      k.hex('nut', 0.042, 0.024, [ax, 0.032, az])
      k.cyl('boltXylan', 0.011, 0.07, [ax, 0.05, az], 'y', 8)
    }
  }
  // lifting lugs at the 4 corners
  for (const sx of [-1, 1])
    for (const sz of [-1, 1]) {
      k.box(BASE, [0.1, 0.1, 0.02], [sx * (BL / 2 - 0.12), hb * 0.55, sz * (BW / 2 + 0.01)])
      k.torus('nut', 0.025, 0.006, [sx * (BL / 2 - 0.12), hb * 0.55 + 0.02, sz * (BW / 2 + 0.022)], 'z', 6, 14)
    }
  // drain connection at the low corner
  k.cyl(BASE, 0.022, 0.12, [BL / 2 + 0.05, hb - 0.04, -BW / 2 + 0.15], 'x', 12)
  k.hex('nut', 0.05, 0.03, [BL / 2 + 0.11, hb - 0.04, -BW / 2 + 0.15], 'x')
  // earth boss + tag sign
  k.cyl('brass', 0.015, 0.03, [-BL / 2 + 0.3, hb * 0.5, BW / 2 + 0.015], 'z', 10)
  if (p.tag) tagPlate(k, p.tag, [-BL / 2 + 0.6, hb * 0.5, BW / 2 + 0.002], 'z', 0.22)

  // API Plan 53A seal reservoir
  const px = Math.min(xbh - bhL * 0.2, xc - 0.25)
  const pz = -(BW / 2 - 0.12)
  const vy0 = Math.max(ys + 0.25, 0.95)
  const vH = 0.5
  const vr = 0.075
  k.rod('structSteel', [px, hb, pz], [px, vy0 - 0.05, pz], 0.025, 10)
  k.box('structSteel', [0.16, 0.012, 0.16], [px, hb + 0.006, pz])
  k.box('structSteel', [0.2, 0.012, 0.2], [px, vy0 - 0.05, pz])
  k.cyl('stainless', vr, vH, [px, vy0 + vH / 2, pz], 'y', 28)
  k.dome('stainless', vr, [px, vy0 + vH, pz], 'y', Math.PI / 2, 0.5)
  k.dome('stainless', vr, [px, vy0, pz], '-y', Math.PI / 2, 0.5)
  for (const s of [-1, 1]) k.box('structSteel', [0.03, 0.08, 0.012], [px + s * (vr + 0.012), vy0 - 0.02, pz])
  // level transmitter chamber on reservoir + PI gauge + N2 inlet + fill cap
  k.cyl('stainless', 0.018, vH * 0.7, [px + vr + 0.06, vy0 + vH / 2, pz], 'y', 12)
  for (const yy of [vy0 + vH * 0.2, vy0 + vH * 0.8]) k.rod('tubing', [px + vr * 0.9, yy, pz], [px + vr + 0.06, yy, pz], 0.006, 8)
  k.rbox('actuatorBlue', [0.06, 0.08, 0.06], 0.01, [px + vr + 0.06, vy0 + vH * 0.85 + 0.06, pz])
  k.cyl('stainless', 0.008, 0.08, [px, vy0 + vH + 0.07, pz], 'y', 8)
  gauge(k, [px, vy0 + vH + 0.14, pz], 'z', 0.08, 'bar', 60)
  k.cyl('brass', 0.016, 0.05, [px - 0.035, vy0 + vH + 0.02, pz], 'y', 10)
  k.rod('tubing', [px + 0.03, vy0 + vH + 0.02, pz], [px + 0.03, vy0 + vH + 0.1, pz + 0.1], 0.005, 6)
  // seal supply / return tubing between gland and reservoir
  k.tube('tubing', [[portIn[0], portIn[1], portIn[2]], [portIn[0], portIn[1] + 0.06, portIn[2]], [px, portIn[1] + 0.06, portIn[2]], [px, portIn[1] + 0.06, pz + vr + 0.08], [px, vy0 + 0.05, pz + vr + 0.08], [px, vy0 + 0.05, pz + vr * 0.7]], 0.006)
  k.tube('tubing', [[portOut[0], portOut[1], portOut[2]], [portOut[0], portOut[1] + 0.12, portOut[2]], [px + 0.04, portOut[1] + 0.12, portOut[2]], [px + 0.04, portOut[1] + 0.12, pz + vr + 0.12], [px + 0.04, vy0 + vH * 0.7, pz + vr + 0.12], [px + 0.04, vy0 + vH * 0.7, pz + vr * 0.6]], 0.006)

  // local control station
  const lx = -BL / 2 + 0.14
  const lz = BW / 2 - 0.14
  k.rod('structSteel', [lx, hb, lz], [lx, 1.45, lz], 0.03, 12)
  k.box('structSteel', [0.15, 0.012, 0.15], [lx, hb + 0.006, lz])
  const by = 1.2
  k.box('structSteel', [0.04, 0.3, 0.02], [lx, by, lz + 0.04])
  k.rbox('stainless', [0.24, 0.32, 0.13], 0.012, [lx, by, lz + 0.115])
  k.box('stainless', [0.3, 0.012, 0.2], [lx, by + 0.18, lz + 0.12], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0.15))
  const fz = lz + 0.18
  k.cyl('safetyGreen', 0.016, 0.02, [lx - 0.055, by + 0.05, fz], 'z', 16)
  k.cyl('actuatorRed', 0.016, 0.02, [lx + 0.055, by + 0.05, fz], 'z', 16)
  k.cyl('actuatorYellow', 0.04, 0.004, [lx, by - 0.07, fz - 0.002], 'z', 24)
  k.cyl('actuatorRed', 0.012, 0.03, [lx, by - 0.07, fz + 0.012], 'z', 12)
  k.dome('actuatorRed', 0.028, [lx, by - 0.07, fz + 0.025], 'z', Math.PI / 2, 0.6)
  gauge(k, [lx, by + 0.11, fz - 0.01], 'z', 0.06, 'bar', 600)
  k.hex('brass', 0.03, 0.02, [lx, by - 0.17, lz + 0.11])
  k.spline('cable', [[lx, by - 0.18, lz + 0.11], [lx, by - 0.35, lz + 0.06], [lx, hb + 0.2, lz + 0.04], [lx + 0.1, hb + 0.02, lz]], 0.008, 6, 24)

  return { built: k.build(), rotors }
}
```
