{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jacket",
  "type": "registry:component",
  "title": "Offshore steel jacket",
  "description": "Fixed steel jacket substructure (4, 6 or 8 legs): hollow battered tubular legs with node cans, horizontal frames, X or K braced faces with oversized brace stubs and crossing cans, conductor guide framing with funnelled sleeves, risers clamped to the +X face with a splash-zone sheath, a seabed bend and top flanges (ports riser-1…n), a boat landing with bumpers, grating and ladder on +Z, barge bumpers on the corner legs through the splash zone, stand-off anodes, mudmats and yellow sea-level marker rings. Origin is the top of the jacket legs at y = 0 and the structure extends below it: mean sea level at y = −airGap, seabed at y = −(airGap + waterDepth). A deck-module with legHeight h placed at y = h sits on it when its leg spacing matches spanX / spanZ.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "structure/jacket.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/structure/jacket.tsx",
      "content": "// Fixed steel jacket (ISO 19902): battered tubular legs, X or K braced faces, horizontal frames, conductor guide framing, risers with clamps, boat landing, anodes and mudmats.\nimport * as THREE from 'three'\nimport type { Kit, V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { flange } from '../lib/parts'\nimport { port, type Port } from '../lib/ports'\nimport { pipeOD, type Rating } from '../lib/standards'\nimport { ladderFlight } from './_access'\nimport { gratingBay, railRun } from './_steel'\n\nexport interface JacketProps {\n  /** 4, 6 or 8 legs (2 rows along X) */\n  legs?: number\n  /** mean sea level to seabed, m */\n  waterDepth?: number\n  /** jacket top (y = 0) above mean sea level, m */\n  airGap?: number\n  /** leg centre spacing at the top, m */\n  spanX?: number\n  spanZ?: number\n  /** leg batter, 1 horizontal in n vertical */\n  batter?: number\n  legDiameter?: number\n  bracing?: 'X' | 'K'\n  conductors?: number\n  risers?: number\n  riserNps?: number\n  riserRating?: Rating\n  boatLanding?: boolean\n  anodes?: boolean\n  seaLevelMarker?: boolean\n}\n\nexport const defaults = {\n  legs: 4,\n  waterDepth: 60,\n  airGap: 20,\n  spanX: 16,\n  spanZ: 12,\n  batter: 10,\n  legDiameter: 1.5,\n  bracing: 'X' as 'X' | 'K',\n  conductors: 6,\n  risers: 2,\n  riserNps: 12,\n  riserRating: 600 as Rating,\n  boatLanding: true,\n  anodes: true,\n  seaLevelMarker: true,\n}\n\ntype P = typeof defaults\n\nconst RISER_OFF = 1.4\n\nfunction geo(p: P) {\n  const nx = Math.max(2, Math.round((p.legs || 4) / 2))\n  const sx = p.spanX / 2\n  const sz = p.spanZ / 2\n  const b = Math.max(4, p.batter)\n  const hx = (y: number) => sx + Math.max(0, -y) / b\n  const hz = (y: number) => sz + Math.max(0, -y) / b\n  const xTop = Array.from({ length: nx }, (_, i) => -1 + (2 * i) / (nx - 1))\n  const leg = (i: number, j: number, y: number): V3 => [xTop[i] * hx(y), y, (j ? 1 : -1) * hz(y)]\n  const ySea = -p.airGap\n  const yBed = -(p.airGap + p.waterDepth)\n  return { nx, hx, hz, leg, ySea, yBed }\n}\n\nfunction riserTops(p: P): V3[] {\n  const n = Math.max(0, Math.round(p.risers))\n  return Array.from({ length: n }, (_, i) => [p.spanX / 2 + RISER_OFF, 0, (i - (n - 1) / 2) * 1.6] as V3)\n}\n\nexport function ports(props: JacketProps = {}): Port[] {\n  const p = { ...defaults, ...props } as P\n  return riserTops(p).map((t, i) => port(`riser-${i + 1}`, `Riser ${i + 1} top`, t, [0, 1, 0], p.riserNps, p.riserRating))\n}\n\nexport function Jacket(props: JacketProps & GroupProps) {\n  const { legs, waterDepth, airGap, spanX, spanZ, batter, legDiameter, bracing, conductors, risers, riserNps, riserRating, boatLanding, anodes, seaLevelMarker, ...group } = { ...defaults, ...props }\n  const p: P = { legs, waterDepth, airGap, spanX, spanZ, batter, legDiameter, bracing, conductors, risers, riserNps, riserRating, boatLanding, anodes, seaLevelMarker }\n  const data = useKit((k) => build(k, p), [legs, waterDepth, airGap, spanX, spanZ, batter, legDiameter, bracing, conductors, risers, riserNps, riserRating, boatLanding, anodes, seaLevelMarker])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n    </group>\n  )\n}\n\nexport default Jacket\n\nconst v = (a: V3) => new THREE.Vector3(...a)\nconst lerp = (a: V3, b: V3, t: number): V3 => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]\n\nfunction build(k: Kit, p: P) {\n  const { nx, hx, hz, leg, ySea, yBed } = geo(p)\n  const above = paint('#d9a521', { roughness: 0.55, weather: 1.2 }) // typical jacket yellow\n  const splash = paint('#6b5d3a', { roughness: 0.75, clearcoat: 0, weather: 1.5 })\n  const growth = paint('#4d5446', { roughness: 0.92, clearcoat: 0, metalness: 0.05, weather: 1.6 })\n  const anodeMat = paint('#a3a6a2', { metalness: 0.55, roughness: 0.7, clearcoat: 0 })\n  const sTop = ySea + 4\n  const sBot = ySea - 3\n  const matAt = (y: number) => (y > sTop ? above : y > sBot ? splash : growth)\n  const R = p.legDiameter / 2\n  const rH = Math.max(0.2, p.legDiameter * 0.24) // horizontal frame members\n  const rD = Math.max(0.18, p.legDiameter * 0.21) // face diagonals\n\n  // tube a→b split at the splash-zone limits so each part carries the right finish\n  const tube = (a: V3, b: V3, r: number, seg = 14, r2?: number, wall = 0) => {\n    const cuts = [0, 1]\n    for (const y of [sTop, sBot]) {\n      const t = (y - a[1]) / (b[1] - a[1])\n      if (t > 0 && t < 1) cuts.push(t)\n    }\n    cuts.sort((x, y) => x - y)\n    for (let i = 0; i < cuts.length - 1; i++) {\n      const pa = lerp(a, b, cuts[i])\n      const pb = lerp(a, b, cuts[i + 1])\n      const ra = r2 === undefined ? r : r + (r2 - r) * cuts[i]\n      const rb = r2 === undefined ? r : r + (r2 - r) * cuts[i + 1]\n      if (wall > 0) k.pipe(matAt((pa[1] + pb[1]) / 2), pa, pb, 2 * ra, wall, seg)\n      else k.rod(matAt((pa[1] + pb[1]) / 2), pa, pb, ra, seg, rb)\n    }\n  }\n  // brace with oversized stubs at both ends (welded tubular joints)\n  const brace = (a: V3, b: V3, r: number, ra = R, rb = R) => {\n    const L = v(b).distanceTo(v(a))\n    const d = v(b).sub(v(a)).normalize()\n    const pa = v(a).addScaledVector(d, ra * 0.9)\n    const pb = v(b).addScaledVector(d, -rb * 0.9)\n    tube(pa.toArray() as V3, pb.toArray() as V3, r, 14)\n    const s = Math.min(1.4, L * 0.15)\n    for (const [q, dir] of [[pa, 1], [pb, -1]] as const) {\n      const e = q.clone().addScaledVector(d, dir * s)\n      tube(q.toArray() as V3, e.toArray() as V3, r + 0.035, 14)\n      tube(e.toArray() as V3, e.clone().addScaledVector(d, dir * 0.2).toArray() as V3, r + 0.035, 14, r + 0.002)\n    }\n  }\n\n  // ---- elevations: top frame, splash frame, underwater frames with bays growing with depth, mudline frame\n  const levels = [-2]\n  const splashFrame = ySea + Math.min(6, p.airGap * 0.4)\n  if (splashFrame < -4.5) levels.push(splashFrame)\n  const m = Math.max(2, Math.round(p.waterDepth / 22))\n  const w = Array.from({ length: m }, (_, i) => 1.3 ** i)\n  const tot = w.reduce((a, b) => a + b, 0)\n  let acc = 0\n  for (let i = 0; i < m; i++) {\n    acc += w[i]\n    levels.push(ySea - (p.waterDepth - 1.5) * (acc / tot))\n  }\n\n  // ---- legs with node cans, top caps, mudmats\n  for (let i = 0; i < nx; i++)\n    for (const j of [0, 1]) {\n      const top = leg(i, j, 0)\n      const bot = leg(i, j, yBed)\n      tube(top, bot, R, 32, undefined, Math.max(0.04, p.legDiameter / 30))\n      const d = v(bot).sub(v(top)).normalize()\n      for (const y of levels) {\n        const c = v(leg(i, j, y))\n        const a = c.clone().addScaledVector(d, -1.2)\n        const b = c.clone().addScaledVector(d, 1.2)\n        tube(a.toArray() as V3, b.toArray() as V3, R + 0.05, 32)\n        tube(a.clone().addScaledVector(d, -0.35).toArray() as V3, a.toArray() as V3, R + 0.003, 32, R + 0.05)\n        tube(b.toArray() as V3, b.clone().addScaledVector(d, 0.35).toArray() as V3, R + 0.05, 32, R + 0.003)\n      }\n      k.pipe(above, [top[0], -0.3, top[2]], [top[0], 0, top[2]], 2 * R + 0.12, 0.06 + Math.max(0.04, p.legDiameter / 30), 32)\n      // mudmat: plate with edge stiffeners round the leg foot\n      const s = p.legDiameter * 2.6\n      k.box(growth, [s, 0.06, s], [bot[0], yBed + 0.03, bot[2]])\n      for (const ax of [1, 0]) k.box(growth, ax ? [s, 0.5, 0.04] : [0.04, 0.5, s], [bot[0], yBed + 0.31, bot[2]])\n      if (p.seaLevelMarker) k.torus('safetyYellow', R + 0.06, 0.06, leg(i, j, ySea), 'y', 8, 40)\n      // barge bumper on corner legs: vertical fender tube on stand-off arms through the splash zone\n      if (i === 0 || i === nx - 1) {\n        const out = new THREE.Vector3(i === 0 ? -1 : 1, 0, j ? 1 : -1).normalize()\n        const off = R + 1.1\n        const at = (y: number): V3 => {\n          const c = leg(i, j, y)\n          return [c[0] + out.x * off, y, c[2] + out.z * off]\n        }\n        const b0 = sBot - 2.5\n        const b1 = sTop + 1.5\n        tube(at(b0), at(b1), 0.3, 18)\n        tube(at(b0 - 0.6), at(b0), 0.06, 18, 0.3)\n        tube(at(b1), at(b1 + 0.6), 0.3, 18, 0.06)\n        for (const y of [b0 + 0.4, ySea, b1 - 0.4]) brace(leg(i, j, y), at(y), 0.16, R, 0.3)\n      }\n    }\n\n  // ---- faces: perimeter + interior transverse frames\n  const faces: [[number, number], [number, number]][] = []\n  for (let i = 0; i < nx - 1; i++) for (const j of [0, 1]) faces.push([[i, j], [i + 1, j]])\n  for (let i = 0; i < nx; i++) faces.push([[i, 0], [i, 1]])\n  for (const y of levels) for (const [A, B] of faces) brace(leg(A[0], A[1], y), leg(B[0], B[1], y), rH)\n  for (let l = 0; l < levels.length - 1; l++) {\n    const ya = levels[l]\n    const yb = levels[l + 1]\n    for (const [A, B] of faces) {\n      const a0 = leg(A[0], A[1], ya)\n      const b0 = leg(B[0], B[1], ya)\n      const a1 = leg(A[0], A[1], yb)\n      const b1 = leg(B[0], B[1], yb)\n      if (p.bracing === 'K') {\n        const mTop = lerp(a0, b0, 0.5)\n        brace(a1, mTop, rD, R, rH)\n        brace(b1, mTop, rD, R, rH)\n      } else {\n        brace(a0, b1, rD)\n        brace(b0, a1, rD)\n        const c = lerp(a0, b1, 0.5)\n        const d = v(b1).sub(v(a0)).normalize()\n        tube(v(c).addScaledVector(d, -1.0).toArray() as V3, v(c).addScaledVector(d, 1.0).toArray() as V3, rD + 0.04, 14)\n      }\n    }\n  }\n\n  // ---- plan framing: conductor guide frame tied to the legs, or plan X\n  const nc = Math.max(0, Math.round(p.conductors))\n  const rows = nc > 3 ? 2 : 1\n  const cols = Math.ceil(nc / rows)\n  const pitch = 2.3\n  const cons: [number, number][] = []\n  for (let r = 0; r < rows; r++) for (let c = 0; c < cols && cons.length < nc; c++) cons.push([(c - (cols - 1) / 2) * pitch, (r - (rows - 1) / 2) * pitch])\n  const rcx = ((cols - 1) * pitch) / 2 + 1.15\n  const rcz = ((rows - 1) * pitch) / 2 + 1.15\n  const rP = rH * 0.8\n  const rC = 0.762 / 2\n  for (const y of levels) {\n    if (nc) {\n      const cn: V3[] = [[-rcx, y, -rcz], [rcx, y, -rcz], [rcx, y, rcz], [-rcx, y, rcz]]\n      for (let i = 0; i < 4; i++) brace(cn[i], cn[(i + 1) % 4], rP * 0.8, 0.3, 0.3)\n      for (let c = 1; c < cols; c++) {\n        const x = (c - cols / 2) * pitch\n        brace([x, y, -rcz], [x, y, rcz], rP * 0.6, 0.25, 0.25)\n      }\n      if (rows > 1) brace([-rcx, y, 0], [rcx, y, 0], rP * 0.6, 0.25, 0.25)\n      const corners: [number, number][] = [[0, 0], [nx - 1, 0], [nx - 1, 1], [0, 1]]\n      corners.forEach(([i, j], q) => brace(leg(i, j, y), cn[q], rP, R, 0.3))\n      for (const [cx, cz] of cons) {\n        k.cyl(matAt(y), rC + 0.06, 1.2, [cx, y, cz], 'y', 20)\n        k.cyl(matAt(y), rC + 0.06, 0.35, [cx, y + 0.775, cz], 'y', 20, rC + 0.2, true)\n      }\n    } else {\n      brace(leg(0, 0, y), leg(nx - 1, 1, y), rP)\n      brace(leg(nx - 1, 0, y), leg(0, 1, y), rP)\n    }\n  }\n  for (const [cx, cz] of cons) {\n    tube([cx, 0, cz], [cx, yBed, cz], rC, 20, undefined, 0.025)\n  }\n\n  // ---- risers on the +X face: clamps at each frame, splash-zone sheath, bend onto the seabed\n  const od = pipeOD(p.riserNps)\n  const tops = riserTops(p)\n  const rx = (y: number) => hx(y) + RISER_OFF\n  for (const t of tops) {\n    const z = t[2]\n    const Rb = 5 * od\n    const dir = v([1 / Math.max(4, p.batter), -1, 0]).normalize()\n    const yEnd = yBed + Rb * (1 - Math.sin(Math.acos(-dir.y))) + od / 2 + 0.3\n    const start: V3 = [rx(yEnd), yEnd, z]\n    const L = flange(k, p.riserNps, p.riserRating, t, [0, 1, 0], true)\n    k.pipe('pipePaint', [rx(-L), -L, z], [rx(Math.min(sTop, -L - 0.01)), Math.min(sTop, -L - 0.01), z], od)\n    k.pipe('rubberBlack', [rx(sTop), sTop, z], [rx(sBot), sBot, z], od + 0.03)\n    k.pipe(growth, [rx(sBot), sBot, z], start, od)\n    const toward = v([1, 0, 0]).addScaledVector(dir, -dir.x).normalize()\n    const ang = Math.acos(Math.min(1, dir.x === 0 ? 0 : v([1, 0, 0]).dot(dir))) // angle from dir to +X\n    k.bend(growth, start, dir.toArray() as V3, toward.toArray() as V3, Rb, od, ang)\n    const c = v(start).addScaledVector(toward, Rb)\n    const end = c.clone().addScaledVector(toward, -Rb * Math.cos(ang)).addScaledVector(dir, Rb * Math.sin(ang))\n    k.pipe(growth, end.toArray() as V3, [end.x + 12, end.y, end.z], od)\n    for (const y of levels) {\n      if (y < yEnd) continue\n      const cx = rx(y)\n      k.cyl(matAt(y), od / 2 + 0.05, 0.4, [cx, y, z], [1 / Math.max(4, p.batter), -1, 0], 20)\n      tube([hx(y), y, z], [cx - od / 2 - 0.05, y, z], 0.14, 10)\n    }\n  }\n\n  // ---- boat landing on the +Z face at sea level\n  if (p.boatLanding) {\n    const zf = (y: number) => hz(y)\n    const zB = zf(ySea) + 2.2\n    const yb0 = ySea - 4\n    const yb1 = ySea + 4\n    for (const x of [-1.6, 1.6]) {\n      tube([x, yb0, zB], [x, yb1, zB], 0.32, 20)\n      tube([x, yb0 - 0.6, zB], [x, yb0, zB], 0.08, 20, 0.32)\n      for (const y of [ySea - 2.5, ySea + 2.6]) {\n        const xi = x < 0 ? 0 : nx - 1\n        brace([x, y, zB], leg(xi, 1, y), 0.17, 0.32, R)\n        tube([x, y, zB], [x, y, zf(y)], 0.15, 12)\n      }\n    }\n    const yL = ySea + 3.2\n    gratingBay(k, -1.9, zf(yL) + 0.2, 1.9, zB + 0.45, yL, 'z')\n    for (const s of [-1, 1]) k.rod(above, [s * 1.9, yL - 0.12, zf(yL) + 0.2], [s * 1.9, yL - 0.12, zB + 0.45], 0.06, 10)\n    railRun(k, [-1.85, yL, zf(yL) + 0.25], [-1.85, yL, zB + 0.4])\n    railRun(k, [1.85, yL, zf(yL) + 0.25], [1.85, yL, zB + 0.4])\n    ladderFlight(k, 0, ySea - 2.5, yL, zB + 0.3, { cage: false, standoff: 0.15, extend: 1.1 })\n  }\n\n  // ---- stand-off anodes on the legs and underwater frames\n  if (p.anodes) {\n    const anode = (c: THREE.Vector3, along: THREE.Vector3, out: THREE.Vector3, rr: number) => {\n      const p0 = c.clone().addScaledVector(out, rr + 0.28)\n      k.rod(anodeMat, p0.clone().addScaledVector(along, -0.8).toArray() as V3, p0.clone().addScaledVector(along, 0.8).toArray() as V3, 0.12, 6)\n      for (const s of [-0.75, 0.75]) k.rod('structSteelDark', c.clone().addScaledVector(along, s).addScaledVector(out, rr * 0.9).toArray() as V3, p0.clone().addScaledVector(along, s).toArray() as V3, 0.025, 6)\n    }\n    for (let i = 0; i < nx; i++)\n      for (const j of [0, 1]) {\n        const top = v(leg(i, j, 0))\n        const d = v(leg(i, j, yBed)).sub(top).normalize()\n        const out = new THREE.Vector3(Math.sign(leg(i, j, 0)[0]) || 0.0001, 0, j ? 1 : -1).normalize()\n        for (let y = sBot - 3; y > yBed + 3; y -= 9) {\n          const c = v(leg(i, j, y))\n          anode(c, d, out, R)\n        }\n      }\n    for (const y of levels.filter((yy) => yy < sBot))\n      for (const [A, B] of faces) {\n        const a = v(leg(A[0], A[1], y))\n        const b = v(leg(B[0], B[1], y))\n        const along = b.clone().sub(a).normalize()\n        for (const t of [0.33, 0.67]) anode(a.clone().lerp(b, t), along, new THREE.Vector3(0, 1, 0), rH)\n      }\n  }\n}\n"
    },
    {
      "path": "structure/_access.ts",
      "type": "registry:file",
      "target": "components/deck-twin/structure/_access.ts",
      "content": "// Shared access steel after ISO 14122-3/-4: stair flights with channel stringers and grating treads, landings, caged ladders with rest platforms.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { ACCESS, stairFlight } from '../lib/standards'\nimport { anchorBolt, bar, beamTOS, bolt, boxMM, column, gratingBay, railRun, sec, strip } from './_steel'\n\nexport interface FlightOpts {\n  /** clear width between stringers, m */\n  width: number\n  angle: number\n  stringer?: string\n  mat?: MatKey\n  railLeft?: boolean\n  railRight?: boolean\n  /** base cleats with anchor bolts at the foot */\n  footCleats?: boolean\n}\n\n/** One straight flight: foot (first riser) at (x0, y0), centred on z = zc, climbing +X by `rise`. Upper floor edge at x0 + run. */\nexport function flight(k: Kit, x0: number, y0: number, zc: number, rise: number, o: FlightOpts) {\n  const f = stairFlight(rise, o.angle)\n  const { steps, riser: r, going: g, run } = f\n  const mat = o.mat ?? 'galvanised'\n  const s = sec(o.stringer ?? 'UPN 200')\n  const w = o.width\n  if (steps < 2) return f\n  const tan = r / g\n  const cos = Math.cos(Math.atan(tan))\n  const yN = (x: number) => y0 + r + (x - x0) * tan\n  const yT = (x: number) => yN(x) + 0.04\n  const dv = s.d / cos\n  const top = y0 + rise\n  const xF = x0 + (y0 - yT(x0)) / tan // back of foot, top edge on the floor\n  const xB = x0 + (y0 - yT(x0) + dv) / tan // front of foot, bottom edge on the floor\n  const xE = x0 + run\n  const xT = x0 + (top - yT(x0)) / tan\n  // stringers: channel web as a plumb/level-cut plate, flanges along the slope, toes outward\n  for (const side of [-1, 1]) {\n    const zIn = zc + side * (w / 2)\n    const sh = new THREE.Shape()\n    sh.moveTo(xF, y0).lineTo(xB, y0).lineTo(xE, yT(xE) - dv).lineTo(xE, top).lineTo(xT, top).closePath()\n    const z0 = side < 0 ? zIn - s.tw : zIn\n    k.extrude(mat, sh, s.tw, new THREE.Matrix4().makeTranslation(0, 0, z0), 1)\n    const zf = zIn + side * (s.b / 2)\n    const dt = s.tf / 2 / cos\n    strip(k, mat, [xF, y0 - dt, zf], [xT, top - dt, zf], s.b, s.tf)\n    strip(k, mat, [xB, y0 + dt, zf], [xE, yT(xE) - dv + dt, zf], s.b, s.tf)\n    if (o.footCleats ?? true) {\n      boxMM(k, mat, [xF - 0.02, y0, zf - s.b / 2 - 0.03], [xB + 0.02, y0 + 0.015, zf + s.b / 2 + 0.03])\n      anchorBolt(k, xF + 0.04, y0 + 0.015, zf + side * 0.02, 0.016)\n      anchorBolt(k, xB - 0.04, y0 + 0.015, zf + side * 0.02, 0.016)\n    }\n  }\n  // treads: grating with chequer nosing and bolted end plates\n  for (let i = 1; i <= steps - 1; i++) {\n    const yk = y0 + i * r\n    const xa = x0 + (i - 1) * g\n    const xb = Math.min(x0 + i * g + 0.02, xE)\n    gratingBay(k, xa, zc - w / 2 + 0.008, xb, zc + w / 2 - 0.008, yk, 'z', 0.03, false)\n    k.box('safetyYellow', [0.035, 0.034, w - 0.016], [xa + 0.0175, yk - 0.015, zc])\n    for (const side of [-1, 1]) {\n      const z = zc + side * (w / 2 - 0.004)\n      k.box(mat, [xb - xa, 0.06, 0.008], [(xa + xb) / 2, yk - 0.03, z])\n      bolt(k, [(xa + xb) / 2, yk - 0.03, zc + side * (w / 2 + s.tw / 2)], [0, 0, 1], 0.012, s.tw + 0.008)\n    }\n  }\n  // handrails each side on the stringer top flange: 1.0 m above the nosing line\n  for (const side of [-1, 1]) {\n    if ((side < 0 && o.railLeft === false) || (side > 0 && o.railRight === false)) continue\n    const z = zc + side * (w / 2 + s.b / 2)\n    railRun(k, [x0, yN(x0), z], [xE, yN(xE), z], { top: 1.0, toe: false, basePlates: false })\n  }\n  return f\n}\n\n/** Horizontal length of a straight stair of total `rise` with landings every ≤ 3 m (top landing excluded). */\nexport function stairLength(rise: number, angle: number, landingLength = 1.0) {\n  const n = Math.max(1, Math.ceil(rise / ACCESS.stair.landingEvery - 1e-6))\n  return n * stairFlight(rise / n, angle).run + (n - 1) * Math.max(0.8, landingLength)\n}\n\n/** Straight stair from (0, 0) climbing +X, centred on z = 0: flights with intermediate landings on posts, optional top landing. */\nexport function stairRun(k: Kit, rise: number, o: FlightOpts & { landingLength?: number; topLanding?: boolean }) {\n  const n = Math.max(1, Math.ceil(rise / ACCESS.stair.landingEvery - 1e-6))\n  const h = rise / n\n  const Ll = Math.max(0.8, o.landingLength ?? 1.0)\n  const sb = sec(o.stringer ?? 'UPN 200').b\n  const mat = o.mat ?? 'galvanised'\n  let x = 0\n  for (let i = 0; i < n; i++) {\n    x += flight(k, x, i * h, 0, h, { ...o, footCleats: i === 0 }).run\n    const last = i === n - 1\n    if (!last || o.topLanding) {\n      landing(k, x, x + Ll, -o.width / 2 - sb, o.width / 2 + sb, (i + 1) * h, { rails: [false, last, true, true], post: 'SHS 100x100x6.3', mat, flightAtX1: !last })\n      x += Ll\n    }\n  }\n  return x\n}\n\nexport interface LandingOpts {\n  /** guard rails on each edge: x-min, x-max, z-min, z-max */\n  rails?: [boolean, boolean, boolean, boolean]\n  /** post section down to y = 0, or '' for none (landing carried by other steel) */\n  post?: string\n  frame?: string\n  mat?: MatKey\n  /** a flight starts at the x1 edge (its rail posts replace the landing's end posts) */\n  flightAtX1?: boolean\n}\n\n/** Rectangular landing / platform deck: grating top at y over x0..x1, z0..z1, channel frame, optional posts with base plates. */\nexport function landing(k: Kit, x0: number, x1: number, z0: number, z1: number, y: number, o: LandingOpts = {}) {\n  const mat = o.mat ?? 'galvanised'\n  const fr = o.frame ?? 'UPN 160'\n  const f = sec(fr)\n  const tos = y - 0.03\n  gratingBay(k, x0, z0, x1, z1, y, (x1 - x0) > (z1 - z0) ? 'z' : 'x')\n  const e = f.b / 2\n  beamTOS(k, x0, z0 + e, x1, z0 + e, tos, fr, mat)\n  beamTOS(k, x0, z1 - e, x1, z1 - e, tos, fr, mat)\n  beamTOS(k, x0 + e, z0 + f.b, x0 + e, z1 - f.b, tos, fr, mat)\n  beamTOS(k, x1 - e, z0 + f.b, x1 - e, z1 - f.b, tos, fr, mat)\n  const nMid = Math.floor((x1 - x0) / 1.2)\n  for (let i = 1; i <= nMid; i++) {\n    const x = x0 + ((x1 - x0) * i) / (nMid + 1)\n    beamTOS(k, x, z0 + f.b, x, z1 - f.b, tos, 'L60x60x6', mat)\n  }\n  if (o.post) {\n    const p = sec(o.post)\n    const yb = tos - f.d\n    const corners: [number, number][] = [[x0 + p.b / 2, z0 + p.b / 2], [x1 - p.b / 2, z0 + p.b / 2], [x0 + p.b / 2, z1 - p.b / 2], [x1 - p.b / 2, z1 - p.b / 2]]\n    for (const [x, z] of corners) {\n      const t = postBase(k, x, z, p.b, mat)\n      column(k, x, z, t, yb, o.post, mat)\n    }\n    if (yb > 2.2) {\n      // X bracing on the two z faces, single diagonals on the x faces\n      for (const z of [z0 + p.b / 2, z1 - p.b / 2]) {\n        bar(k, [x0 + p.b, 0.3, z], [x1 - p.b, yb - 0.15, z], 'L60x60x6', mat, [0, 0, 1])\n        bar(k, [x1 - p.b, 0.3, z], [x0 + p.b, yb - 0.15, z], 'L60x60x6', mat, [0, 0, 1])\n      }\n      for (const x of [x0 + p.b / 2, x1 - p.b / 2]) bar(k, [x, 0.3, z0 + p.b], [x, yb - 0.15, z1 - p.b], 'L60x60x6', mat, [1, 0, 0])\n    }\n  }\n  const [rx0, rx1, rz0, rz1] = o.rails ?? [true, true, true, true]\n  const i = 0.03\n  // an open x end continues into a stair flight whose rail already has a post there\n  if (rz0) railRun(k, [x0 + i, y, z0 + i], [x1 - i, y, z0 + i], { startPost: rx0, endPost: rx1 || !o.flightAtX1 })\n  if (rz1) railRun(k, [x0 + i, y, z1 - i], [x1 - i, y, z1 - i], { startPost: rx0, endPost: rx1 || !o.flightAtX1 })\n  if (rx0) railRun(k, [x0 + i, y, z0 + i], [x0 + i, y, z1 - i], { startPost: !rz0, endPost: !rz1 })\n  if (rx1) railRun(k, [x1 - i, y, z0 + i], [x1 - i, y, z1 - i], { startPost: !rz0, endPost: !rz1 })\n}\n\n/** Small base plate with four anchors for a post of flange width b at y = 0. Returns plate top. */\nexport function postBase(k: Kit, x: number, z: number, b: number, mat: MatKey = 'galvanised') {\n  const s = b + 0.12\n  k.box(mat, [s, 0.02, s], [x, 0.01, z])\n  for (const a of [-1, 1]) for (const c of [-1, 1]) anchorBolt(k, x + a * (s / 2 - 0.035), 0.02, z + c * (s / 2 - 0.035), 0.016)\n  return 0.02\n}\n\nexport interface LadderOpts {\n  mat?: MatKey\n  /** first hoop above the foot (ISO 14122-4: 2.2–3.0 m) */\n  cageFrom?: number\n  /** stile extension above the top rung */\n  extend?: number\n  cage?: boolean\n  /** wall brackets back to z = -standoff every ~2 m */\n  standoff?: number\n}\n\n/**\n * Fixed ladder in the x-y plane at z = zr (rungs), foot at (x, y0), top rung at y1. The climber and cage are on +Z;\n * the structure is at z = zr - standoff. 65×12 flat-bar stiles at 450 mm, Ø25 rungs at 300 mm, cage Ø720 with 5 bars.\n */\nexport function ladderFlight(k: Kit, x: number, y0: number, y1: number, zr: number, o: LadderOpts = {}) {\n  const mat = o.mat ?? 'galvanised'\n  const half = ACCESS.ladder.width / 2\n  const ext = o.extend ?? 1.1\n  const yTop = y1 + ext\n  for (const s of [-1, 1]) k.box(mat, [0.012, yTop - y0, 0.065], [x + s * half, (y0 + yTop) / 2, zr])\n  const n = Math.max(1, Math.round((y1 - y0) / ACCESS.ladder.rungPitch))\n  const pitch = (y1 - y0) / n\n  for (let i = 1; i <= n; i++) k.rod(mat, [x - half, y0 + i * pitch, zr], [x + half, y0 + i * pitch, zr], 0.0125, 8)\n  // stile ends turned back as grab handles\n  for (const s of [-1, 1]) k.box(mat, [0.012, 0.065, (o.standoff ?? 0.2) + 0.03], [x + s * half, yTop - 0.03, zr - (o.standoff ?? 0.2) / 2])\n  if (o.standoff) {\n    for (let y = y0 + 0.6; y < y1; y += 2.0)\n      for (const s of [-1, 1]) {\n        k.box(mat, [0.08, 0.008, o.standoff], [x + s * half, y, zr - o.standoff / 2 - 0.032])\n        k.box(mat, [0.08, 0.1, 0.008], [x + s * half, y, zr - o.standoff - 0.032])\n      }\n  }\n  const cageFrom = o.cageFrom ?? ACCESS.ladder.cageStart\n  if (o.cage === false || yTop - y0 < cageFrom + 0.6) return\n  const R = ACCESS.ladder.cageDia / 2 + 0.01\n  const cz = zr + R\n  const pts: V3[] = [[x - half, 0, zr]]\n  const N = 10\n  for (let i = 0; i <= N; i++) {\n    const a = Math.PI + (i / N) * Math.PI\n    pts.push([x + Math.cos(a) * R, 0, cz - Math.sin(a) * R])\n  }\n  pts.push([x + half, 0, zr])\n  const hoops: number[] = []\n  for (let y = y0 + cageFrom; y < yTop - 0.2; y += Math.min(ACCESS.ladder.hoopSpacing, 1.5)) hoops.push(y)\n  hoops.push(yTop - 0.05)\n  for (const y of hoops)\n    for (let i = 0; i < pts.length - 1; i++) strip(k, mat, [pts[i][0], y, pts[i][2]], [pts[i + 1][0], y, pts[i + 1][2]], 0.008, 0.05)\n  for (let j = 1; j <= 5; j++) {\n    const a = Math.PI + (j / 6) * Math.PI\n    const px = x + Math.cos(a) * (R - 0.012)\n    const pz = cz - Math.sin(a) * (R - 0.012)\n    strip(k, mat, [px, y0 + cageFrom, pz], [px, yTop - 0.05, pz], 0.05, 0.008, [px - x, 0, pz - cz])\n  }\n}\n\n/**\n * Caged ladder from y0 up to a landing at y1, rungs in the plane z = zr, climber on +Z, structure face at z = zr - standoff.\n * Climbs over 10 m are split into staggered flights of ≤ ACCESS.ladder.maxFlight, 0.9 m apart, with bracketed rest platforms.\n * The top flight is centred on x. Returns the x extent [min, max] of the ladder steel.\n */\nexport function ladderRun(k: Kit, x: number, y0: number, y1: number, zr: number, o: LadderOpts = {}): [number, number] {\n  const H = y1 - y0\n  // single flight up to 10 m of climb, otherwise flights of at most 6 m (EN ISO 14122-4)\n  const n = H <= 10 ? 1 : Math.max(2, Math.ceil(H / ACCESS.ladder.maxFlight - 1e-6))\n  const h = H / n\n  const off = 0.9\n  const so = o.standoff ?? 0.2\n  const mat = o.mat ?? 'galvanised'\n  let lo = x - 0.4\n  const hi = x + 0.4\n  for (let i = 0; i < n; i++) {\n    const xi = x - ((n - 1 - i) % 2) * off\n    lo = Math.min(lo, xi - 0.4)\n    const fy0 = y0 + i * h\n    ladderFlight(k, xi, fy0, fy0 + h, zr, { ...o, standoff: so, cageFrom: i === 0 ? o.cageFrom : 1.2 })\n    if (i === n - 1) break\n    // rest platform under the next flight, open towards the flight below\n    const xn = x - ((n - 2 - i) % 2) * off\n    const dir = xn > xi ? 1 : -1\n    const xa = xn - 0.45 * dir\n    const xb = xn + 0.45 * dir\n    const za = zr - so\n    const zb = zr + 0.78\n    const y = fy0 + h\n    gratingBay(k, Math.min(xa, xb), za, Math.max(xa, xb), zb, y, 'z')\n    for (const xx of [xa, xb]) {\n      beamTOS(k, xx - dir * 0.04, za, xx - dir * 0.04, zb, y - 0.03, 'UPN 100', mat)\n      bar(k, [xx - dir * 0.04, y - 0.75, za], [xx - dir * 0.04, y - 0.12, zb - 0.1], 'L60x60x6', mat, [1, 0, 0])\n      k.box(mat, [0.12, 0.25, 0.012], [xx - dir * 0.04, y - 0.12, za - 0.006])\n    }\n    beamTOS(k, xa, zb - 0.04, xb, zb - 0.04, y - 0.03, 'UPN 100', mat)\n    railRun(k, [xa, y, zb - 0.03], [xb - dir * 0.03, y, zb - 0.03])\n    railRun(k, [xb - dir * 0.03, y, za + 0.03], [xb - dir * 0.03, y, zb - 0.03], { endPost: false })\n  }\n  return [lo, hi]\n}\n"
    },
    {
      "path": "structure/_steel.ts",
      "type": "registry:file",
      "target": "components/deck-twin/structure/_steel.ts",
      "content": "// Shared structural steel details: sections placed by top of steel, plate girders, base/cap/end/fin plates, bolts, gussets, grating bays, ISO 14122 guard rails and gates.\nimport * as THREE from 'three'\nimport { mat4, uvScale, type Kit, type MatKey, type V3 } from '../lib/kit'\nimport { sectionShape } from '../lib/parts'\nimport { ACCESS, SECTIONS, type Section } from '../lib/standards'\nimport { gratingMaterial, hazardStripeMaterial } from '../lib/textures'\n\nexport const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\n/** Section by name. Also accepts welded plate girders \"PG <depth mm>[x<flange mm>]\", e.g. \"PG 1200\" or \"PG 1500x500\". */\nexport function sec(name: string): Section {\n  const m = /^PG\\s*(\\d+)(?:\\s*[xX]\\s*(\\d+))?$/.exec(String(name).trim())\n  if (m) {\n    const d = +m[1] / 1000\n    const b = m[2] ? +m[2] / 1000 : Math.round(d * 0.38 * 20) / 20 // typical\n    const tw = clamp(Math.round(d / 70 / 0.002) * 0.002, 0.012, 0.03) // typical\n    const tf = clamp(Math.round(d / 32 / 0.005) * 0.005, 0.025, 0.07) // typical\n    return { kind: 'I', d, b, tw, tf, r: 0, mass: (2 * b * tf + (d - 2 * tf) * tw) * 7850 }\n  }\n  return SECTIONS[name] ?? SECTIONS['HEB 300']\n}\n\nfunction shapeOf(name: string): THREE.Shape {\n  if (!/^PG/i.test(String(name).trim())) return sectionShape(SECTIONS[name] ? name : 'HEB 300')\n  const { d, b, tw, tf } = sec(name)\n  const sh = new THREE.Shape()\n  sh.moveTo(-b / 2, -d / 2).lineTo(b / 2, -d / 2).lineTo(b / 2, -d / 2 + tf).lineTo(tw / 2, -d / 2 + tf)\n  sh.lineTo(tw / 2, d / 2 - tf).lineTo(b / 2, d / 2 - tf).lineTo(b / 2, d / 2).lineTo(-b / 2, d / 2)\n  sh.lineTo(-b / 2, d / 2 - tf).lineTo(-tw / 2, d / 2 - tf).lineTo(-tw / 2, -d / 2 + tf).lineTo(-b / 2, -d / 2 + tf).closePath()\n  return sh\n}\n\n/** Member of any section (incl. plate girders) between a and b; `up` is the depth (web) direction. */\nexport function bar(k: Kit, a: V3, b: V3, name: string, mat: MatKey = 'structSteel', up: V3 = [0, 1, 0]) {\n  const A = new THREE.Vector3(...a)\n  const z = new THREE.Vector3(...b).sub(A)\n  const L = z.length()\n  if (L < 1e-4) return\n  z.normalize()\n  let u = new THREE.Vector3(...up).normalize()\n  if (Math.abs(u.dot(z)) > 0.99) u = Math.abs(z.y) > 0.9 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0)\n  const x = new THREE.Vector3().crossVectors(u, z).normalize()\n  const y = new THREE.Vector3().crossVectors(z, x).normalize()\n  const s = sec(name)\n  k.extrude(mat, shapeOf(name), L, new THREE.Matrix4().makeBasis(x, y, z).setPosition(A), s.kind === 'CHS' ? 10 : 4)\n}\n\n/** Horizontal beam with its top of steel at `tos`, web vertical. */\nexport function beamTOS(k: Kit, x0: number, z0: number, x1: number, z1: number, tos: number, name: string, mat: MatKey = 'structSteel') {\n  const h = sec(name).d / 2\n  bar(k, [x0, tos - h, z0], [x1, tos - h, z1], name, mat, [0, 1, 0])\n}\n\n/** Vertical column from y0 to y1; `web` is the axis the web (depth) runs along. */\nexport function column(k: Kit, x: number, z: number, y0: number, y1: number, name: string, mat: MatKey = 'structSteel', web: 'x' | 'z' = 'x') {\n  bar(k, [x, y0, z], [x, y1, z], name, mat, web === 'x' ? [1, 0, 0] : [0, 0, 1])\n}\n\n/** Axis-aligned box from min to max corner. */\nexport function boxMM(k: Kit, mat: MatKey, a: V3, b: V3) {\n  const s: V3 = [Math.abs(b[0] - a[0]), Math.abs(b[1] - a[1]), Math.abs(b[2] - a[2])]\n  if (s[0] < 1e-5 || s[1] < 1e-5 || s[2] < 1e-5) return\n  k.box(mat, s, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2])\n}\n\n/** Box oriented along a→b (length), with cross-section w (horizontal, ⟂) × h (in the plane containing `up`). */\nexport function strip(k: Kit, mat: MatKey, a: V3, b: V3, w: number, h: number, up: V3 = [0, 1, 0]) {\n  const A = new THREE.Vector3(...a)\n  const z = new THREE.Vector3(...b).sub(A)\n  const L = z.length()\n  if (L < 1e-5) return\n  z.normalize()\n  let u = new THREE.Vector3(...up).normalize()\n  if (Math.abs(u.dot(z)) > 0.99) u = new THREE.Vector3(1, 0, 0)\n  const x = new THREE.Vector3().crossVectors(u, z).normalize()\n  const y = new THREE.Vector3().crossVectors(z, x).normalize()\n  const m = new THREE.Matrix4().makeBasis(x, y, z).setPosition(A.add(new THREE.Vector3(...b)).multiplyScalar(0.5))\n  k.add(mat, new THREE.BoxGeometry(w, h, L), m)\n}\n\n/** Bolt (stud + nut each side) through a plate pack centred at p, along `axis`. */\nexport function bolt(k: Kit, p: V3, axis: V3, dia = 0.02, grip = 0.03, mat: MatKey = 'galvanised') {\n  const v = new THREE.Vector3(...axis).normalize()\n  const P = new THREE.Vector3(...p)\n  const e = grip / 2 + dia * 0.5\n  k.rod(mat, P.clone().addScaledVector(v, -e - dia * 0.3).toArray() as V3, P.clone().addScaledVector(v, e + dia * 0.3).toArray() as V3, dia / 2, 6)\n  for (const s of [-1, 1]) k.hex('nut', dia * 1.8, dia * 0.8, P.clone().addScaledVector(v, s * (grip / 2 + dia * 0.4)).toArray() as V3, axis)\n}\n\n/** Anchor bolt projecting above a base plate whose top is at y: stud, washer and nut. */\nexport function anchorBolt(k: Kit, x: number, y: number, z: number, dia = 0.024) {\n  k.cyl('galvanised', dia / 2, dia * 3, [x, y + dia * 1.5 - 0.01, z], 'y', 6)\n  k.cyl('galvanised', dia * 1.1, 0.004, [x, y + 0.002, z], 'y', 10)\n  k.hex('nut', dia * 1.8, dia * 0.8, [x, y + 0.004 + dia * 0.4, z], 'y')\n}\n\n/** Base plate (bottom at y) with anchor bolts at the corners and an optional grout bed below. Returns the plate top. */\nexport function basePlate(k: Kit, x: number, y: number, z: number, sx: number, sz: number, t = 0.025, dia = 0.024, mat: MatKey = 'structSteel', grout = 0) {\n  if (grout > 0) k.box('concrete', [sx + 0.08, grout, sz + 0.08], [x, y - grout / 2, z])\n  k.box(mat, [sx, t, sz], [x, y + t / 2, z])\n  const e = Math.max(0.045, dia * 2)\n  for (const a of [-1, 1]) for (const b of [-1, 1]) anchorBolt(k, x + a * (sx / 2 - e), y + t, z + b * (sz / 2 - e), dia)\n  return y + t\n}\n\n/** Triangular gusset plate: right angle at `corner`, legs la along dirA and lb along dirB, thickness t. */\nexport function gusset(k: Kit, corner: V3, dirA: V3, dirB: V3, la: number, lb: number, t = 0.012, mat: MatKey = 'structSteel') {\n  const a = new THREE.Vector3(...dirA).normalize()\n  const b = new THREE.Vector3(...dirB).normalize()\n  const n = new THREE.Vector3().crossVectors(a, b).normalize()\n  const c = Math.min(la, lb) * 0.12 // snipe at the root\n  const sh = new THREE.Shape()\n  sh.moveTo(c, 0).lineTo(la, 0).lineTo(la, lb * 0.1).lineTo(la * 0.1, lb).lineTo(0, lb).lineTo(0, c).closePath()\n  const o = new THREE.Vector3(...corner).addScaledVector(n, -t / 2)\n  k.extrude(mat, sh, t, new THREE.Matrix4().makeBasis(a, b, n).setPosition(o), 1)\n}\n\n/** Full-depth web stiffener pair on a horizontal beam (along `along` = 'x'|'z') at position p (centre of the beam). */\nexport function webStiffeners(k: Kit, p: V3, along: 'x' | 'z', s: Section, mat: MatKey = 'structSteel') {\n  const h = s.d - 2 * s.tf\n  const w = (s.b - s.tw) / 2 - 0.004\n  const t = clamp(s.tw * 1.2, 0.008, 0.025)\n  for (const sg of [-1, 1]) {\n    const off = sg * (s.tw / 2 + w / 2)\n    if (along === 'x') k.box(mat, [t, h, w], [p[0], p[1], p[2] + off])\n    else k.box(mat, [w, h, t], [p[0] + off, p[1], p[2]])\n  }\n}\n\n/**\n * Bolted end-plate connection at the end of a horizontal beam (along x or z): the beam ends at `p` (web centre) and the\n * plate runs from p to p + dir·t, where `dir` = +1/-1 points from the beam out through the plate.\n */\nexport function endPlate(k: Kit, p: V3, along: 'x' | 'z', dir: number, s: Section, mat: MatKey = 'structSteel') {\n  const t = s.d > 0.5 ? 0.025 : 0.02\n  const h = s.d + 0.03\n  const w = s.b + 0.02\n  const c: V3 = along === 'x' ? [p[0] + dir * t / 2, p[1], p[2]] : [p[0], p[1], p[2] + dir * t / 2]\n  k.box(mat, along === 'x' ? [t, h, w] : [w, h, t], c)\n  const rows = Math.max(2, Math.min(6, Math.floor((s.d - 0.06) / 0.09)))\n  const g = Math.min(w * 0.6, 0.14) / 2\n  const dia = s.d > 0.5 ? 0.024 : 0.02\n  for (let i = 0; i < rows; i++) {\n    const y = p[1] - s.d / 2 + 0.05 + ((s.d - 0.1) * i) / (rows - 1)\n    for (const sg of [-1, 1]) {\n      const q: V3 = along === 'x' ? [c[0], y, c[2] + sg * g] : [c[0] + sg * g, y, c[2]]\n      bolt(k, q, along === 'x' ? [1, 0, 0] : [0, 0, 1], dia, t * 2)\n    }\n  }\n}\n\n/** Fin-plate shear connection: plate welded to a supporting web face at `p`, projecting `len` along ±x|z, bolted to the supported web. */\nexport function finPlate(k: Kit, p: V3, along: 'x' | 'z', dir: number, depth: number, len = 0.13, mat: MatKey = 'structSteel') {\n  const h = clamp(depth * 0.7, 0.12, 0.6)\n  const t = 0.01\n  const c: V3 = along === 'x' ? [p[0] + dir * len / 2, p[1], p[2] + 0.011] : [p[0] + 0.011, p[1], p[2] + dir * len / 2]\n  k.box(mat, along === 'x' ? [len, h, t] : [t, h, len], c)\n  const n = Math.max(2, Math.floor(h / 0.075))\n  for (let i = 0; i < n; i++) {\n    const y = p[1] - h / 2 + 0.04 + ((h - 0.08) * i) / Math.max(1, n - 1)\n    const q: V3 = along === 'x' ? [p[0] + dir * (len - 0.045), y, p[2] + 0.006] : [p[0] + 0.006, y, p[2] + dir * (len - 0.045)]\n    bolt(k, q, along === 'x' ? [0, 0, 1] : [1, 0, 0], 0.016, 0.026)\n  }\n}\n\n/**\n * Bar-grating bay from (x0,z0) to (x1,z1), top at `top`: one textured box plus flat-bar banding.\n * `bars` = direction the bearing bars run (they span between the supports).\n */\nexport function gratingBay(k: Kit, x0: number, z0: number, x1: number, z1: number, top: number, bars: 'x' | 'z' = 'z', t = 0.03, band = true) {\n  const sx = Math.abs(x1 - x0)\n  const sz = Math.abs(z1 - z0)\n  if (sx < 0.02 || sz < 0.02) return\n  const g = new THREE.BoxGeometry(sx, t, sz)\n  uvScale(g, sx / 0.3, sz / 0.3)\n  if (bars === 'x') {\n    const uv = g.getAttribute('uv') as THREE.BufferAttribute\n    for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getY(i), uv.getX(i))\n  }\n  const cx = (x0 + x1) / 2\n  const cz = (z0 + z1) / 2\n  k.add(gratingMaterial(), g, mat4([cx, top - t / 2, cz]))\n  if (!band) return\n  const bt = 0.004\n  for (const s of [-1, 1]) {\n    k.box('galvanised', [sx, t, bt], [cx, top - t / 2, cz + s * (sz / 2 - bt / 2)])\n    k.box('galvanised', [bt, t, sz - 2 * bt], [cx + s * (sx / 2 - bt / 2), top - t / 2, cz])\n  }\n}\n\n/** Hazard-striped (black/yellow) band from a to b, w wide and h tall, oriented like strip(). Stripe pitch ~0.2 m. */\nexport function hazardBand(k: Kit, a: V3, b: V3, w: number, h: number, up: V3 = [0, 1, 0]) {\n  const L = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])\n  const A = new THREE.Vector3(...a)\n  const z = new THREE.Vector3(...b).sub(A).normalize()\n  let u = new THREE.Vector3(...up).normalize()\n  if (Math.abs(u.dot(z)) > 0.99) u = new THREE.Vector3(1, 0, 0)\n  const x = new THREE.Vector3().crossVectors(u, z).normalize()\n  const y = new THREE.Vector3().crossVectors(z, x).normalize()\n  // box with its length along local X so the stripe texture repeats along U\n  const g = new THREE.BoxGeometry(L, h, w)\n  uvScale(g, L / 0.25, 1)\n  const m = new THREE.Matrix4().makeBasis(z, y, x.clone().negate()).setPosition(A.add(new THREE.Vector3(...b)).multiplyScalar(0.5))\n  k.add(hazardStripeMaterial(), g, m)\n}\n\n// ------------------------------------------------------------------ guard rails (ISO 14122-3)\n\nexport const RAIL = {\n  top: ACCESS.handrail.height,\n  knee: ACCESS.handrail.kneeRail,\n  toe: ACCESS.handrail.toePlate,\n  postR: 0.0242, // 48.3 mm tube\n  railR: 0.0212, // 42.4 mm\n  kneeR: 0.0169, // 33.7 mm\n  maxPost: ACCESS.handrail.postSpacingMax,\n}\n\nexport interface RailOpts {\n  top?: number\n  knee?: number\n  /** toe plate on the walking edge */\n  toe?: boolean\n  startPost?: boolean\n  endPost?: boolean\n  /** square base plate under each post */\n  basePlates?: boolean\n  mat?: MatKey\n}\n\n/** Straight guard-rail run a → b; a and b are on the walking surface (may slope). */\nexport function railRun(k: Kit, a: V3, b: V3, o: RailOpts = {}) {\n  const { top = RAIL.top, knee = RAIL.knee, toe = true, startPost = true, endPost = true, basePlates = true, mat = 'safetyYellow' } = o\n  const dx = b[0] - a[0]\n  const dy = b[1] - a[1]\n  const dz = b[2] - a[2]\n  const L = Math.hypot(dx, dz)\n  if (L < 0.08) return\n  const n = Math.max(1, Math.ceil(L / RAIL.maxPost))\n  for (let i = 0; i <= n; i++) {\n    if ((i === 0 && !startPost) || (i === n && !endPost)) continue\n    const t = i / n\n    const p: V3 = [a[0] + dx * t, a[1] + dy * t, a[2] + dz * t]\n    k.rod(mat, p, [p[0], p[1] + top, p[2]], RAIL.postR, 8)\n    if (basePlates) k.box(mat, [0.1, 0.01, 0.1], [p[0], p[1] + 0.005, p[2]])\n  }\n  k.rod(mat, [a[0], a[1] + top, a[2]], [b[0], b[1] + top, b[2]], RAIL.railR, 8)\n  k.rod(mat, [a[0], a[1] + knee, a[2]], [b[0], b[1] + knee, b[2]], RAIL.kneeR, 8)\n  if (startPost) k.sphere(mat, RAIL.railR * 1.15, [a[0], a[1] + top, a[2]], undefined, 8)\n  if (endPost) k.sphere(mat, RAIL.railR * 1.15, [b[0], b[1] + top, b[2]], undefined, 8)\n  if (toe) strip(k, mat, [a[0], a[1] + 0.005 + RAIL.toe / 2, a[2]], [b[0], b[1] + 0.005 + RAIL.toe / 2, b[2]], 0.006, RAIL.toe)\n}\n\n/**\n * Guard rail along a polyline of (x, z) points at height y, leaving gaps [s0, s1] (metres along the line) open.\n * `ends` drops the post at the very start / end of the polyline (where a neighbouring run already has one).\n */\nexport function railPath(k: Kit, pts: [number, number][], y: number, gaps: [number, number][] = [], closed = false, o: RailOpts = {}, ends: [boolean, boolean] = [true, true]) {\n  const P = closed ? [...pts, pts[0]] : pts\n  let s = 0\n  for (let i = 0; i < P.length - 1; i++) {\n    const [x0, z0] = P[i]\n    const [x1, z1] = P[i + 1]\n    const L = Math.hypot(x1 - x0, z1 - z0)\n    let pieces: [number, number][] = [[0, L]]\n    for (const [g0, g1] of gaps) {\n      const a = g0 - s\n      const b = g1 - s\n      const next: [number, number][] = []\n      for (const [p0, p1] of pieces) {\n        if (b <= p0 || a >= p1) next.push([p0, p1])\n        else {\n          if (a > p0) next.push([p0, a])\n          if (b < p1) next.push([b, p1])\n        }\n      }\n      pieces = next\n    }\n    const ux = (x1 - x0) / L\n    const uz = (z1 - z0) / L\n    for (const [p0, p1] of pieces) {\n      if (p1 - p0 < 0.1) continue\n      const startPost = !(i === 0 && p0 < 1e-6 && !ends[0] && !closed)\n      const endPost = !(i === P.length - 2 && p1 > L - 1e-6 && !ends[1] && !closed)\n      railRun(k, [x0 + ux * p0, y, z0 + uz * p0], [x0 + ux * p1, y, z0 + uz * p1], { ...o, startPost, endPost })\n    }\n    s += L\n  }\n}\n\n/** Self-closing swing gate (shown closed) across an opening from hinge point h to latch point e on the walking surface. */\nexport function gate(k: Kit, h: V3, e: V3, mat: MatKey = 'safetyYellow') {\n  const d = new THREE.Vector3(e[0] - h[0], 0, e[2] - h[2])\n  const L = d.length()\n  d.normalize()\n  const at = (s: number, y: number): V3 => [h[0] + d.x * s, h[1] + y, h[2] + d.z * s]\n  const g0 = 0.06\n  const g1 = L - 0.05\n  const r = 0.0169\n  // frame: two verticals, top rail, mid rail, diagonal\n  for (const s of [g0, g1]) k.rod(mat, at(s, 0.15), at(s, RAIL.top - 0.02), r, 8)\n  k.rod(mat, at(g0, RAIL.top - 0.02), at(g1, RAIL.top - 0.02), r, 8)\n  k.rod(mat, at(g0, RAIL.knee), at(g1, RAIL.knee), r * 0.9, 8)\n  k.rod(mat, at(g0, 0.15), at(g1, 0.15), r * 0.9, 8)\n  // hinge barrels with spring housings on the hinge post side\n  for (const y of [0.3, 0.9]) {\n    k.cyl('galvanised', 0.022, 0.09, at(0.03, y), 'y', 10)\n    k.box('galvanised', [0.03, 0.012, 0.03], at(0.045, y))\n  }\n  k.cyl('nut', 0.026, 0.05, at(0.03, 0.96), 'y', 10)\n  // latch stop on the far post\n  k.box(mat, [0.05, 0.05, 0.05], at(L - 0.01, RAIL.knee + 0.1))\n}\n\n/** Toe plate / coaming only (no rail), a → b at walking-surface level. */\nexport function toePlate(k: Kit, a: V3, b: V3, h: number = RAIL.toe, mat: MatKey = 'safetyYellow') {\n  strip(k, mat, [a[0], a[1] + h / 2, a[2]], [b[0], b[1] + h / 2, b[2]], 0.008, h)\n}\n"
    }
  ],
  "categories": [
    "structure"
  ],
  "meta": {
    "export": "Jacket",
    "anchor": "base",
    "standards": [
      "ISO 19902",
      "API RP 2A-WSD",
      "DNV-RP-B401",
      "NORSOK N-004"
    ]
  }
}