{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pipeline-valve-station",
  "type": "registry:component",
  "title": "Pipeline block-valve station",
  "description": "Mainline block-valve station on a buried pipeline. A fully welded, full-bore trunnion ball valve sits in the trench on a support pad, with a stem extension and extended vent, drain and sealant lines up to a concrete pad, topped by a gas-over-oil (twin gas/oil tanks, scotch-yoke, manual pump) or electric actuator. Above grade: bypass with two block valves, blowdown risers with valves to a common stack and silencer, pressure transmitters, RTU kiosk with solar panel, CP test post, marker posts and a chain-link compound with a double gate. `cutaway` leaves the trench open; `cover` sets the depth of cover; ports are flanged pup ends at the trench limits.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json",
    "https://deck-twin-library.demoareeb.workers.dev/r/gate-valve.json"
  ],
  "files": [
    {
      "path": "onshore/pipeline-valve-station.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/onshore/pipeline-valve-station.tsx",
      "content": "// Buried pipeline block-valve station: full-bore ball valve with stem extension and actuator, bypass, blowdown stack with silencer, fenced compound. ASME B31.8, API 6D.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { flange, gasket, gauge, junctionBox, member, pipeRun, tagPlate } from '../lib/parts'\nimport { paint } from '../lib/materials'\nimport { port, type Port } from '../lib/ports'\nimport { faceToFace, pipeOD, pipeWall, type Rating } from '../lib/standards'\nimport { signMaterial, textMaterial } from '../lib/textures'\nimport { GateValve } from '../valves/gate-valve'\nimport { fenceRun, swingGate } from './_fence'\nimport { basePlate, clamp, earthBond, plinth } from './_onshore'\n\nexport interface PipelineValveStationProps {\n  /** mainline size, inches */\n  nps?: number\n  rating?: Rating\n  actuator?: 'gas-over-oil' | 'electric'\n  /** cover over the pipe crown, m */\n  cover?: number\n  /** leave the trench open to show the buried pipe */\n  cutaway?: boolean\n  /** mainline valve, 0 closed … 1 open */\n  open?: number\n  tag?: string\n}\n\nexport const defaults = { nps: 24, rating: 600 as Rating, actuator: 'gas-over-oil' as 'gas-over-oil' | 'electric', cover: 1.2, cutaway: true, open: 1, tag: '' }\n\nconst LP = 7 // trench limit, port faces\nconst BYPASS: [number, number][] = [[8, 2], [12, 3], [16, 4], [24, 6], [36, 8], [48, 10]]\nconst COAT = () => paint('#2f5d50', { metalness: 0.1, roughness: 0.55, clearcoat: 0.3, weather: 0.4 })\nconst YELLOW = () => paint('#c9a227', { metalness: 0.2, roughness: 0.45, clearcoat: 0.3 })\n\nfunction layout(p: typeof defaults) {\n  const nps = p.nps\n  const od = pipeOD(nps)\n  const cover = clamp(p.cover, 0.6, 3)\n  const yc = -(cover + od / 2)\n  const tw = od + 0.6\n  const yb = yc - od / 2 - 0.15\n  const nb = BYPASS.find(([m]) => nps <= m)?.[1] ?? 12\n  const s = clamp(nps / 24, 0.6, 1.8)\n  return { nps, od, cover, yc, tw, yb, nb, s, ys: yb - 0.5 }\n}\n\nexport function ports(props: PipelineValveStationProps = {}): Port[] {\n  const p = { ...defaults, ...props }\n  const g = layout(p)\n  return [port('upstream', 'Upstream pipeline', [-LP, g.yc, 0], [-1, 0, 0], p.nps, p.rating), port('downstream', 'Downstream pipeline', [LP, g.yc, 0], [1, 0, 0], p.nps, p.rating)]\n}\n\nexport function PipelineValveStation(props: PipelineValveStationProps & GroupProps) {\n  const { nps, rating, actuator, cover, cutaway, open, tag, ...group } = { ...defaults, ...props }\n  const p = { nps, rating, actuator, cover, cutaway, open, tag }\n  const g = layout(p)\n  const data = useKit((k) => build(k, p, g), [nps, rating, actuator, cover, cutaway, tag])\n  const ball = useKit((k) => ballCore(k, g), [nps])\n  const pointer = useKit((k) => {\n    k.box('actuatorRed', [0.16, 0.02, 0.03], [0.03, 0, 0])\n    k.cyl('actuatorRed', 0.02, 0.025, [0, 0, 0], 'y', 10)\n  }, [])\n  const ya = 0.5 * g.s + 0.35\n  const yInd = actuator === 'gas-over-oil' ? ya + 0.49 * g.s : ya + 0.75 * g.s + 0.04\n  const yv = 0.9\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n      <KitMesh data={ball} position={[0, g.yc, 0]} rotation-y={(1 - clamp(open, 0, 1)) * (Math.PI / 2)} />\n      <KitMesh data={pointer} position={[0, yInd, 0]} rotation-y={(1 - clamp(open, 0, 1)) * (Math.PI / 2)} />\n      {[-1, 1].map((sx) => (\n        <GateValve key={'b' + sx} nps={g.nb} rating={rating} operator={g.nb >= 6 ? 'gear' : 'handwheel'} open={0} position={[sx * 1.3, yv, -1.6]} tag={`HV-${sx < 0 ? 'U' : 'D'}1`} />\n      ))}\n      {[-1, 1].map((sx) => (\n        <GateValve key={'d' + sx} nps={g.nb} rating={rating} operator={g.nb >= 6 ? 'gear' : 'handwheel'} open={0} position={[sx * 2.4, yv, -3.2]} tag={`BDV-${sx < 0 ? 'U' : 'D'}1`} />\n      ))}\n    </group>\n  )\n}\n\nexport default PipelineValveStation\n\ntype P = typeof defaults\ntype G = ReturnType<typeof layout>\nconst soilM = () => paint('#6e5238', { metalness: 0, roughness: 0.97, clearcoat: 0, weather: 1.4 })\nconst topsoil = () => paint('#5a4630', { metalness: 0, roughness: 1, clearcoat: 0, weather: 1.2 })\nconst sand = () => paint('#c8b48a', { metalness: 0, roughness: 1, clearcoat: 0, weather: 0.8 })\nconst gravel = () => paint('#a29d92', { metalness: 0, roughness: 1, clearcoat: 0, weather: 1.5 })\n\n/** Ported ball seen down the bore; turns with `open`. */\nfunction ballCore(k: Kit, g: G) {\n  const r = g.od * 0.62\n  k.lathe('stainless', [[r * 0.98, -r * 0.8], [r, -r * 0.5], [r, r * 0.5], [r * 0.98, r * 0.8]].map(([a, b]) => [a, b] as [number, number]), [0, 0, 0], 'x', 32)\n  k.cyl('pipeBore', g.od * 0.47, r * 1.6, [0, 0, 0], 'x', 32, undefined, true)\n}\n\nfunction valveEnds(k: Kit, nps: number, rating: Rating, x: number, y: number, z: number, L: number) {\n  // mating flanges, gaskets and studs either side of an inline valve centred on x; returns the weld ends\n  const ends: number[] = []\n  for (const s of [-1, 1]) {\n    const face = x + s * (L / 2 + 0.0045)\n    const hub = flange(k, nps, rating, [face, y, z], s < 0 ? 'x' : '-x', true)\n    gasket(k, nps, rating, [x + s * (L / 2 + 0.00225), y, z], 'x')\n    ends.push(face + s * hub)\n  }\n  return ends\n}\n\nfunction build(k: Kit, p: P, g: G) {\n  const { od, yc, tw, yb, ys, nb, s } = g\n  const nbOd = pipeOD(nb)\n  const coat = COAT()\n  // ground: soil either side of the trench, sand bedding, backfill unless cut away\n  const z0 = -5.2\n  const z1 = 3.4\n  const soil = soilM()\n  // with the cut-away the earth in front of the trench is ghosted so the buried pipe and valve show through\n  const ghost = paint('#6a4c30', { metalness: 0, roughness: 1, clearcoat: 0, weather: 0, transparent: true, opacity: 0.22 })\n  for (const [za, zb] of [[z0, -tw / 2], [tw / 2, z1]]) {\n    const front = za > 0 && p.cutaway\n    k.box(front ? ghost : soil, [2 * LP, -ys - 0.25, zb - za], [0, (ys - 0.25) / 2, (za + zb) / 2])\n    k.box(front ? ghost : topsoil(), [2 * LP, 0.25, zb - za], [0, -0.125, (za + zb) / 2])\n  }\n  k.box(soil, [2 * LP, yb - ys, tw], [0, (yb + ys) / 2, 0])\n  const ySand = p.cutaway ? yc - od / 2 : yc + od * 0.3\n  k.box(sand(), [2 * LP, ySand - yb, tw], [0, (ySand + yb) / 2, 0])\n  if (!p.cutaway) k.box(soil, [2 * LP, -(yc + od * 0.3), tw], [0, (yc + od * 0.3) / 2, 0])\n  for (const [za, zb] of p.cutaway ? [[-4.6, -tw / 2], [tw / 2, 2.6]] : [[-4.6, 2.6]]) k.box(gravel(), [12.4, 0.04, zb - za], [0, 0.02, (za + zb) / 2])\n\n  // mainline: flanged pup ends at the trench limits, 3LPE-coated pipe, welded ball valve body\n  const Lb = Math.max(faceToFace('ball-full', g.nps, p.rating), od * 2.2)\n  const Rb = od * 0.82\n  for (const sx of [-1, 1]) {\n    const hub = flange(k, g.nps, p.rating, [sx * LP, yc, 0], sx < 0 ? '-x' : 'x', true)\n    k.pipe(coat, [sx * (LP - hub), yc, 0], [sx * (Lb / 2), yc, 0], od, pipeWall(g.nps, 'XS'))\n    k.lathe(coat, [[od / 2, 0], [od * 0.6, od * 0.18], [Rb * 0.95, Lb / 2 - Rb * 0.95], [Rb, Lb / 2 - Rb * 0.7]], [sx * Lb / 2, yc, 0], sx < 0 ? 'x' : '-x', 40)\n  }\n  k.cyl(coat, Rb, Lb - 2 * Rb * 0.7 + 0.02, [0, yc, 0], 'x', 40)\n  k.sphere(coat, Rb * 1.02, [0, yc, 0], [0.8, 1, 1], 32)\n  // trunnion bosses, body support pad, vent / drain / sealant extension lines\n  for (const sz of [-1, 1]) k.cyl(coat, od * 0.3, 0.12, [0, yc, sz * (Rb + 0.02)], 'z', 20)\n  k.cyl(coat, od * 0.28, 0.12, [0, yc - Rb - 0.03, 0], 'y', 20)\n  k.box('concrete', [Lb * 0.7, yc - Rb - 0.08 - yb + 0.05, tw - 0.1], [0, (yc - Rb - 0.08 + yb - 0.05) / 2, 0])\n  const yTop = yc + Rb\n  // stem extension: housing tube up through grade to the actuator adapter\n  const ya = 0.5 * s + 0.35\n  k.cyl(coat, od * 0.32, 0.1, [0, yTop + 0.04, 0], 'y', 28)\n  k.cyl(coat, Math.max(0.11, od * 0.2), -yTop + 0.1, [0, (yTop + 0.1) / 2, 0], 'y', 24)\n  k.cyl(YELLOW(), Math.max(0.11, od * 0.2), ya - 0.1, [0, (ya + 0.1) / 2, 0], 'y', 24)\n  k.cyl('flangeSteel', Math.max(0.17, od * 0.3), 0.04, [0, ya, 0], 'y', 28)\n  for (const [dx, dz, c] of [[0.22, 0.18, 'brass'], [-0.22, 0.18, 'brass'], [0.22, -0.18, 'stainless']] as [number, number, MatKey][]) {\n    k.tube('tubing', [[dx * 0.5, yTop - 0.02, dz * 0.5], [dx, yTop + 0.25, dz], [dx, 0.35, dz]], 0.012)\n    k.cyl(c, 0.025, 0.08, [dx, 0.39, dz], 'y', 10)\n    k.hex('nut', 0.05, 0.03, [dx, 0.44, dz])\n  }\n  // valve pad spanning the trench\n  k.box('concrete', [2.4, 0.2, Math.max(2.4, tw + 1)], [0, 0.1, 0])\n  if (p.actuator === 'gas-over-oil') gasOverOil(k, ya, s)\n  else electricActuator(k, ya, s)\n\n  // risers through grade: bypass at ±2.6, blowdown at ±4.4\n  const yv = 0.9\n  const Lg = faceToFace('gate', nb, p.rating)\n  const riser = (x: number, zTo: number, valveX: number, valveL: number) => {\n    const top = yc + od / 2\n    k.cyl(coat, nbOd * 0.9, 0.06, [x, top + 0.01, 0], 'y', 20)\n    k.pipe(coat, [x, top - 0.02, 0], [x, 0.12, 0], nbOd, pipeWall(nb, 'XS'))\n    k.cyl(paint('#3b3226', { roughness: 0.9 }), nbOd / 2 + 0.006, 0.3, [x, 0, 0], 'y', 20)\n    const sx = Math.sign(x)\n    const end = valveX + sx * (valveL / 2 + 0.0045 + 0.1)\n    pipeRun(k, [[x, 0.12, 0], [x, yv, 0], [x, yv, zTo], [end, yv, zTo]], nb, { mat: YELLOW() })\n    // pipe support on a concrete sleeper\n    plinth(k, x, zTo, 0.4, 0.4, yv - nbOd / 2 - 0.06)\n    k.box('galvanised', [0.2, 0.06, 0.25], [x, yv - nbOd / 2 - 0.03, zTo])\n  }\n  for (const sx of [-1, 1]) {\n    riser(sx * 2.6, -1.6, sx * 1.3, Lg)\n    const [eA, eB] = valveEnds(k, nb, p.rating, sx * 1.3, yv, -1.6, Lg)\n    // the inner end runs to the centre: bypass header between the two valves\n    k.pipe(YELLOW(), [sx < 0 ? eB : eA, yv, -1.6], [0, yv, -1.6], nbOd, pipeWall(nb, 'XS'))\n    riser(sx * 4.4, -3.2, sx * 2.4, Lg)\n    const [dA, dB] = valveEnds(k, nb, p.rating, sx * 2.4, yv, -3.2, Lg)\n    k.pipe(YELLOW(), [sx < 0 ? dB : dA, yv, -3.2], [0, yv, -3.2], nbOd, pipeWall(nb, 'XS'))\n    // pressure transmitter on the bypass riser, earth on the riser\n    pt(k, [sx * 2.6, 0.55, 0], sx)\n  }\n  k.sphere(YELLOW(), nbOd * 0.56, [0, yv, -1.6], undefined, 16)\n  plinth(k, 0, -1.6, 0.4, 0.4, yv - nbOd / 2 - 0.06)\n  // blowdown stack with silencer, supported by a column\n  const stackTop = 3.2 + s\n  k.sphere(YELLOW(), nbOd * 0.56, [0, yv, -3.2], undefined, 16)\n  k.pipe(YELLOW(), [0, yv, -3.2], [0, 2.2, -3.2], nbOd, pipeWall(nb, 'XS'))\n  const Rs = Math.max(0.25, nbOd * 1.9)\n  k.cyl(YELLOW(), nbOd * 0.7, 0.15, [0, 2.25, -3.2], 'y', 24, Rs)\n  k.cyl('galvanised', Rs, 1.6, [0, 3.1, -3.2], 'y', 36)\n  for (const y of [2.3, 3.1, 3.9]) k.torus('galvanised', Rs + 0.01, 0.015, [0, y, -3.2], 'y', 6, 36)\n  k.cyl('galvanised', Rs, 0.15, [0, 3.98, -3.2], 'y', 24, nbOd * 0.7)\n  k.pipe('galvanised', [0, 4.0, -3.2], [0, stackTop + 0.8, -3.2], nbOd * 1.3, 0.006)\n  plinth(k, 0, -4.0, 0.6, 0.6, 0.3)\n  basePlate(k, 0, 0.33, -4.0, 0.35, 0.35)\n  member(k, [0, 0.33, -4.0], [0, stackTop, -4.0], 'CHS 168.3x8', 'galvanised')\n  for (const y of [1.5, 2.8, stackTop - 0.3]) {\n    k.box('galvanised', [0.08, 0.08, 0.8 - (y > 2.2 && y < 4 ? Rs - nbOd / 2 : 0)], [0, y, -4.0 + (0.8 - (y > 2.2 && y < 4 ? Rs - nbOd / 2 : 0)) / 2])\n  }\n\n  // RTU kiosk with solar panel and antenna, CP test post, pipeline markers\n  plinth(k, -4.6, -3.9, 1.1, 0.8, 0.15)\n  k.rbox(paint('#d8d4c4', { roughness: 0.6 }), [0.9, 1.3, 0.6], 0.03, [-4.6, 0.8, -3.9])\n  k.box(paint('#bdb8a6'), [0.9, 0.04, 0.6], [-4.6, 1.47, -3.9])\n  member(k, [-5.2, 0.15, -3.9], [-5.2, 3.2, -3.9], 'CHS 88.9x6.3', 'galvanised')\n  k.box(paint('#1a2a44', { metalness: 0.5, roughness: 0.25 }), [1.2, 0.03, 0.8], [-5.2, 3.2, -3.85], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -0.6))\n  k.rod('galvanised', [-5.2, 3.2, -3.9], [-5.2, 4.6, -3.9], 0.015, 6)\n  k.cyl('plasticGrey', 0.03, 0.6, [-5.2, 4.9, -3.9], 'y', 8)\n  k.tube('cable', [[-5.2, 2.0, -3.85], [-5.0, 1.6, -3.85], [-4.9, 1.2, -3.6]], 0.01)\n  for (const [x, z, c] of [[5.0, 1.9, '#e5b114'], [-6.8, 1.0, '#e2671f'], [6.8, 1.0, '#e2671f']] as [number, number, string][]) {\n    k.cyl(paint(c), 0.05, 1.3, [x, 0.65, z], 'y', 12)\n    k.box(paint(c), [0.25, 0.3, 0.02], [x, 1.1, z + 0.06])\n  }\n  // compound fence (chain-link, barbed topping) with a double gate, gap where the trench passes\n  const fo = { style: 'chain-link' as const, height: 2.1, topping: 'barbed' as const }\n  const X = 6.2\n  const zf0 = -4.6\n  const zf1 = 2.6\n  const tr = tw / 2 + 0.3\n  fenceRun(k, [-X, 0, zf1], [-2, 0, zf1], fo, true, [0, 0, 1])\n  swingGate(k, [-2, 0, zf1], [2, 0, zf1], fo)\n  fenceRun(k, [2, 0, zf1], [X, 0, zf1], fo, true, [0, 0, 1])\n  fenceRun(k, [X, 0, zf1], [X, 0, tr], fo, false, [1, 0, 0])\n  fenceRun(k, [X, 0, tr], [X, 0, -tr], fo, false, [1, 0, 0])\n  fenceRun(k, [X, 0, -tr], [X, 0, zf0], fo, false, [1, 0, 0])\n  fenceRun(k, [X, 0, zf0], [-X, 0, zf0], fo, false, [0, 0, -1])\n  fenceRun(k, [-X, 0, zf0], [-X, 0, -tr], fo, false, [-1, 0, 0])\n  fenceRun(k, [-X, 0, -tr], [-X, 0, tr], fo, false, [-1, 0, 0])\n  fenceRun(k, [-X, 0, tr], [-X, 0, zf1], fo, false, [-1, 0, 0])\n  // warning signs and station name plate on the gate side\n  const sq = new THREE.Quaternion()\n  k.plane(signMaterial('noentry'), 0.6, 0.3, [-3.5, 1.4, zf1 + 0.05], sq)\n  k.plane(textMaterial('DANGER  HIGH PRESSURE GAS', '#c3221a', '#ffffff'), 0.9, 0.22, [3.5, 1.4, zf1 + 0.05], sq)\n  if (p.tag) {\n    k.plane(textMaterial(p.tag, '#1f4e9a', '#ffffff'), 0.9, 0.22, [3.5, 1.1, zf1 + 0.05], sq)\n    tagPlate(k, p.tag, [0.4, ya - 0.2, Math.max(0.11, g.od * 0.2) + 0.002], 'z', 0.14)\n  }\n  earthBond(k, [0.2, 0.6, 0.62 * s], [0.4, 0, 1], 0.2, 0.3)\n}\n\nfunction pt(k: Kit, p: V3, sx: number) {\n  const z = p[2] + 0.12\n  k.rod('stainless', [p[0], p[1], p[2]], [p[0], p[1], z + 0.1], 0.012, 8)\n  k.box('valveBody', [0.07, 0.07, 0.09], [p[0], p[1], z + 0.05])\n  k.box('stainless', [0.12, 0.05, 0.05], [p[0], p[1], z + 0.13])\n  k.cyl(paint('#3a6ea5'), 0.045, 0.09, [p[0], p[1] + 0.09, z + 0.13], 'y', 16)\n  k.cyl(paint('#3a6ea5'), 0.05, 0.05, [p[0], p[1] + 0.16, z + 0.13], 'z', 16)\n  gauge(k, [p[0] + sx * 0.12, p[1] + 0.06, z + 0.13], 'z', 0.08, 'bar', 160)\n}\n\nfunction gasOverOil(k: Kit, y: number, s: number) {\n  const body = paint('#e1b10f', { clearcoat: 0.5 })\n  // scotch-yoke housing on the adapter, hydraulic cylinder along +Z, spring-free double acting\n  k.rbox(body, [0.45 * s, 0.42 * s, 0.7 * s], 0.03, [0, y + 0.21 * s, 0])\n  k.cyl(body, 0.15 * s, 0.85 * s, [0, y + 0.21 * s, 0.35 * s + 0.42 * s], 'z', 28)\n  k.cyl(body, 0.17 * s, 0.04, [0, y + 0.21 * s, 0.35 * s + 0.02], 'z', 28)\n  k.cyl(body, 0.17 * s, 0.05, [0, y + 0.21 * s, 0.35 * s + 0.85 * s], 'z', 28)\n  k.boltCircle('boltXylan', 'nut', 6, 0.13 * s, [0, y + 0.21 * s, 0.35 * s + 0.82 * s], 'z', 0.012, 0.1, [0.08])\n  // position indicator\n  k.cyl('plasticGrey', 0.07, 0.06, [0, y + 0.45 * s, 0], 'y', 20)\n  // gas / oil tanks on a frame behind\n  for (const sx of [-1, 1]) {\n    const x = sx * 0.4 * s\n    const zt = -0.55 * s\n    k.cyl(body, 0.15 * s, 1.0 * s, [x, y + 0.55 * s, zt], 'y', 24)\n    k.dome(body, 0.15 * s, [x, y + 1.05 * s, zt], 'y', Math.PI / 2, 0.5)\n    k.dome(body, 0.15 * s, [x, y + 0.05 * s, zt], '-y', Math.PI / 2, 0.5)\n    k.cyl('brass', 0.015, 0.06, [x, y + 1.15 * s, zt], 'y', 8)\n    k.box('galvanised', [0.06, y + 0.05 * s - 0.2, 0.06], [x, (y + 0.05 * s + 0.2) / 2, zt])\n    k.tube('tubing', [[x, y + 0.08 * s, zt + 0.12 * s], [x, y + 0.08 * s, -0.1], [x * 0.4, y + 0.1 * s, 0.25 * s]], 0.008)\n  }\n  // control manifold with manual hydraulic pump and lever, power-gas filter\n  k.rbox('stainless', [0.5 * s, 0.35 * s, 0.18 * s], 0.02, [0.62 * s, y + 0.3 * s, 0.25 * s])\n  k.cyl('handwheel', 0.012, 0.6 * s, [0.62 * s + 0.2 * s, y + 0.6 * s, 0.25 * s], 'y', 8)\n  gauge(k, [0.62 * s - 0.12 * s, y + 0.38 * s, 0.25 * s + 0.09 * s], 'z', 0.07, 'bar', 100)\n  k.cyl('stainless', 0.04, 0.16, [0.62 * s - 0.15 * s, y + 0.1 * s, 0.25 * s], 'y', 12)\n  k.tube('tubing', [[0.62 * s - 0.15 * s, y + 0.02 * s, 0.25 * s], [0.62 * s - 0.15 * s, 0.25, 0.25 * s], [2.6 - 0.1, 0.25, 0.25], [2.6 - 0.05, 0.4, 0.06]], 0.006)\n  junctionBox(k, [-0.62 * s, y + 0.15 * s, 0.1], 'x', [0.2, 0.25, 0.12], 'stainless')\n  for (const [x, z, top] of [[-0.62 * s - 0.03, 0.1, y + 0.15 * s - 0.12], [0.62 * s, 0.25 * s - 0.12 * s, y + 0.3 * s - 0.175 * s]] as [number, number, number][]) {\n    k.box('galvanised', [0.05, top - 0.2, 0.05], [x, (top + 0.2) / 2, z])\n    k.box('galvanised', [0.15, 0.012, 0.15], [x, 0.206, z])\n  }\n}\n\nfunction electricActuator(k: Kit, y: number, s: number) {\n  const body = paint('#2f4f7a', { clearcoat: 0.5 })\n  k.rbox(body, [0.5 * s, 0.35 * s, 0.5 * s], 0.04, [0, y + 0.18 * s, 0])\n  k.cyl(body, 0.16 * s, 0.6 * s, [0.5 * s, y + 0.2 * s, 0], 'x', 28)\n  k.rbox(body, [0.3 * s, 0.4 * s, 0.35 * s], 0.04, [0, y + 0.55 * s, 0])\n  k.cyl('glass', 0.06, 0.02, [0, y + 0.55 * s, 0.18 * s], 'z', 20)\n  k.cyl('plasticGrey', 0.07, 0.03, [0, y + 0.75 * s + 0.015, 0], 'y', 20)\n  k.handwheel('handwheel', 0.45 * s, [0, y + 0.2 * s, -0.32 * s], 'z', 5)\n  junctionBox(k, [-0.25 * s, y + 0.3 * s, 0], '-x', [0.18, 0.22, 0.1], 'stainless')\n}\n"
    },
    {
      "path": "onshore/_fence.ts",
      "type": "registry:file",
      "target": "components/deck-twin/onshore/_fence.ts",
      "content": "// Security fence runs and gates: chain-link (BS 1722-10 / ASTM F567) and steel palisade (BS 1722-12), barbed or razor topping.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { meshGuardMaterial } from '../lib/textures'\n\nexport type FenceStyle = 'chain-link' | 'palisade'\nexport type Topping = 'barbed' | 'razor' | 'none'\n\nexport interface FenceOpts {\n  style: FenceStyle\n  /** fabric or pale height above ground, m */\n  height: number\n  topping: Topping\n  /** coating: galvanised or polyester powder-coat colour */\n  mat?: MatKey\n}\n\nconst GALV: MatKey = 'galvanised'\n/** typical: line post spacing, m */\nexport const POST_SPACING: Record<FenceStyle, number> = { 'chain-link': 3.0, palisade: 2.75 }\n\nconst v = (a: V3) => new THREE.Vector3(...a)\nconst arr = (p: THREE.Vector3) => p.toArray() as V3\n\nfunction frame(a: V3, b: V3) {\n  const A = v(a)\n  const d = v(b).sub(A)\n  d.y = 0\n  const L = d.length()\n  d.normalize()\n  const n = new THREE.Vector3(-d.z, 0, d.x)\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(1, 0, 0), d)\n  return { A, d, n, L, q }\n}\n\n/** Concrete footing collar at grade around a post. */\nfunction collar(k: Kit, p: V3, r: number) {\n  k.cyl('concrete', r, 0.06, [p[0], p[1] + 0.03, p[2]], 'y', 14, r * 0.8)\n}\n\n/** One post. Terminal posts are heavier. The topping arm leans to the `out` side. */\nexport function fencePost(k: Kit, p: V3, o: FenceOpts, along: THREE.Vector3, out: THREE.Vector3, terminal = false) {\n  const mat = o.mat ?? GALV\n  const H = o.height\n  if (o.style === 'chain-link') {\n    const r = terminal ? 0.038 : 0.03\n    collar(k, p, r * 4)\n    k.cyl(mat, r, H + 0.05, [p[0], p[1] + (H + 0.05) / 2, p[2]], 'y', 12)\n    k.dome(mat, r * 1.15, [p[0], p[1] + H + 0.05, p[2]], 'y', Math.PI / 2, 0.6, 12)\n  } else {\n    // RSJ-style post 102 x 44, typical\n    collar(k, p, 0.16)\n    const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(1, 0, 0), along)\n    k.box(mat, [0.044, H + 0.1, 0.006], [p[0], p[1] + (H + 0.1) / 2, p[2]], q)\n    for (const s of [-1, 1]) k.box(mat, [0.006, H + 0.1, 0.102], arr(v(p).addScaledVector(along, s * 0.019).setY(p[1] + (H + 0.1) / 2)), q)\n    k.box(mat, [0.06, 0.006, 0.12], [p[0], p[1] + H + 0.1, p[2]], q)\n  }\n  topping(k, p, o, out)\n}\n\nfunction armTop(p: V3, o: FenceOpts, out: THREE.Vector3) {\n  const y = p[1] + o.height + (o.style === 'palisade' ? 0.1 : 0.05)\n  return { y, tip: arr(v(p).addScaledVector(out, 0.32).setY(y + 0.32)), base: [p[0], y, p[2]] as V3 }\n}\n\nfunction topping(k: Kit, p: V3, o: FenceOpts, out: THREE.Vector3) {\n  if (o.topping === 'none') return\n  const { base, tip } = armTop(p, o, out)\n  if (o.topping === 'barbed') k.rod(o.mat ?? GALV, base, tip, 0.012, 6)\n  else {\n    k.rod(o.mat ?? GALV, base, [base[0], base[1] + 0.3, base[2]], 0.012, 6)\n    k.rod(o.mat ?? GALV, [base[0], base[1] + 0.3, base[2]], arr(v(base).addScaledVector(out, 0.15).setY(base[1] + 0.3)), 0.01, 6)\n  }\n}\n\n/** Fence run from a to b (ground points), posts at both ends unless `startPost` is false. Returns post count. */\nexport function fenceRun(k: Kit, a: V3, b: V3, o: FenceOpts, startPost = true, out?: V3) {\n  const { A, d, n, L, q } = frame(a, b)\n  if (L < 0.05) return 0\n  const outV = out ? v(out).normalize() : n.clone()\n  const mat = o.mat ?? GALV\n  const bays = Math.max(1, Math.ceil(L / POST_SPACING[o.style]))\n  const bl = L / bays\n  const H = o.height\n  const at = (s: number, y: number) => arr(A.clone().addScaledVector(d, s).setY(a[1] + y))\n  for (let i = startPost ? 0 : 1; i <= bays; i++) fencePost(k, at(i * bl, 0), o, d, outV, i === 0 || i === bays)\n  const mid = (y: number) => at(L / 2, y)\n  if (o.style === 'chain-link') {\n    // top rail, bottom tension wire, fabric (50 mm diamond mesh)\n    k.rod(mat, at(0, H - 0.02), at(L, H - 0.02), 0.021, 8)\n    k.rod(mat, at(0, 0.06), at(L, 0.06), 0.003, 4)\n    k.rod(mat, at(0, H * 0.5), at(L, H * 0.5), 0.003, 4)\n    const geo = new THREE.PlaneGeometry(L, H - 0.04)\n    const uv = geo.getAttribute('uv') as THREE.BufferAttribute\n    for (let i = 0; i < uv.count; i++) uv.setXY(i, (uv.getX(i) * L) / 0.2, (uv.getY(i) * (H - 0.04)) / 0.2)\n    k.add(fabric(mat), geo, new THREE.Matrix4().compose(v(mid((H - 0.04) / 2 + 0.03)).addScaledVector(n, 0.035), q, new THREE.Vector3(1, 1, 1)))\n    // diagonal braces at terminal posts\n    for (const [s0, s1] of [[0, Math.min(bl, 1.6)], [L, L - Math.min(bl, 1.6)]]) k.rod(mat, at(s0, H * 0.8), at(s1, 0.15), 0.017, 6)\n  } else {\n    // two (three above 2.4 m) angle rails, W-section pales with triple-pointed heads\n    const rails = H > 2.3 ? [0.3, H / 2, H - 0.35] : [0.3, H - 0.35]\n    for (const y of rails) {\n      k.box(mat, [L, 0.05, 0.006], mid(y), q)\n      k.box(mat, [L, 0.006, 0.05], arr(v(mid(y + 0.022)).addScaledVector(n, 0.025)), q)\n    }\n    const pitch = 0.155 // typical: 65 mm pale, 90 mm gap\n    const np = Math.max(1, Math.floor((L - 0.05) / pitch))\n    const off = (L - (np - 1) * pitch) / 2\n    const pale = paleGeometry(H)\n    for (let i = 0; i < np; i++) {\n      const p = v(at(off + i * pitch, 0)).addScaledVector(n, 0.05)\n      k.addShared(mat, pale, new THREE.Matrix4().compose(p, q, new THREE.Vector3(1, 1, 1)))\n      k.hex('nut', 0.016, 0.01, arr(p.clone().addScaledVector(n, 0.022).setY(a[1] + rails[rails.length - 1])), arr(n))\n    }\n  }\n  // topping strands or razor coil, carried on the arms\n  if (o.topping !== 'none') {\n    const { y, tip } = armTop(arr(A), o, outV)\n    const off = v(tip).sub(A.clone().setY(y))\n    if (o.topping === 'barbed')\n      for (const t of [0.35, 0.68, 1]) {\n        const p0 = arr(A.clone().setY(y).addScaledVector(off, t))\n        k.rod(mat, p0, arr(v(p0).addScaledVector(d, L)), 0.0025, 4)\n      }\n    else razorCoil(k, arr(A.clone().setY(y + 0.3).addScaledVector(outV, 0.08)), d, L, 0.45)\n  }\n  return bays + (startPost ? 1 : 0)\n}\n\nconst fab = new Map<string, THREE.Material>()\nfunction fabric(mat: MatKey) {\n  const key = typeof mat === 'string' ? mat : mat.uuid\n  let m = fab.get(key)\n  if (!m) fab.set(key, (m = meshGuardMaterial(typeof mat === 'string' ? (mat === 'galvanised' ? '#a9aeb1' : '#2f5a3a') : '#' + (mat as THREE.MeshStandardMaterial).color.getHexString())))\n  return m\n}\n\nconst paleCache = new Map<number, THREE.BufferGeometry>()\n/** W-section pale, 65 mm wide, triple-pointed head, 2.5 mm steel; origin at the foot, face in local XY. */\nfunction paleGeometry(H: number) {\n  let g = paleCache.get(H)\n  if (g) return g\n  const w = 0.065\n  const sh = new THREE.Shape()\n  sh.moveTo(-w / 2, 0)\n  sh.lineTo(w / 2, 0)\n  sh.lineTo(w / 2, H - 0.07)\n  sh.lineTo(w / 2 - 0.004, H - 0.02)\n  sh.lineTo(w / 6, H - 0.06)\n  sh.lineTo(0, H)\n  sh.lineTo(-w / 6, H - 0.06)\n  sh.lineTo(-w / 2 + 0.004, H - 0.02)\n  sh.lineTo(-w / 2, H - 0.07)\n  sh.closePath()\n  const flat = new THREE.ExtrudeGeometry(sh, { depth: 0.0025, bevelEnabled: false })\n  // two folded flanges give the W its depth\n  const fl = new THREE.BoxGeometry(0.0025, H - 0.1, 0.018)\n  const parts: THREE.BufferGeometry[] = [flat]\n  for (const x of [-w / 2, w / 2, -w / 6, w / 6]) {\n    const f = fl.clone()\n    f.translate(x, (H - 0.1) / 2, x === -w / 2 || x === w / 2 ? -0.008 : 0.01)\n    parts.push(f)\n  }\n  g = mergeAll(parts)\n  paleCache.set(H, g)\n  return g\n}\n\nfunction mergeAll(list: THREE.BufferGeometry[]) {\n  const geos = list.map((x) => (x.index ? x.toNonIndexed() : x))\n  let n = 0\n  for (const x of geos) n += x.getAttribute('position').count\n  const pos = new Float32Array(n * 3)\n  const nor = new Float32Array(n * 3)\n  const uv = new Float32Array(n * 2)\n  let o = 0\n  for (const x of geos) {\n    x.computeVertexNormals()\n    const c = x.getAttribute('position').count\n    pos.set(x.getAttribute('position').array as Float32Array, o * 3)\n    nor.set(x.getAttribute('normal').array as Float32Array, o * 3)\n    const u = x.getAttribute('uv')\n    if (u) uv.set(u.array as Float32Array, o * 2)\n    o += c\n  }\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.BufferAttribute(pos, 3))\n  g.setAttribute('normal', new THREE.BufferAttribute(nor, 3))\n  g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))\n  return g\n}\n\n/** Concertina razor coil along d from p for L, coil diameter D (low-poly helix). */\nexport function razorCoil(k: Kit, p: V3, d: THREE.Vector3, L: number, D: number) {\n  const [u, w] = [new THREE.Vector3(0, 1, 0), new THREE.Vector3().crossVectors(d, new THREE.Vector3(0, 1, 0)).normalize()]\n  const turns = Math.max(2, Math.round(L / 0.2))\n  const per = 10\n  const pts: THREE.Vector3[] = []\n  for (let i = 0; i <= turns * per; i++) {\n    const t = i / (turns * per)\n    const a = t * turns * Math.PI * 2\n    pts.push(v(p).addScaledVector(d, t * L).addScaledVector(u, Math.sin(a) * D / 2).addScaledVector(w, Math.cos(a) * D / 2))\n  }\n  const curve = new THREE.CatmullRomCurve3(pts)\n  k.add(paint('#c9cdd0', { metalness: 0.9, roughness: 0.25, clearcoat: 0 }), new THREE.TubeGeometry(curve, turns * per, 0.004, 3, false))\n}\n\n/** Double-leaf swing gate (chain-link or palisade infill) across a to b, leaves closed. */\nexport function swingGate(k: Kit, a: V3, b: V3, o: FenceOpts) {\n  const { A, d, n, L, q } = frame(a, b)\n  const mat = o.mat ?? GALV\n  const H = o.height\n  for (const s of [0, L]) {\n    const p = arr(A.clone().addScaledVector(d, s))\n    collar(k, p, 0.18)\n    k.cyl(mat, 0.05, H + 0.15, [p[0], a[1] + (H + 0.15) / 2, p[2]], 'y', 14)\n    k.dome(mat, 0.055, [p[0], a[1] + H + 0.15, p[2]], 'y', Math.PI / 2, 0.6, 12)\n    topping(k, p, o, n)\n  }\n  const leaf = (L - 0.16) / 2\n  for (const [s0, dir] of [[0.08, 1], [L - 0.08, -1]] as const) {\n    const c = (t: number, y: number) => arr(A.clone().addScaledVector(d, s0 + dir * t).setY(a[1] + y))\n    for (const y of [0.08, H - 0.05]) k.rod(mat, c(0.03, y), c(leaf - 0.01, y), 0.021, 8)\n    for (const t of [0.03, leaf - 0.01]) k.rod(mat, c(t, 0.08), c(t, H - 0.05), 0.021, 8)\n    k.rod(mat, c(0.03, 0.1), c(leaf - 0.01, H - 0.08), 0.017, 6)\n    for (const y of [0.25, H - 0.3]) k.box('nut', [0.06, 0.03, 0.03], c(0.0, y))\n    if (o.style === 'chain-link') {\n      const geo = new THREE.PlaneGeometry(leaf - 0.06, H - 0.16)\n      const uv = geo.getAttribute('uv') as THREE.BufferAttribute\n      for (let i = 0; i < uv.count; i++) uv.setXY(i, (uv.getX(i) * (leaf - 0.06)) / 0.2, (uv.getY(i) * (H - 0.16)) / 0.2)\n      k.add(fabric(mat), geo, new THREE.Matrix4().compose(v(c(leaf / 2, H / 2)).addScaledVector(n, 0.025), q, new THREE.Vector3(1, 1, 1)))\n    } else {\n      const pale = paleGeometry(H - 0.05)\n      for (let t = 0.1; t < leaf - 0.05; t += 0.155) k.addShared(mat, pale, new THREE.Matrix4().compose(v(c(t, 0.05)).addScaledVector(n, 0.03), q, new THREE.Vector3(1, 1, 1)))\n    }\n  }\n  // drop bolt and padlock hasp at the meeting stiles\n  const m = arr(A.clone().addScaledVector(d, L / 2).setY(a[1] + 1.05).addScaledVector(n, 0.03))\n  k.box('brass', [0.05, 0.08, 0.03], m, q)\n  k.rod(mat, arr(v(m).setY(a[1])), arr(v(m).setY(a[1] + 0.5)), 0.008, 6)\n}\n\n/** Cantilever sliding gate closed across a to b, counterbalance tail running back past a; track wheels on rail posts. */\nexport function slidingGate(k: Kit, a: V3, b: V3, o: FenceOpts) {\n  const { A, d, n, L, q } = frame(a, b)\n  const mat = o.mat ?? GALV\n  const H = o.height\n  const tail = L * 0.45\n  const at = (s: number, y: number) => arr(A.clone().addScaledVector(d, s).setY(a[1] + y).addScaledVector(n, 0.12))\n  // guide posts with roller carriages, receiving post at b\n  for (const s of [-tail * 0.15, -tail * 0.85]) {\n    const p = arr(A.clone().addScaledVector(d, s))\n    collar(k, p, 0.2)\n    k.box(mat, [0.1, 0.25, 0.1], [p[0], a[1] + 0.125, p[2]])\n    k.box('nut', [0.3, 0.1, 0.12], at(s, 0.16))\n    k.box(mat, [0.1, H + 0.1, 0.1], [p[0], a[1] + (H + 0.1) / 2, p[2]])\n  }\n  const pb = arr(A.clone().addScaledVector(d, L + 0.1))\n  collar(k, pb, 0.2)\n  k.box(mat, [0.1, H + 0.1, 0.1], [pb[0], a[1] + (H + 0.1) / 2, pb[2]])\n  // leaf frame: bottom box track beam, top rail, stiles, diagonal bracing in the tail\n  k.box(mat, [L + tail, 0.12, 0.08], at((L - tail) / 2, 0.26), q)\n  k.box(mat, [L + tail, 0.05, 0.05], at((L - tail) / 2, H), q)\n  for (const s of [-tail, 0, L]) k.box(mat, [0.05, H - 0.26, 0.05], at(s, (H + 0.26) / 2), q)\n  k.rod(mat, at(-tail, 0.32), at(0, H), 0.02, 6)\n  if (o.style === 'chain-link') {\n    const geo = new THREE.PlaneGeometry(L, H - 0.32)\n    const uv = geo.getAttribute('uv') as THREE.BufferAttribute\n    for (let i = 0; i < uv.count; i++) uv.setXY(i, (uv.getX(i) * L) / 0.2, (uv.getY(i) * (H - 0.32)) / 0.2)\n    k.add(fabric(mat), geo, new THREE.Matrix4().compose(v(at(L / 2, (H + 0.32) / 2)), q, new THREE.Vector3(1, 1, 1)))\n  } else {\n    const pale = paleGeometry(H)\n    for (let s = 0.08; s < L - 0.05; s += 0.155) k.addShared(mat, pale, new THREE.Matrix4().compose(v(at(s, 0.2)).addScaledVector(n, 0.045), q, new THREE.Vector3(1, 1, 1)))\n  }\n  // motor operator on its plinth beside the first guide post\n  const mp = arr(A.clone().addScaledVector(d, -0.5).addScaledVector(n, 0.55))\n  k.box('concrete', [0.5, 0.15, 0.4], [mp[0], a[1] + 0.075, mp[2]])\n  k.rbox(paint('#6f7478'), [0.3, 0.38, 0.22], 0.03, [mp[0], a[1] + 0.34, mp[2]], q)\n  if (o.topping !== 'none') {\n    const y = a[1] + H + 0.05\n    const start = arr(A.clone().addScaledVector(d, -tail).setY(y))\n    if (o.topping === 'razor') razorCoil(k, [start[0], y + 0.2, start[2]], d, L + tail, 0.4)\n    else for (const t of [0.12, 0.24]) k.rod(mat, arr(v(start).setY(y + t)), arr(v(start).setY(y + t).addScaledVector(d, L + tail)), 0.0025, 4)\n  }\n}\n"
    },
    {
      "path": "onshore/_onshore.ts",
      "type": "registry:file",
      "target": "components/deck-twin/onshore/_onshore.ts",
      "content": "// Shared onshore parts: plinths, anchor bolts, earth bonds, ISO 14122 stairs, shedded insulators, lattice steel and swivel joints.\nimport * as THREE from 'three'\nimport { axisVec, type Axis, type Kit, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { grating, handrail, member } from '../lib/parts'\nimport { stairFlight } from '../lib/standards'\n\nexport const add = (a: V3, b: V3, s = 1): V3 => [a[0] + b[0] * s, a[1] + b[1] * s, a[2] + b[2] * s]\nexport const deg = (d: number) => (d * Math.PI) / 180\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nexport const porcelain = () => paint('#6e3420', { metalness: 0.05, roughness: 0.12, clearcoat: 0.9, clearcoatRoughness: 0.1, weather: 0 })\nexport const silicone = () => paint('#8b9399', { metalness: 0, roughness: 0.55, clearcoat: 0.2, clearcoatRoughness: 0.5, weather: 0.3 })\nexport const earthLead = () => paint('#5c9e33', { metalness: 0, roughness: 0.6, clearcoat: 0, weather: 0 })\nexport const lightGrey = () => paint('#cfd2cf', { metalness: 0.2, roughness: 0.5, clearcoat: 0.25, weather: 1 })\n\n/** Concrete plinth from y0 up to y0 + h with a small arris, centred on (cx, cz). */\nexport function plinth(k: Kit, cx: number, cz: number, sx: number, sz: number, h: number, y0 = 0) {\n  const c = Math.min(0.025, h * 0.2)\n  k.box('concrete', [sx, h - c, sz], [cx, y0 + (h - c) / 2, cz])\n  k.box('concrete', [sx - 2 * c, c, sz - 2 * c], [cx, y0 + h - c / 2, cz])\n}\n\n/** Cast-in anchor bolt standing on a surface at p: washer, double nut, projecting thread. */\nexport function anchorBolt(k: Kit, p: V3, dia = 0.024) {\n  k.cyl('galvanised', dia * 1.1, 0.005, [p[0], p[1] + 0.0025, p[2]], 'y', 12)\n  k.hex('nut', dia * 1.85, dia * 0.9, [p[0], p[1] + 0.005 + dia * 0.45, p[2]])\n  k.hex('nut', dia * 1.85, dia * 0.9, [p[0], p[1] + 0.005 + dia * 1.4, p[2]])\n  k.cyl('boltXylan', dia / 2, dia * 2.6, [p[0], p[1] + dia * 1.3, p[2]], 'y', 8)\n}\n\n/** Steel base plate with its top at y, on a grout bed, anchored near each corner. */\nexport function basePlate(k: Kit, cx: number, y: number, cz: number, sx: number, sz: number, t = 0.025, dia = 0.024, mat: MatKey = 'galvanised') {\n  k.box(mat, [sx, t, sz], [cx, y - t / 2, cz])\n  const ix = sx / 2 - dia * 2.2\n  const iz = sz / 2 - dia * 2.2\n  for (const a of [-1, 1]) for (const b of [-1, 1]) anchorBolt(k, [cx + a * ix, y, cz + b * iz], dia)\n}\n\n/** Earth boss with a bolted lug and a green/yellow bonding lead down to an inspection pit at grade. */\nexport function earthBond(k: Kit, pos: V3, out: V3, ground = 0, pitOffset = 0.3) {\n  const o = new THREE.Vector3(...out).normalize().toArray() as V3\n  k.cyl('brass', 0.017, 0.03, add(pos, o, 0.015), o, 10)\n  k.box('copper', [0.03, 0.05, 0.004], add(add(pos, o, 0.032), [0, -0.012, 0]), o)\n  k.hex('nut', 0.022, 0.012, add(pos, o, 0.04), o)\n  const lug = add(add(pos, o, 0.035), [0, -0.035, 0])\n  const pit: V3 = [pos[0] + o[0] * pitOffset, ground, pos[2] + o[2] * pitOffset]\n  if (pos[1] - ground > 0.12) {\n    k.spline(earthLead(), [lug, add(lug, [0, -0.08, 0]), add([pit[0], ground + 0.25, pit[2]], o, -pitOffset * 0.3), [pit[0], ground + 0.1, pit[2]], [pit[0], ground + 0.03, pit[2]]], 0.0065, 6, 24)\n    k.cyl('concrete', 0.14, 0.05, [pit[0], ground + 0.025, pit[2]], 'y', 16)\n    k.cyl('castIron', 0.1, 0.008, [pit[0], ground + 0.054, pit[2]], 'y', 16)\n  }\n}\n\n/** ISO 14122-3 straight stair: first riser at `foot`, climbing along `dir` by `rise`. Channel stringers, grating treads, handrails. Returns the horizontal run. */\nexport function stair(k: Kit, foot: V3, dir: Axis, rise: number, width = 0.9, rails: 'both' | 'left' | 'right' | 'none' = 'both') {\n  const f = stairFlight(rise)\n  const n = f.steps\n  const g = f.going\n  const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(1, 0, 0), axisVec(dir).setY(0).normalize())\n  const w = width / 2\n  const slope = f.riser / g\n  k.with(new THREE.Matrix4().compose(new THREE.Vector3(...foot), q, new THREE.Vector3(1, 1, 1)), () => {\n    const sd = 0.18\n    const yAt = (x: number) => f.riser + x * slope - 0.07\n    const x0 = (sd * 0.45 + 0.07 - f.riser) / slope\n    const run = (n - 1) * g\n    for (const s of [-1, 1]) {\n      const z = s * (w + 0.04)\n      member(k, [x0, yAt(x0), z], [run, yAt(run), z], 'UPN 180', 'galvanised', [-slope, 1, 0])\n      k.box('galvanised', [0.22, 0.015, 0.16], [x0 + 0.06, 0.0075, z])\n    }\n    for (let i = 1; i < n; i++) {\n      const y = i * f.riser\n      grating(k, [(i - 1) * g + g / 2 + 0.0125, y, 0], g + 0.025, width)\n      k.box('safetyYellow', [0.03, 0.032, width], [(i - 1) * g + 0.015, y - 0.016, 0])\n    }\n    const pts = (z: number): V3[] => [[0, f.riser * 0.5, z], [run, rise, z]]\n    if (rails === 'both' || rails === 'left') handrail(k, pts(w + 0.04), 'safetyYellow')\n    if (rails === 'both' || rails === 'right') handrail(k, pts(-w - 0.04), 'safetyYellow')\n  })\n  return (n - 1) * g\n}\n\n/** Shedded post insulator from `base` along `axis` for L: galvanised end caps, alternating sheds of radius ~2.2 r. */\nexport function insulator(k: Kit, base: V3, L: number, r: number, mat: MatKey, axis: Axis = 'y') {\n  const v = axisVec(axis).toArray() as V3\n  const cap = Math.min(0.08, L * 0.08)\n  const prof: [number, number][] = [[0, cap], [r, cap]]\n  const body = L - 2 * cap\n  const n = Math.max(3, Math.round(body / Math.max(0.045, r * 0.75)))\n  const pitch = body / n\n  let y = cap\n  for (let i = 0; i < n; i++) {\n    const big = i % 2 === 0 ? 2.3 : 1.85\n    prof.push([r * 1.05, y + pitch * 0.15], [r * big, y + pitch * 0.42], [r * (big - 0.1), y + pitch * 0.55], [r, y + pitch * 0.62])\n    y += pitch\n    prof.push([r, y])\n  }\n  prof.push([0, L - cap])\n  k.lathe(mat, prof, base, v, 20)\n  for (const t of [cap / 2, L - cap / 2]) k.cyl('galvanised', r * 1.25, cap, add(base, v, t), v, 18)\n  for (const t of [0.006, L - 0.006]) k.cyl('galvanised', r * 1.9, 0.012, add(base, v, t), v, 18)\n}\n\n/** Square lattice column of four angle legs with zig-zag bracing on every face, (cx, cz) centre, from y0 to y1. */\nexport function latticeColumn(k: Kit, cx: number, cz: number, y0: number, y1: number, w: number, leg = 'L100x100x10', brace = 'L50x50x5', mat: MatKey = 'galvanised') {\n  const h = w / 2\n  const c: [number, number][] = [[-h, -h], [h, -h], [h, h], [-h, h]]\n  for (const [x, z] of c) member(k, [cx + x, y0, cz + z], [cx + x, y1, cz + z], leg, mat, [-x, 0, -z])\n  const H = y1 - y0\n  const nb = Math.max(1, Math.round(H / w))\n  const dy = H / nb\n  for (let f = 0; f < 4; f++) {\n    const [ax, az] = c[f]\n    const [bx, bz] = c[(f + 1) % 4]\n    for (let i = 0; i < nb; i++) {\n      const ya = y0 + i * dy\n      const flip = (i + f) % 2 === 0\n      const p0: V3 = [cx + (flip ? ax : bx), ya, cz + (flip ? az : bz)]\n      const p1: V3 = [cx + (flip ? bx : ax), ya + dy, cz + (flip ? bz : az)]\n      member(k, p0, p1, brace, mat, [ax + bx, 0, az + bz])\n    }\n    member(k, [cx + ax, y1 - 0.03, cz + az], [cx + bx, y1 - 0.03, cz + bz], brace, mat)\n  }\n}\n\n/** Swivel joint between a and b: two ball-race housings, seal ring, grease nipples and plug bolts. od = pipe OD. */\nexport function swivel(k: Kit, a: V3, b: V3, od: number, mat: MatKey = 'flangeSteel') {\n  const A = new THREE.Vector3(...a)\n  const d = new THREE.Vector3(...b).sub(A)\n  const L = d.length()\n  d.normalize()\n  const v = d.toArray() as V3\n  const at = (t: number) => A.clone().addScaledVector(d, t).toArray() as V3\n  const R = od * 0.82\n  const ri = od * 0.44\n  ring(k, mat, ri, od * 0.56, L, at(L / 2), v)\n  k.cyl('pipeBore', ri, L, at(L / 2), v, 32, undefined, true)\n  ring(k, mat, od * 0.55, R, L * 0.36, at(L * 0.3), v)\n  ring(k, 'machined', od * 0.55, R * 0.96, L * 0.04, at(L * 0.5), v)\n  ring(k, mat, od * 0.55, R * 0.92, L * 0.3, at(L * 0.69), v)\n  for (const t of [0.12, 0.88]) ring(k, mat, od * 0.55, od * 0.7, L * 0.06, at(L * t), v)\n  const [u, w] = perpOf(d)\n  for (let i = 0; i < 6; i++) {\n    const ang = (i / 6) * Math.PI * 2\n    const r = new THREE.Vector3().addScaledVector(u, Math.cos(ang) * R).addScaledVector(w, Math.sin(ang) * R)\n    const p = new THREE.Vector3(...at(L * 0.3)).add(r)\n    k.hex('nut', Math.max(0.018, od * 0.06), 0.012, p.toArray() as V3, r.clone().normalize().toArray() as V3)\n  }\n  const gp = new THREE.Vector3(...at(L * 0.69)).addScaledVector(u, R * 0.92)\n  k.cyl('brass', Math.max(0.006, od * 0.02), 0.03, gp.clone().addScaledVector(u, 0.012).toArray() as V3, u.toArray() as V3, 8)\n}\n\nfunction perpOf(d: THREE.Vector3): [THREE.Vector3, THREE.Vector3] {\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  return [u, new THREE.Vector3().crossVectors(d, u).normalize()]\n}\n\n/** Annular ring (hollow short cylinder) centred on pos along axis, open through the middle. */\nexport function ring(k: Kit, mat: MatKey, ri: number, ro: number, h: number, pos: V3, axis: Axis = 'y', seg = 36) {\n  const v = axisVec(axis)\n  const base = new THREE.Vector3(...pos).addScaledVector(v, -h / 2).toArray() as V3\n  k.lathe(mat, [[ro, 0], [ro, h], [ri, h], [ri, 0], [ro, 0]], base, v.toArray() as V3, seg)\n}\n\n/** Hydraulic cylinder from a (barrel end clevis) to b (rod eye). */\nexport function hydraulicCylinder(k: Kit, a: V3, b: V3, bore: number, mat: MatKey = 'actuatorBlue') {\n  const A = new THREE.Vector3(...a)\n  const B = new THREE.Vector3(...b)\n  const L = A.distanceTo(B)\n  if (L < 1e-3) return\n  const d = B.clone().sub(A).normalize()\n  const bl = Math.min(L * 0.62, L - 0.1)\n  const p = (t: number) => A.clone().addScaledVector(d, t).toArray() as V3\n  k.rod(mat, p(0.04), p(bl), bore / 2, 16)\n  k.rod(mat, p(0.04), p(0.1), bore * 0.62, 16)\n  k.rod(mat, p(bl - 0.06), p(bl), bore * 0.62, 16)\n  k.rod('stainless', p(bl), p(L - 0.04), bore * 0.28, 12)\n  const side = Math.abs(d.x) > 0.9 ? 'z' : 'x'\n  for (const t of [0.02, L - 0.02]) k.cyl('flangeSteel', bore * 0.45, bore * 0.6, p(t), side, 12)\n  k.rod('tubing', add(p(0.12), [0, bore * 0.6, 0]), add(p(bl - 0.08), [0, bore * 0.6, 0]), 0.006, 6)\n}\n\n/**\n * Trapezoidal profiled sheet (wall or roof cladding): profile runs along `u` for `length`, ribs stand out along `n`,\n * extruded `height` along `w`; `origin` is the (0, 0, 0) corner. Typical 35 mm rib at 200 mm pitch.\n */\nexport function profiledSheet(k: Kit, mat: MatKey, length: number, height: number, origin: V3, u: V3, n: V3, w: V3, pitch = 0.2, depth = 0.035) {\n  const top = pitch * 0.2\n  const slope = pitch * 0.15\n  const pts: [number, number][] = [[0, 0]]\n  const ribs = Math.max(1, Math.round(length / pitch))\n  const sc = length / (ribs * pitch)\n  for (let i = 0; i < ribs; i++) {\n    const a = i * pitch * sc\n    const m = a + (pitch * sc) / 2\n    pts.push([m - (top / 2 + slope) * sc, 0], [m - (top / 2) * sc, depth], [m + (top / 2) * sc, depth], [m + (top / 2 + slope) * sc, 0])\n  }\n  pts.push([length, 0])\n  const sh = new THREE.Shape()\n  pts.forEach(([x, y], i) => (i ? sh.lineTo(x, y) : sh.moveTo(x, y)))\n  for (let i = pts.length - 1; i >= 0; i--) sh.lineTo(pts[i][0], pts[i][1] - 0.0015)\n  sh.closePath()\n  const m = new THREE.Matrix4().makeBasis(new THREE.Vector3(...u), new THREE.Vector3(...n), new THREE.Vector3(...w)).setPosition(...origin)\n  k.extrude(mat, sh, height, m, 1)\n}\n"
    }
  ],
  "categories": [
    "onshore"
  ],
  "meta": {
    "export": "PipelineValveStation",
    "anchor": "base",
    "standards": [
      "ASME B31.8",
      "API 6D",
      "ISO 14313",
      "ASME B16.5",
      "BS 1722-10"
    ]
  }
}