{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "utility-station",
  "type": "registry:component",
  "title": "Utility station",
  "description": "Deck utility (hose) station on an anchored SHS post with a galvanised backing panel: plant air, utility water and nitrogen drops fed from overhead flanges, each with an isolation ball valve, check valve and a different downward-pointing hose coupling (claw, cam-and-groove, dedicated threaded nitrogen) so hoses cannot be cross-connected. ISO 14726 bands, service signs, asphyxiant warning, hose racks with coiled hoses, optional impact bollards.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "piping/utility-station.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/piping/utility-station.tsx",
      "content": "// Deck utility (hose) station: post with backing panel, air / water / nitrogen drops fed from overhead, isolation ball valve, check valve and service-specific hose coupling pointing down, ISO 14726 bands, signage and hose racks.\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { member } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { SERVICE, pipeOD, type Service } from '../lib/standards'\nimport { textMaterial } from '../lib/textures'\nimport { flangeLength, portFlange, serviceBand, smallBallValve } from './_piping'\n\nexport interface UtilityStationProps {\n  air?: boolean\n  water?: boolean\n  nitrogen?: boolean\n  /** elevation of the supply flanges at the top of the drops, m */\n  supply?: number\n  tag?: string\n  /** yellow impact bollards in front */\n  bollards?: boolean\n}\n\nexport const defaults = { air: true, water: true, nitrogen: true, supply: 2.4, tag: 'US-01', bollards: false }\n\ntype Svc = 'air' | 'water' | 'nitrogen'\nconst SVC: { id: Svc; service: Service; label: string; x: number; y: number; nps: number }[] = [\n  { id: 'air', service: 'air', label: 'PLANT AIR', x: -0.17, y: 1.32, nps: 1 },\n  { id: 'water', service: 'water', label: 'UTILITY WATER', x: -0.05, y: 1.18, nps: 1 },\n  { id: 'nitrogen', service: 'nitrogen', label: 'NITROGEN', x: 0.07, y: 1.04, nps: 1 },\n]\nconst Z = 0.13\nconst RATING = 150\n\nexport function ports(p: UtilityStationProps = {}): Port[] {\n  const o = { ...defaults, ...p }\n  return SVC.filter((s) => o[s.id]).map((s) => port(s.id, `${s.label} supply`, [s.x, Math.max(o.supply, 2.1), Z], [0, 1, 0], s.nps, RATING, 'utility'))\n}\n\nexport function UtilityStation({ air = defaults.air, water = defaults.water, nitrogen = defaults.nitrogen, supply = defaults.supply, tag = defaults.tag, bollards = defaults.bollards, ...group }: UtilityStationProps & GroupProps) {\n  const data = useKit((k) => build(k, { air, water, nitrogen }, Math.max(supply, 2.1), tag, bollards), [air, water, nitrogen, supply, tag, bollards])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default UtilityStation\n\nfunction build(k: Kit, on: Record<Svc, boolean>, supply: number, tag: string, bollards: boolean) {\n  // SHS post on an anchored base plate, galvanised backing panel and header sign\n  const H = 2.0\n  k.box('galvanised', [0.3, 0.02, 0.3], [0, 0.01, 0])\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) {\n    k.hex('nut', 0.03, 0.016, [sx * 0.11, 0.028, sz * 0.11])\n    k.cyl('galvanised', 0.008, 0.04, [sx * 0.11, 0.035, sz * 0.11], 'y', 8)\n  }\n  member(k, [0, 0.02, 0], [0, H, 0], 'SHS 100x100x6.3', 'galvanised')\n  k.box('galvanised', [0.12, 0.01, 0.12], [0, H + 0.005, 0])\n  k.box('galvanised', [0.52, 1.1, 0.006], [0, 1.35, 0.053])\n  for (const y of [0.9, 1.8]) k.box('galvanised', [0.06, 0.04, 0.012], [0, y, 0.046])\n  k.plane(textMaterial(`UTILITY STATION ${tag}`, '#1d4f91', '#ffffff', 512, 96), 0.48, 0.09, [0, 0.86, 0.0565])\n\n  for (const s of SVC) {\n    if (!on[s.id]) continue\n    const band: MatKey = paint(SERVICE[s.service].band)\n    const d = pipeOD(s.nps)\n    const yF = supply - flangeLength(s.nps, RATING)\n    portFlange(k, s.nps, RATING, [s.x, supply, Z], [0, 1, 0])\n    // drop from the supply flange down to the valve\n    const yv = s.y + 0.22\n    const pipeMat: MatKey = 'stainless'\n    k.pipe(pipeMat, [s.x, yv + 0.06, Z], [s.x, yF, Z], d, 0.0034)\n    for (const y of [1.75, 1.15]) if (y < yF && y > yv) k.torus('galvanised', d / 2 + 0.006, 0.004, [s.x, y, Z - 0.002], 'y', 6, 16, Math.PI)\n    serviceBand(k, s.service, [s.x, Math.min(yF - 0.15, 1.95), Z], [0, 1, 0], d / 2)\n    // isolation valve (lever to the front), check valve, coupling pointing down\n    smallBallValve(k, s.nps, [s.x, yv, Z], [0, -1, 0], [0, 0, 1], s.id === 'nitrogen' ? 'safetyYellow' : 'actuatorRed')\n    k.cyl(pipeMat, d / 2, 0.05, [s.x, yv - 0.07, Z], 'y', 12)\n    k.hex(pipeMat, d * 1.45, 0.06, [s.x, yv - 0.12, Z])\n    k.cyl(pipeMat, d * 0.62, 0.03, [s.x, yv - 0.12, Z], 'y', 12)\n    coupling(k, s.id, [s.x, yv - 0.15, Z], d)\n    // service sign above the drop\n    k.plane(textMaterial(s.label, SERVICE[s.service].band, s.id === 'nitrogen' ? '#111111' : '#ffffff', 512, 128), 0.11, 0.04, [s.x, s.y - 0.07, 0.0565])\n    k.box(band, [0.11, 0.006, 0.002], [s.x, s.y - 0.093, 0.0565])\n  }\n  if (on.nitrogen) k.plane(textMaterial('N2 ASPHYXIANT', '#c8102e', '#ffffff', 512, 220), 0.11, 0.05, [0.195, 1.04, 0.0565])\n\n  // hose racks on both sides: arm, half-round saddle, coiled hose\n  for (const [sx, hose] of [[1, 'rubberBlack'], [-1, paint('#2f6f3a')]] as [number, MatKey][]) {\n    const x0 = sx * 0.05\n    const x1 = sx * 0.33\n    const y = 1.5\n    member(k, [x0, y, 0], [x1, y, 0], 'L60x60x6', 'galvanised')\n    k.tube('galvanised', Array.from({ length: 9 }, (_, i) => [x1, y - 0.12 * Math.sin((i / 8) * Math.PI), 0.12 * Math.cos((i / 8) * Math.PI)] as V3), 0.012, 8, 0.01)\n    for (let i = 0; i < 4; i++) k.torus(hose, 0.2, 0.012, [x1 + sx * (0.02 + i * 0.024), y - 0.2 + 0.0, 0], 'x', 8, 40)\n  }\n\n  if (bollards)\n    for (const x of [-0.45, 0.45]) {\n      k.cyl('safetyYellow', 0.057, 1.0, [x, 0.5, 0.7], 'y', 20)\n      k.dome('safetyYellow', 0.057, [x, 1.0, 0.7], 'y')\n      k.cyl('rubberBlack', 0.058, 0.05, [x, 0.8, 0.7], 'y', 20)\n      k.box('galvanised', [0.2, 0.015, 0.2], [x, 0.0075, 0.7])\n    }\n}\n\n/** Service-specific hose connection so air, water and nitrogen hoses cannot be cross-connected. */\nfunction coupling(k: Kit, id: Svc, top: V3, d: number) {\n  const [x, y, z] = top\n  if (id === 'air') {\n    // universal claw (Chicago) head: body, two lugs, safety-clip hole\n    k.cyl('brass', d * 0.55, 0.035, [x, y - 0.0175, z], 'y', 16)\n    for (const s of [-1, 1]) k.box('brass', [d * 0.4, 0.012, d * 0.28], [x + s * d * 0.6, y - 0.03, z])\n    k.cyl('rubberBlack', d * 0.6, 0.004, [x, y - 0.037, z], 'y', 16)\n  } else if (id === 'water') {\n    // cam-and-groove adapter with two cam arms\n    k.cyl('aluminium', d * 0.7, 0.045, [x, y - 0.0225, z], 'y', 20)\n    for (const s of [-1, 1]) {\n      k.box('aluminium', [0.006, 0.05, 0.012], [x + s * (d * 0.75), y - 0.015, z])\n      k.cyl('aluminium', 0.004, 0.02, [x + s * (d * 0.75), y - 0.038, z], 'z', 8)\n    }\n    k.lathe('pipeBore', [[d * 0.5, -0.045], [d * 0.5, 0]], [x, y, z], 'y', 16)\n  } else {\n    // dedicated threaded nitrogen connection with a hex nut and dust cap on a chain\n    k.cyl('brass', d * 0.45, 0.03, [x, y - 0.015, z], 'y', 12)\n    k.hex('brass', d * 1.25, 0.022, [x, y - 0.04, z])\n    k.cyl(paint('#2b2b2b'), d * 0.5, 0.018, [x, y - 0.06, z], 'y', 12)\n    k.tube('galvanised', [[x + d * 0.5, y - 0.06, z], [x + d * 0.9, y - 0.09, z], [x + d * 0.7, y - 0.02, z]], 0.0015, 4, 0.01)\n  }\n}\n"
    },
    {
      "path": "piping/_piping.ts",
      "type": "registry:file",
      "target": "components/deck-twin/piping/_piping.ts",
      "content": "// Shared piping geometry: port flanges with joint studs, B16.25 bevelled pipe, grid-swept surfaces, ISO 14726 bands, clad insulation, small valves.\nimport * as THREE from 'three'\nimport { mat4, type Kit, type MatKey, type Orient, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { textMaterial } from '../lib/textures'\nimport { GASKET_SPIRAL_WOUND, SERVICE, flange as flangeDims, pipeOD, pipeWall, studLength, type Rating, type Schedule, type Service } from '../lib/standards'\n\nexport const GAP = GASKET_SPIRAL_WOUND\n/** Radial segments for a size; every lathe and ring sheet of that size uses it so joints share vertices. */\nexport const SEG = (nps: number) => (nps >= 10 ? 64 : nps >= 4 ? 48 : 32)\nexport const add = (a: V3, d: V3, s = 1): V3 => [a[0] + d[0] * s, a[1] + d[1] * s, a[2] + d[2] * s]\nexport const neg = (d: V3): V3 => [-d[0], -d[1], -d[2]]\n\n/** Weld end to raised face of a WN flange. */\nexport function flangeLength(nps: number, rating: Rating) {\n  const f = flangeDims(nps, rating)\n  return f.hubLength + f.raisedFaceHeight\n}\n\nexport interface FlangeOpts {\n  /** draw the back half of the joint studs and nuts (default true) */\n  studs?: boolean\n  /** body material for integral cast / forged flanges; default a forged WN flange in flange steel */\n  mat?: MatKey\n  /** bore radius, m (default: Sch 40 / STD bore of the size) */\n  bore?: number\n}\n\n/**\n * Weld-neck (or integral) RF flange whose raised face is a port face pointing along `dir`: closed solid with the bore\n * open through, plus the back half of the joint studs and nuts exactly where the plant assembler draws its mating\n * joint. Returns weld-end-to-face length.\n */\nexport function portFlange(k: Kit, nps: number, rating: Rating, face: V3, dir: V3, o: FlangeOpts = {}) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const ri = Math.min(o.bore ?? ro - pipeWall(nps, 'STD'), ro - 0.0015)\n  const rf = f.raisedFaceHeight\n  const L = f.hubLength + rf\n  const back = L - rf - f.thickness\n  const hubR = Math.max(f.hubDia / 2, ro * 1.05)\n  const cast = o.mat !== undefined\n  const mat: MatKey = o.mat ?? 'flangeSteel'\n  const r0 = cast ? Math.max(ro * 1.08, ri + 0.004) : ro\n  const seg = SEG(nps)\n  const base = add(face, dir, -L)\n  k.lathe(mat, [[ri, 0], [r0, 0], [hubR, Math.max(back - 0.004, back * 0.7)], [hubR + 0.004, back], [f.od / 2, back], [f.od / 2, L - rf], [f.raisedFace / 2, L - rf]], base, dir, seg)\n  k.lathe('machined', [[f.raisedFace / 2, L - rf], [f.raisedFace / 2, L], [ri, L]], base, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], base, dir, seg)\n  if (o.studs !== false) {\n    const half = studLength(nps, rating) / 2\n    k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(face, dir, GAP / 2 - half), dir, f.boltDia, half, [f.boltDia * 0.6])\n  }\n  return L\n}\n\n/** Bore radius of a pipe size and schedule. */\nexport const boreOf = (nps: number, schedule: Schedule) => pipeOD(nps) / 2 - pipeWall(nps, schedule)\n\n/** Full joint stud set (studs + two nuts each) centred on the gasket plane at `centre`, as the assembler draws it. */\nexport function jointStuds(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  k.boltCircle('boltXylan', 'nut', f.bolts, f.boltCircle / 2, add(centre, dir, -studL / 2), dir, f.boltDia, studL, [f.boltDia * 0.6, studL - f.boltDia * 0.6])\n}\n\nconst BEVEL = Math.tan((37.5 * Math.PI) / 180)\nexport const LAND = 0.0016\n\n/** Axial depth of an ASME B16.25 37.5° weld bevel with a 1.6 mm land. */\nexport const bevelDepth = (nps: number, schedule: Schedule) => Math.max(0, pipeWall(nps, schedule) - LAND) * BEVEL\n\n/** Straight pipe from `start` along `dir` for length L. Ends: 'bevel' (machined B16.25 prep), 'square' (closed wall end) or 'open' (no end face, for joints). */\nexport type PipeEnd = 'bevel' | 'square' | 'open'\nexport function straight(k: Kit, mat: MatKey, start: V3, dir: V3, L: number, nps: number, schedule: Schedule, endA: PipeEnd = 'square', endB: PipeEnd = 'square', seg = SEG(nps)) {\n  if (L <= 1e-5) return\n  const ro = pipeOD(nps) / 2\n  const ri = ro - pipeWall(nps, schedule)\n  const h = Math.min(bevelDepth(nps, schedule), L / 3)\n  const hA = endA === 'bevel' ? h : 0\n  const hB = endB === 'bevel' ? h : 0\n  k.lathe(mat, [[ro, hA], [ro, L - hB]], start, dir, seg)\n  if (hA) k.lathe('machined', [[ri, 0], [ri + LAND, 0], [ro, hA]], start, dir, seg)\n  else if (endA === 'square') k.lathe(mat, [[ri, 0], [ro, 0]], start, dir, seg)\n  if (hB) k.lathe('machined', [[ro, L - hB], [ri + LAND, L], [ri, L]], start, dir, seg)\n  else if (endB === 'square') k.lathe(mat, [[ro, L], [ri, L]], start, dir, seg)\n  k.lathe('pipeBore', [[ri, L], [ri, 0]], start, dir, seg)\n}\n\n/**\n * Quad mesh from rows of points P[i][j]. With `normal`, normals are analytic and the winding follows them;\n * otherwise `out` (a rough outward direction) picks the winding and normals are computed.\n */\nexport function sheet(k: Kit, mat: MatKey, P: V3[][], normal?: (i: number, j: number) => V3, out?: (i: number, j: number) => V3) {\n  const nr = P.length\n  const nc = P[0].length\n  const pos: number[] = []\n  const nor: number[] = []\n  for (let i = 0; i < nr; i++)\n    for (let j = 0; j < nc; j++) {\n      pos.push(...P[i][j])\n      if (normal) nor.push(...normal(i, j))\n    }\n  const V = (q: V3) => new THREE.Vector3(...q)\n  let sign = 0\n  for (let i = 0; i < nr - 1 && !sign; i++)\n    for (let j = 0; j < nc - 1 && !sign; j++) {\n      const a = V(P[i][j])\n      const n = V(P[i + 1][j]).sub(a).cross(V(P[i][j + 1]).sub(a))\n      if (n.lengthSq() < 1e-14) continue\n      const ref = normal?.(i, j) ?? out?.(i, j)\n      sign = !ref || n.dot(V(ref)) >= 0 ? 1 : -1\n    }\n  const idx: number[] = []\n  for (let i = 0; i < nr - 1; i++)\n    for (let j = 0; j < nc - 1; j++) {\n      const a = i * nc + j\n      const b = a + nc\n      const c = a + 1\n      const d = b + 1\n      if (sign >= 0) idx.push(a, b, c, b, d, c)\n      else idx.push(a, c, b, b, c, d)\n    }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  if (normal) g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n  g.setIndex(idx)\n  if (!normal) g.computeVertexNormals()\n  k.add(mat, g)\n}\n\n/** Circumferential butt weld cap: slightly proud bead. */\nexport function buttWeld(k: Kit, nps: number, pos: V3, dir: V3) {\n  const r = pipeOD(nps) / 2\n  const w = Math.max(0.006, Math.min(0.02, pipeWall(nps, 'STD') * 1.6))\n  k.torus('flangeSteel', r + 0.0006, w / 2, pos, dir, 6, nps >= 6 ? 48 : 32)\n}\n\n/**\n * Surface swept as a grid: f(s, phi) returns a local point (s along +Y from 0 to 1, phi around); x = .. sin(phi), z = .. cos(phi)\n * keeps the same handedness as a lathe so normals face out. Placed at `pos` with local +Y on `axis`.\n */\nexport function gridSurface(k: Kit, mat: MatKey, f: (s: number, phi: number) => V3, nu: number, nv: number, pos: V3 = [0, 0, 0], axis: Orient = 'y', inward = false) {\n  const p: number[] = []\n  const uv: number[] = []\n  const idx: number[] = []\n  for (let i = 0; i <= nu; i++)\n    for (let j = 0; j <= nv; j++) {\n      const q = f(i / nu, (j / nv) * Math.PI * 2)\n      p.push(q[0], q[1], q[2])\n      uv.push(j / nv, i / nu)\n    }\n  for (let i = 0; i < nu; i++)\n    for (let j = 0; j < nv; j++) {\n      const a = i * (nv + 1) + j\n      const b = a + nv + 1\n      if (inward) idx.push(a, b, a + 1, b, b + 1, a + 1)\n      else idx.push(a, a + 1, b, b, a + 1, b + 1)\n    }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(p, 3))\n  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))\n  g.setIndex(idx)\n  g.computeVertexNormals()\n  // weld the seam normals (phi = 0 and 2π are duplicate vertices)\n  const n = g.getAttribute('normal') as THREE.BufferAttribute\n  const t = new THREE.Vector3()\n  for (let i = 0; i <= nu; i++) {\n    const a = i * (nv + 1)\n    const b = a + nv\n    t.set(n.getX(a) + n.getX(b), n.getY(a) + n.getY(b), n.getZ(a) + n.getZ(b)).normalize()\n    n.setXYZ(a, t.x, t.y, t.z)\n    n.setXYZ(b, t.x, t.y, t.z)\n  }\n  k.add(mat, g, mat4(pos, axis))\n}\n\n/** ISO 14726 identification band (basic colour) around a pipe of outer radius r. */\nexport function serviceBand(k: Kit, service: Service, pos: V3, dir: V3, r: number) {\n  const w = Math.min(0.15, Math.max(0.05, r * 1.6 + 0.02))\n  k.cyl(paint(SERVICE[service].band), r + 0.0012, w, pos, dir, 40, undefined, true)\n  return w\n}\n\n/** typical: insulation thickness for hot / personnel-protection service, m. */\nexport const insulationThickness = (nps: number) => (nps <= 2 ? 0.04 : nps <= 6 ? 0.05 : nps <= 12 ? 0.065 : 0.075)\n\n/**\n * Aluminium-jacketed insulation from `start` along `dir` for length L on pipe NPS `nps`: bevelled weather end caps,\n * 0.9 m sheet laps and stainless bands at 229 mm. Returns the jacket radius.\n */\nexport function cladding(k: Kit, start: V3, dir: V3, L: number, nps: number) {\n  const ro = pipeOD(nps) / 2\n  const R = ro + insulationThickness(nps)\n  const cap = Math.min(insulationThickness(nps) * 0.9, L / 4)\n  if (L < 2 * cap + 0.02) return R\n  k.lathe('insulation', [[ro + 0.001, 0], [R, cap], [R, L - cap], [ro + 0.001, L]], start, dir, 48)\n  const at = (s: number) => add(start, dir, s)\n  for (let s = 0.9; s < L - cap - 0.05; s += 0.9) k.cyl('insulation', R + 0.0009, 0.05, at(s), dir, 48, undefined, true)\n  // bands at most 229 mm (9 in) on centre\n  const n = Math.max(1, Math.ceil((L - 2 * cap) / 0.229))\n  for (let i = 0; i <= n; i++) {\n    const s = cap + 0.04 + ((L - 2 * cap - 0.08) * i) / n\n    k.cyl('stainless', R + 0.0016, 0.019, at(s), dir, 48, undefined, true)\n  }\n  return R\n}\n\n/** Small quarter-turn ball valve centred at `pos`, bore along `dir`, lever lying along `lever` (unit, not parallel to dir). */\nexport function smallBallValve(k: Kit, nps: number, pos: V3, dir: V3, lever: V3, leverMat: MatKey = 'actuatorRed', bodyMat: MatKey = 'stainless') {\n  const d = pipeOD(nps)\n  const L = d * 2.6\n  const D = new THREE.Vector3(...dir).normalize()\n  const up = new THREE.Vector3(...lever).normalize()\n  const stem = new THREE.Vector3().crossVectors(up, D).normalize()\n  const S = stem.toArray() as V3\n  k.cyl(bodyMat, d * 0.62, L * 0.55, pos, dir, 16)\n  k.sphere(bodyMat, d * 0.85, pos, undefined, 16)\n  for (const s of [-1, 1]) k.hex(bodyMat, d * 1.5, L * 0.22, add(pos, dir, (s * L) / 2 - s * L * 0.11), dir)\n  const top = add(pos, S, d * 1.15)\n  k.rod(bodyMat, add(pos, S, d * 0.6), top, d * 0.18, 8)\n  k.hex('nut', d * 0.5, d * 0.25, top, S)\n  const ll = Math.max(0.1, d * 4.5)\n  const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(up, stem, up.clone().cross(stem)))\n  k.box(leverMat, [ll, d * 0.12, d * 0.4], add(add(top, S, d * 0.18), [up.x, up.y, up.z], ll / 2 - d * 0.2), q)\n  return L\n}\n\n/** ASME B16.20 spiral-wound gasket (approx dims): inner ring, 316L / graphite winding, colour-coded centring ring against the studs. */\nexport function spiralWound(k: Kit, nps: number, rating: Rating, centre: V3, dir: V3) {\n  const f = flangeDims(nps, rating)\n  const ro = pipeOD(nps) / 2\n  const wi = ro + 0.003\n  const wo = f.raisedFace / 2 - 0.0015\n  const co = f.boltCircle / 2 - f.boltDia / 2 - 0.001\n  const t = 0.0032\n  const ring = (mat: MatKey, r0: number, r1: number, h: number) => k.lathe(mat, [[r0, -h / 2], [r1, -h / 2], [r1, h / 2], [r0, h / 2], [r0, -h / 2]], centre, dir, 48)\n  ring('stainless', ro * 0.94, wi, t)\n  ring('gasket', wi, wo, GAP)\n  ring('galvanised', wo, co, t)\n  // periphery colour code: continuous green (316L winding) with grey stripes (flexible graphite filler)\n  k.cyl(paint('#2f8f46'), co + 0.0004, t * 0.9, centre, dir, 48, undefined, true)\n  const d = new THREE.Vector3(...dir).normalize()\n  const ref = Math.abs(d.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)\n  const u = new THREE.Vector3().crossVectors(d, ref).normalize()\n  const w = new THREE.Vector3().crossVectors(d, u)\n  for (let i = 0; i < 4; i++) {\n    const a = (i / 4) * Math.PI * 2 + 0.4\n    const p = new THREE.Vector3(...centre).addScaledVector(u, Math.cos(a) * (co + 0.0008)).addScaledVector(w, Math.sin(a) * (co + 0.0008))\n    const q = new THREE.Quaternion().setFromRotationMatrix(new THREE.Matrix4().makeBasis(d, u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)), d.clone().cross(u.clone().multiplyScalar(Math.cos(a)).addScaledVector(w, Math.sin(a)))))\n    k.box(paint('#5a5d60'), [t * 0.95, 0.001, 0.012], p.toArray() as V3, q)\n  }\n}\n\n/** Laminated joint tag cable-tied to the outermost stud on the +Z side, hanging past the nut. */\nexport function jointTag(k: Kit, nps: number, rating: Rating, tag: string, studL = studLength(nps, rating)) {\n  const f = flangeDims(nps, rating)\n  let best = 0\n  let bz = -1\n  for (let i = 0; i < f.bolts; i++) {\n    const a = Math.PI / f.bolts + (i / f.bolts) * Math.PI * 2\n    if (Math.cos(a) > bz) [bz, best] = [Math.cos(a), a]\n  }\n  const R = f.boltCircle / 2\n  const y = -Math.sin(best) * R\n  const z = Math.cos(best) * R\n  const x = studL / 2 - f.boltDia * 0.1\n  k.torus('rubberBlack', f.boltDia * 0.62, 0.0012, [x, y, z], 'x', 4, 16)\n  const q = new THREE.Quaternion()\n  const p: V3 = [x + 0.001, y - f.boltDia * 0.6 - 0.025, z + f.boltDia * 0.2]\n  k.box('plasticGrey', [0.0015, 0.05, 0.034], p, q)\n  k.plane(textMaterial(tag, '#f4c400', '#111111', 256, 128), 0.032, 0.046, [p[0] + 0.0009, p[1], p[2]], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2))\n}\n\nexport interface Ring {\n  c: V3\n  e1: V3\n  e2: V3\n  r: number\n}\n\n/** Sheet through rings: point = c + r (cos a e1 + sin a e2) for each angle a. Radial normals, or computed with `out` picking the winding. */\nexport function ringSheet(k: Kit, mat: MatKey, rings: Ring[], angles: number[], normals: 'out' | 'in' | 'computed', out?: V3) {\n  const P = rings.map((g) => angles.map((a) => [0, 1, 2].map((i) => g.c[i] + g.r * (Math.cos(a) * g.e1[i] + Math.sin(a) * g.e2[i])) as V3))\n  const rad = (i: number, j: number): V3 => {\n    const g = rings[i]\n    const a = angles[j]\n    const sg = normals === 'in' ? -1 : 1\n    return [0, 1, 2].map((q) => sg * (Math.cos(a) * g.e1[q] + Math.sin(a) * g.e2[q])) as V3\n  }\n  if (normals === 'computed') sheet(k, mat, P, undefined, (i, j) => (out ? add(out, rad(i, j), 0.3) : rad(i, j)))\n  else sheet(k, mat, P, rad)\n}\n\n/** Uniform angles 0…2π for n segments. */\nexport const turn = (n: number) => Array.from({ length: n + 1 }, (_, j) => (j / n) * Math.PI * 2)\n\n/**\n * B16.25 weld prep on a ring-sampled end: bevel from the outer skin at `inner` (h back from the face) to the land at\n * the face `c`, then the land down to the bore. t points out of the end.\n */\nexport function bevelEnd(k: Kit, c: V3, t: V3, e1: V3, e2: V3, ro: number, ri: number, h: number, angles: number[]) {\n  const back = add(c, t, -h)\n  ringSheet(k, 'machined', [{ c: back, e1, e2, r: ro }, { c, e1, e2, r: ri + LAND }], angles, 'computed', t)\n  ringSheet(k, 'machined', [{ c, e1, e2, r: ri + LAND }, { c, e1, e2, r: ri }], angles, 'computed', t)\n}\n\nexport interface Hole {\n  /** half-width of the hole edge across the shell (z) */\n  A: number\n  /** half-length of the hole edge along the shell (x) */\n  B: number\n}\n\nconst NH = 24\n/** Angles shared by holed shells and the nozzles that fit them (2·NH + 1 samples from -π/2). */\nexport const nozzleAngles = Array.from({ length: 2 * NH + 1 }, (_, j) => -Math.PI / 2 + (j * Math.PI) / NH)\nconst ysq = (R: number, z: number) => Math.sqrt(Math.max(R * R - z * z, 0))\n\n/**\n * Cylinder of radius R along local X from x0 to x1 with optional holes centred at x = 0 on +Y (top) and -Y (bottom).\n * Hole edge: (B cos φ, ±√(R² − z²), z = ±A sin φ). Points are (x, R cos θ, R sin θ); returns the row angles θ.\n */\nexport function holedShell(k: Kit, mat: MatKey, R: number, x0: number, x1: number, top: Hole | null, bottom: Hole | null, outward: boolean) {\n  const rows: { th: number; xb: number }[] = []\n  const band = (h: Hole | null, c: number) => {\n    if (!h) return void rows.push({ th: c, xb: 0 })\n    for (let j = 0; j <= NH; j++) rows.push({ th: c + Math.asin((h.A * Math.sin(nozzleAngles[j])) / R), xb: h.B * Math.cos(nozzleAngles[j]) })\n  }\n  const fill = (a: number, b: number, n: number) => {\n    for (let i = 1; i < n; i++) rows.push({ th: a + ((b - a) * i) / n, xb: 0 })\n  }\n  const t1 = top ? Math.asin(Math.min(top.A / R, 1)) : 0\n  const t2 = bottom ? Math.asin(Math.min(bottom.A / R, 1)) : 0\n  band(top, 0)\n  fill(t1, Math.PI - t2, 20)\n  band(bottom, Math.PI)\n  fill(Math.PI + t2, 2 * Math.PI - t1, 20)\n  rows.push({ ...rows[0], th: rows[0].th + 2 * Math.PI })\n  const pt = (x: number, th: number): V3 => [x, R * Math.cos(th), R * Math.sin(th)]\n  const sg = outward ? 1 : -1\n  const n = (i: number): V3 => [0, sg * Math.cos(rows[i].th), sg * Math.sin(rows[i].th)]\n  sheet(k, mat, rows.map((r) => [pt(Math.max(r.xb, 0), r.th), pt(x1, r.th)]), (i) => n(i))\n  sheet(k, mat, rows.map((r) => [pt(x0, r.th), pt(-Math.max(r.xb, 0), r.th)]), (i) => n(i))\n  return rows.map((r) => r.th)\n}\n\n/** Nozzle skin of radius r (= hole A = B) from its intersection with a shell of radius R out to |y| = yEnd, on side ±1 of a holedShell. */\nexport function nozzleSkin(k: Kit, mat: MatKey, R: number, r: number, side: 1 | -1, yEnd: number, outward: boolean) {\n  const base = nozzleAngles.map((f) => [r * Math.cos(f), side * ysq(R, r * Math.sin(f)), side * r * Math.sin(f)] as V3)\n  const end = nozzleAngles.map((f) => [r * Math.cos(f), side * yEnd, side * r * Math.sin(f)] as V3)\n  const sg = outward ? 1 : -1\n  sheet(k, mat, [base, end], (_, j) => [sg * Math.cos(nozzleAngles[j]), 0, sg * side * Math.sin(nozzleAngles[j])])\n}\n"
    }
  ],
  "categories": [
    "piping"
  ],
  "meta": {
    "export": "UtilityStation",
    "anchor": "base",
    "standards": [
      "ISO 14726",
      "ISO 3864",
      "ASME B31.3",
      "NORSOK L-002"
    ]
  }
}