{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "analyser-shelter",
  "type": "registry:component",
  "title": "Analyser shelter (GRP)",
  "description": "Walk-in GRP analyser house on a steel skid with checker-plate floor, outward-opening doors at both ends (when wide enough), manual call points by the doors, an external shutdown panel, a pressurising ventilation intake stack with rain hood and fan, and a smoke detector inside. Stainless sample conditioning panels (filters, regulators with gauges, rotameters, ball valves, bulkhead tubing) under a front roof canopy, a chained gas-cylinder rack with regulators and pigtails, an Ex wall-mounted AC unit, ventilation louvres, MCT cable entry, gas detector, beacons, door light and signage. The door opens with doorOpen to show the analyser cabinets inside.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "electrical/analyser-shelter.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/electrical/analyser-shelter.tsx",
      "content": "// GRP analyser shelter on a steel skid: outward doors at both ends, sample panels under a canopy, cylinder rack, Ex AC, pressurising ventilation, F&G. IEC 61285, EEMUA 138.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, uvScale, type Built, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { checkerPlate, gauge, liftingLug, member, tagPlate } from '../lib/parts'\nimport { fanGuardMaterial, signMaterial, textMaterial } from '../lib/textures'\nimport { beacon, cableTransit, earthBoss, louvre, louvrePanel } from './_parts'\nimport { grpMaterial, lamp } from './_textures'\n\nexport interface AnalyserShelterProps {\n  /** outside length along X, m */\n  length?: number\n  /** outside width along Z, m */\n  width?: number\n  /** eaves height above deck, m */\n  height?: number\n  /** sample conditioning panels on the front wall */\n  panels?: number\n  /** gas cylinders in the rack */\n  bottles?: number\n  doorOpen?: boolean\n  tag?: string\n}\n\nexport const defaults = {\n  length: 3.6,\n  width: 2.5,\n  height: 3.0,\n  panels: 2,\n  bottles: 4,\n  doorOpen: false,\n  tag: 'AH-3001',\n}\n\nconst T = 0.06 // GRP sandwich wall\nconst HB = 0.18 // skid top / floor\nconst front = new THREE.Quaternion()\n\nexport function AnalyserShelter(props: AnalyserShelterProps & GroupProps) {\n  const { length, width, height, panels, bottles, doorOpen, tag, ...group } = { ...defaults, ...props }\n  const b = useMemo(() => build({ length, width, height, panels, bottles, doorOpen, tag }), [length, width, height, panels, bottles, doorOpen, tag])\n  useEffect(() => () => [b.shell, b.leaf].forEach((x) => x.geometry.dispose()), [b])\n  return (\n    <group {...group}>\n      <KitMesh data={b.shell} />\n      <group position={b.hinge}>\n        <group rotation={[0, doorOpen ? 1.5 : 0, 0]}>\n          <KitMesh data={b.leaf} />\n        </group>\n      </group>\n      {b.end && (\n        <group position={b.end} rotation={[0, Math.PI / 2, 0]}>\n          <group rotation={[0, doorOpen ? 1.5 : 0, 0]}>\n            <KitMesh data={b.leaf} />\n          </group>\n        </group>\n      )}\n    </group>\n  )\n}\n\nexport default AnalyserShelter\n\nfunction build(p: typeof defaults) {\n  const SX = Math.min(8, Math.max(2.4, p.length))\n  const SZ = Math.min(4, Math.max(1.8, p.width))\n  const SY = Math.min(4, Math.max(2.7, p.height))\n  const k = new Kit()\n  const grp = grpMaterial()\n  const trim: MatKey = paint('#d9d5c6', { metalness: 0.05, roughness: 0.4, clearcoat: 0.3, weather: 0.6 })\n  const S: MatKey = 'stainless'\n\n  // skid: UPN 160 perimeter and cross members, checker-plate floor, lifting lugs, earth boss\n  for (const s of [-1, 1]) {\n    member(k, [-SX / 2 - 0.1, 0.08, s * (SZ / 2 + 0.05 - 0.035)], [SX / 2 + 0.1, 0.08, s * (SZ / 2 + 0.05 - 0.035)], 'UPN 160', 'structSteelDark', [0, 1, 0])\n    member(k, [s * (SX / 2 + 0.1 - 0.035), 0.08, -SZ / 2 + 0.05], [s * (SX / 2 + 0.1 - 0.035), 0.08, SZ / 2 - 0.05], 'UPN 160', 'structSteelDark', [0, 1, 0])\n  }\n  checkerPlate(k, [0, HB, 0], SX + 0.2, SZ + 0.1)\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) liftingLug(k, [sx * (SX / 2 - 0.3), HB, sz * (SZ / 2 + 0.03)], 'x', 1, 'structSteelDark')\n  earthBoss(k, [SX / 2 + 0.1, 0.08, SZ / 2 - 0.3], 'x')\n\n  // walls with a door opening on +Z\n  const dw = 0.9\n  const dh = 2.1\n  const du = -SX / 2 + 0.4 + dw / 2\n  const wallH = SY - HB\n  const yw = HB + wallH / 2\n  const panel = (w: number, h: number, c: V3, q?: THREE.Quaternion) => {\n    const g = new THREE.BoxGeometry(w, h, T)\n    uvScale(g, w / 0.8, h / 0.8)\n    k.add(grp, g, new THREE.Matrix4().compose(new THREE.Vector3(...c), q ?? new THREE.Quaternion(), new THREE.Vector3(1, 1, 1)))\n  }\n  const ry = (a: number) => new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n  panel(SX, wallH, [0, yw, -SZ / 2 + T / 2])\n  // end walls: -X plain, +X with the second (escape) door when the shelter is wide enough\n  const endDoor = SZ >= 2.3\n  const zd = SZ / 2 - 0.65\n  panel(SZ - 2 * T, wallH, [-(SX / 2 - T / 2), yw, 0], ry(Math.PI / 2))\n  if (endDoor) {\n    const z0 = -SZ / 2 + T\n    const z1 = SZ / 2 - T\n    const e0 = zd - dw / 2\n    const e1 = zd + dw / 2\n    panel(e0 - z0, wallH, [SX / 2 - T / 2, yw, (z0 + e0) / 2], ry(Math.PI / 2))\n    panel(z1 - e1, wallH, [SX / 2 - T / 2, yw, (e1 + z1) / 2], ry(Math.PI / 2))\n    panel(dw, SY - HB - dh, [SX / 2 - T / 2, HB + dh + (SY - HB - dh) / 2, zd], ry(Math.PI / 2))\n    for (const s of [-1, 1]) k.box('stainless', [T + 0.02, dh, 0.04], [SX / 2 - T / 2, HB + dh / 2, zd + s * (dw / 2 + 0.02)])\n    k.box('stainless', [T + 0.02, 0.04, dw + 0.08], [SX / 2 - T / 2, HB + dh + 0.02, zd])\n    k.rbox('stainless', [0.09, 0.1, 0.24], 0.025, [SX / 2 + 0.045, HB + dh + 0.18, zd])\n    k.box('lampLens', [0.01, 0.05, 0.2], [SX / 2 + 0.092, HB + dh + 0.18, zd])\n  } else panel(SZ - 2 * T, wallH, [SX / 2 - T / 2, yw, 0], ry(Math.PI / 2))\n  const zF = SZ / 2 - T / 2\n  const a0 = du - dw / 2\n  const a1 = du + dw / 2\n  panel(a0 + SX / 2, wallH, [(-SX / 2 + a0) / 2, yw, zF])\n  panel(SX / 2 - a1, wallH, [(a1 + SX / 2) / 2, yw, zF])\n  panel(dw, SY - HB - dh, [du, HB + dh + (SY - HB - dh) / 2, zF])\n  // panel joint cover strips, corner profiles, door frame\n  for (let x = -SX / 2 + 1.2; x < SX / 2 - 0.1; x += 1.2) if (Math.abs(x - du) > dw / 2 + 0.1) k.box(trim, [0.05, wallH, 0.008], [x, yw, SZ / 2 + 0.004])\n  for (let x = -SX / 2 + 1.2; x < SX / 2 - 0.1; x += 1.2) k.box(trim, [0.05, wallH, 0.008], [x, yw, -SZ / 2 - 0.004])\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.box(trim, [0.08, wallH, 0.08], [sx * (SX / 2 - 0.03), yw, sz * (SZ / 2 - 0.03)])\n  for (const s of [-1, 1]) k.box(S, [0.04, dh, T + 0.02], [du + s * (dw / 2 + 0.02), HB + dh / 2, zF])\n  k.box(S, [dw + 0.08, 0.04, T + 0.02], [du, HB + dh + 0.02, zF])\n  k.box(S, [dw, 0.01, T + 0.02], [du, HB + 0.005, zF])\n\n  // roof with front canopy over the sample panels, drip edge\n  const ov = 0.8\n  k.box(grp, [SX + 0.2, 0.08, SZ + ov + 0.1], [0, SY + 0.04, ov / 2])\n  k.box(trim, [SX + 0.24, 0.1, 0.03], [0, SY + 0.03, SZ / 2 + ov + 0.065])\n  for (const s of [-1, 1]) k.rod('stainless', [s * (SX / 2 - 0.05), SY - 0.4, SZ / 2], [s * (SX / 2 - 0.05), SY - 0.01, SZ / 2 + ov], 0.018, 8)\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) liftingLug(k, [sx * (SX / 2 - 0.2), SY + 0.08, sz * (SZ / 2 - 0.2)], 'x', 0.8, S)\n  beacon(k, [SX / 2 - 0.25, SY + 0.08, SZ / 2 + 0.2], lamp('red'))\n  beacon(k, [SX / 2 - 0.5, SY + 0.08, SZ / 2 + 0.2], lamp('amber'))\n\n  // door dressing: light, signs, tag\n  k.rbox(S, [0.24, 0.1, 0.09], 0.025, [du, HB + dh + 0.18, SZ / 2 + 0.045])\n  k.box('lampLens', [0.2, 0.05, 0.01], [du, HB + dh + 0.18, SZ / 2 + 0.092])\n  k.plane(textMaterial('ANALYSER HOUSE', '#1f4e9a', '#ffffff', 512, 96), 0.9, 0.17, [a1 + 0.6 > SX / 2 - 0.3 ? du : a1 + 0.65, SY - 0.22, SZ / 2 + 0.004], front)\n  k.plane(signMaterial('noentry'), 0.36, 0.18, [a0 - 0.25, HB + 1.25, SZ / 2 + 0.002], front)\n\n  // sample conditioning panels between the door and the +X end\n  const nP = Math.max(0, Math.min(4, Math.round(p.panels)))\n  const pw = 0.7\n  const room = SX / 2 - 0.15 - (a1 + 0.15)\n  const nFit = Math.min(nP, Math.floor(room / (pw + 0.05)))\n  for (let i = 0; i < nFit; i++) {\n    const x = a1 + 0.15 + pw / 2 + i * (pw + 0.05)\n    scsPanel(k, x, SZ / 2, pw)\n  }\n\n  // gas bottle rack against the -X end wall\n  bottleRack(k, -SX / 2, SZ, Math.max(0, Math.min(8, Math.round(p.bottles))))\n\n  // Ex wall AC unit on the +X end, ventilation louvre and MCT cable entry on the back wall, gas detector\n  {\n    const x = SX / 2\n    const c: V3 = [x + 0.22, HB + 1.55, endDoor ? -SZ / 2 + 0.45 : -SZ * 0.15]\n    k.rbox(paint('#e3e1d8', { roughness: 0.4, metalness: 0.1 }), [0.44, 0.8, 0.7], 0.02, c)\n    louvrePanel(k, 0.6, 0.3, [c[0] + 0.221, c[1] + 0.2, c[2]], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2), '#cfccc2')\n    k.disc(fanGuardMaterial(), 0.16, [c[0] + 0.222, c[1] - 0.15, c[2]], 'x', 28)\n    k.disc('rubberBlack', 0.155, [c[0] + 0.218, c[1] - 0.15, c[2]], 'x', 28)\n    for (const dz of [-0.25, 0.25]) k.box('structSteelDark', [0.24, 0.05, 0.05], [x + 0.12, c[1] - 0.425, c[2] + dz])\n    k.tube('copper', [[c[0] - 0.1, c[1] - 0.4, c[2] + 0.3], [c[0] - 0.1, c[1] - 0.55, c[2] + 0.3], [x + 0.01, c[1] - 0.55, c[2] + 0.3]], 0.008, 6)\n    k.rbox(S, [0.12, 0.16, 0.08], 0.01, [x + 0.04, c[1] + 0.6, c[2] + 0.45])\n  }\n  k.with(new THREE.Matrix4().makeRotationY(Math.PI).setPosition(0, 0, -SZ / 2), () => {\n    louvre(k, -SX * 0.25, SY - 0.6, 0.5, 0.35, 'stainless', trim)\n    louvre(k, SX * 0.25, HB + 0.4, 0.5, 0.35, 'stainless', trim)\n    cableTransit(k, 0, HB + 0.5, 4, 2, 'stainless')\n    for (let c = 0; c < 4; c++) k.spline('cable', [[-0.11 + c * 0.075, HB + 0.5, 0.13], [-0.11 + c * 0.075, HB + 0.5, 0.35], [-0.11 + c * 0.075, 0.12, 0.6], [-0.11 + c * 0.075, 0.05, 1.1]], 0.014, 6, 16)\n  })\n  // gas detector (IR point) beside the door\n  k.box(S, [0.1, 0.12, 0.06], [a0 - 0.25, SY - 0.6, SZ / 2 + 0.03])\n  k.cyl(S, 0.03, 0.12, [a0 - 0.25, SY - 0.72, SZ / 2 + 0.05], 'y', 14)\n  k.cyl('rubberBlack', 0.032, 0.03, [a0 - 0.25, SY - 0.79, SZ / 2 + 0.05], 'y', 14)\n  tagPlate(k, p.tag, [a0 - 0.25, HB + 1.6, SZ / 2 + 0.002], 'z', 0.16)\n\n  // interior: analyser cabinets along the back wall, ceiling light, floor\n  k.box(paint('#8f9a92', { roughness: 0.8, metalness: 0.1 }), [SX - 2 * T, 0.02, SZ - 2 * T], [0, HB + 0.01, 0])\n  // manual call points beside each door, external shutdown panel (power / sample / carrier gas isolation)\n  const mac = (pos: V3, q: THREE.Quaternion) => k.at(pos, q, () => {\n    k.box('actuatorRed', [0.1, 0.1, 0.05], [0, 0, 0.025])\n    k.box('glass', [0.07, 0.07, 0.004], [0, 0, 0.052])\n  })\n  mac([a1 + 0.2, HB + 1.4, SZ / 2], front)\n  if (endDoor) mac([SX / 2, HB + 1.4, zd - dw / 2 - 0.2], ry(Math.PI / 2))\n  k.rbox('stainless', [0.3, 0.4, 0.15], 0.02, [a0 - 0.25, HB + 0.85, SZ / 2 + 0.075])\n  k.cyl('actuatorRed', 0.03, 0.04, [a0 - 0.25, HB + 0.9, SZ / 2 + 0.17], 'z', 14)\n  k.box('actuatorYellow', [0.1, 0.1, 0.004], [a0 - 0.25, HB + 0.9, SZ / 2 + 0.151])\n  k.plane(textMaterial('SHUTDOWN', '#c3221a', '#ffffff', 256, 64), 0.2, 0.05, [a0 - 0.25, HB + 0.72, SZ / 2 + 0.152], front)\n  // pressurising ventilation: intake stack with rain hood drawn from a safe area, fan, and an overpressure damper\n  {\n    const sx = -SX / 2 + 0.5\n    const sz = -SZ / 2 + 0.45\n    k.box('galvanised', [0.4, 0.5, 0.4], [sx, SY + 0.33, sz])\n    k.cyl('galvanised', 0.12, 1.4, [sx, SY + 1.28, sz], 'y', 20, undefined, true)\n    k.cyl('galvanised', 0.11, 1.38, [sx, SY + 1.28, sz], 'y', 20, undefined, true)\n    k.cyl('galvanised', 0.24, 0.04, [sx, SY + 2.12, sz], 'y', 24, 0.05) // rain hood\n    for (let i = 0; i < 3; i++) k.rod('galvanised', [sx + 0.1 * Math.cos(i * 2.1), SY + 1.98, sz + 0.1 * Math.sin(i * 2.1)], [sx + 0.13 * Math.cos(i * 2.1), SY + 2.1, sz + 0.13 * Math.sin(i * 2.1)], 0.008, 6)\n    k.rbox('motorPaint', [0.25, 0.25, 0.3], 0.03, [sx + 0.32, SY + 0.25, sz])\n  }\n  // smoke detector inside\n  k.cyl('nameplate', 0.05, 0.025, [SX * 0.2, SY - 0.0125, 0], 'y', 20)\n  const cab = paint('#d6d8d4', { metalness: 0.2, roughness: 0.45 })\n  const nC = Math.max(1, Math.floor((SX - 2 * T - 0.4) / 0.85))\n  for (let i = 0; i < nC; i++) {\n    const x = -((nC - 1) * 0.85) / 2 + i * 0.85\n    const z = -SZ / 2 + T + 0.35\n    k.box(cab, [0.8, 2.0, 0.6], [x, HB + 1.02, z])\n    k.box('glass', [0.6, 0.9, 0.01], [x, HB + 1.45, z + 0.305])\n    k.box('rubberBlack', [0.5, 0.25, 0.01], [x, HB + 1.6, z + 0.302])\n    k.box(lamp('green'), [0.3, 0.12, 0.004], [x, HB + 1.62, z + 0.308])\n    for (let j = 0; j < 3; j++) k.box('nut', [0.5, 0.08, 0.01], [x, HB + 0.5 + j * 0.15, z + 0.302])\n  }\n  k.box(paint('#ffffff', { emissive: '#fff3dc', emissiveIntensity: 1.4, roughness: 0.4, metalness: 0, clearcoat: 0, weather: 0 }), [1.2, 0.03, 0.15], [0, SY - 0.03, 0])\n\n  return { shell: k.build(), leaf: leafBuilt(dw, dh, grp), hinge: [a1, HB, SZ / 2] as V3, end: endDoor ? ([SX / 2, HB, zd - dw / 2] as V3) : null }\n}\n\n/** Stainless sample conditioning panel on the front wall: filters, regulators, rotameters, valves, tubing. */\nfunction scsPanel(k: Kit, x: number, zw: number, w: number) {\n  const S: MatKey = 'stainless'\n  const h = 1.3\n  const y0 = HB + 0.5\n  const z = zw + 0.06\n  k.box(S, [w, h, 0.006], [x, y0 + h / 2, z])\n  for (const dy of [0.1, h - 0.1]) for (const s of [-1, 1]) k.box('structSteelDark', [0.04, 0.04, 0.06], [x + s * (w / 2 - 0.05), y0 + dy, zw + 0.03])\n  // incoming sample line from below, filter, regulator + gauge, rotameters, outlet bulkhead into the shelter\n  const xs = [x - w * 0.3, x - w * 0.05, x + w * 0.2]\n  for (const [i, xi] of xs.entries()) {\n    const yf = y0 + 0.25\n    k.cyl(S, 0.035, 0.18, [xi, yf, z + 0.06], 'y', 16) // filter bowl\n    k.hex(S, 0.07, 0.03, [xi, yf + 0.1, z + 0.06])\n    k.rbox(S, [0.07, 0.07, 0.07], 0.01, [xi, yf + 0.3, z + 0.06]) // regulator\n    k.cyl(S, 0.02, 0.06, [xi, yf + 0.37, z + 0.06], 'y', 12)\n    gauge(k, [xi, yf + 0.44, z + 0.07], 'z', 0.06, 'bar', i === 0 ? 10 : 16)\n    k.box(S, [0.05, 0.22, 0.04], [xi, yf + 0.68, z + 0.04]) // rotameter\n    k.box('glass', [0.025, 0.18, 0.01], [xi, yf + 0.68, z + 0.065])\n    k.cyl('actuatorYellow', 0.008, 0.01, [xi, yf + 0.65, z + 0.065], 'y', 8)\n    k.tube('tubing', [[xi, y0 - 0.4, z + 0.06], [xi, yf - 0.09, z + 0.06]], 0.006, 6)\n    k.tube('tubing', [[xi, yf + 0.11, z + 0.06], [xi, yf + 0.265, z + 0.06]], 0.006, 6)\n    k.tube('tubing', [[xi, yf + 0.335, z + 0.06], [xi, yf + 0.37, z + 0.06], [xi + 0.06, yf + 0.37, z + 0.06], [xi + 0.06, yf + 0.57, z + 0.04], [xi, yf + 0.57, z + 0.04]], 0.005, 6, 0.02)\n    k.tube('tubing', [[xi, yf + 0.79, z + 0.04], [xi, yf + 0.9, z + 0.04], [xi, yf + 0.9, zw + 0.005]], 0.005, 6, 0.02)\n    k.hex(S, 0.02, 0.02, [xi, yf + 0.9, zw + 0.012], 'z')\n    // ball valve with lever on the inlet\n    k.rbox(S, [0.03, 0.04, 0.03], 0.005, [xi, y0 + 0.06, z + 0.06])\n    k.box('actuatorBlue', [0.012, 0.012, 0.07], [xi, y0 + 0.09, z + 0.09])\n  }\n  k.plane(textMaterial('SCS', '#e9eae6', '#222222', 256, 64), 0.16, 0.04, [x, y0 + h - 0.06, z + 0.004], front)\n  // sample tubing bundle down to the deck\n  k.spline('tubing', [[xs[0], y0 - 0.4, z + 0.06], [x, 0.4, z + 0.2], [x + 0.1, 0.12, z + 0.5]], 0.02, 8, 16)\n}\n\n/** Gas cylinder rack (frame, chains, regulators, pigtails, rain roof) against the -X wall. */\nfunction bottleRack(k: Kit, xw: number, SZ: number, n: number) {\n  if (!n) return\n  const cols = ['#8a8f93', '#2b2d2f', '#3b6ea8', '#c8a23a', '#8a8f93', '#2b2d2f', '#3b6ea8', '#6a8f4a']\n  const r = 0.115\n  const pitch = 0.28\n  const L = n * pitch + 0.1\n  const xr = xw - 0.4\n  const z0 = -L / 2 + SZ * 0.05\n  const G: MatKey = 'galvanised'\n  const B0 = 0.015\n  k.box(G, [0.62, 0.015, L + 0.1], [xr, 0.0075, z0 + L / 2])\n  for (const dz of [-L / 2, L / 2]) for (const dx of [-0.25, 0.25]) member(k, [xr + dx, B0, z0 + L / 2 + dz], [xr + dx, B0 + 1.9, z0 + L / 2 + dz], 'L50x50x5', G)\n  for (const y of [B0 + 0.05, B0 + 1.0, B0 + 1.35, B0 + 1.9]) for (const dx of [-0.25, 0.25]) k.box(G, [0.05, 0.04, L], [xr + dx, y, z0 + L / 2])\n  k.box(G, [0.6, 0.04, L + 0.1], [xr, B0 + 1.95, z0 + L / 2])\n  k.box(G, [0.04, 1.9, 0.04], [xw - 0.02, B0 + 0.95, z0 + L / 2])\n  for (let i = 0; i < n; i++) {\n    const z = z0 + 0.05 + pitch * (i + 0.5)\n    const col = paint(cols[i % cols.length], { roughness: 0.4, metalness: 0.3, clearcoat: 0.4 })\n    k.cyl(col, r, 1.3, [xr, B0 + 0.7, z], 'y', 20)\n    k.dome(col, r, [xr, B0 + 1.35, z], 'y', Math.PI / 2, 0.8, 20)\n    k.dome(col, r, [xr, B0 + 0.05, z], '-y', Math.PI / 2, 0.15, 20)\n    k.cyl('brass', 0.025, 0.08, [xr, B0 + 1.5, z], 'y', 10)\n    k.rbox('brass', [0.06, 0.05, 0.05], 0.008, [xr, B0 + 1.56, z])\n    gauge(k, [xr + 0.05, B0 + 1.6, z], 'x', 0.05, 'bar', 250)\n    k.tube('tubing', [[xr, B0 + 1.585, z], [xr, B0 + 1.68, z], [xw - 0.05, B0 + 1.68, z], [xw - 0.05, B0 + 1.68, z], [xw - 0.005, B0 + 1.68, z]], 0.005, 6, 0.03)\n    for (const y of [B0 + 1.0, B0 + 1.35]) k.torus('nut', r + 0.008, 0.005, [xr, y - 0.02, z], 'y', 4, 20)\n  }\n  k.plane(signMaterial('noentry'), 0.4, 0.2, [xr - 0.27, B0 + 1.15, z0 + L / 2], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -Math.PI / 2))\n}\n\n/** GRP door leaf with vision panel, hinge at the origin on the outer face, leaf along -X. */\nfunction leafBuilt(w: number, h: number, grp: MatKey): Built {\n  const k = new Kit()\n  const t = 0.05\n  const zc = -0.03\n  k.box(grp, [w - 0.01, h - 0.01, t], [-w / 2, h / 2, zc])\n  k.box('glass', [0.25, 0.35, 0.01], [-w / 2, h * 0.7, zc + t / 2 + 0.002])\n  k.box('stainless', [0.28, 0.38, 0.004], [-w / 2, h * 0.7, zc + t / 2 + 0.001])\n  k.box('stainless', [w * 0.9, 0.2, 0.004], [-w / 2, 0.12, zc + t / 2 + 0.002])\n  k.rod('stainless', [-w + 0.08, 0.9, zc + t / 2 + 0.05], [-w + 0.08, 1.25, zc + t / 2 + 0.05], 0.012, 8)\n  for (const y of [0.93, 1.22]) k.rod('stainless', [-w + 0.08, y, zc + t / 2], [-w + 0.08, y, zc + t / 2 + 0.05], 0.008, 6)\n  for (const y of [0.25, h / 2, h - 0.25]) k.cyl('nut', 0.014, 0.1, [-0.004, y, -0.005], 'y', 10)\n  k.rod('stainless', [-0.12, 1.0, zc - t / 2 - 0.06], [-w + 0.08, 1.0, zc - t / 2 - 0.06], 0.014, 8)\n  return k.build()\n}\n"
    },
    {
      "path": "electrical/_parts.ts",
      "type": "registry:file",
      "target": "components/deck-twin/electrical/_parts.ts",
      "content": "// Shared electrical details: profiled cladding sheet, cable glands, earth bosses, louvres, multi-cable transits, beacons.\nimport * as THREE from 'three'\nimport { eul, type Kit, type MatKey, type V3 } from '../lib/kit'\nimport { louvreMaterial } from '../lib/textures'\n\n/** Trapezoidal (vertical-rib) profiled sheet: x ∈ [-w/2, w/2], y ∈ [0, h], ribs stand out to +Z by `depth`. */\nexport function ribbedPanel(w: number, h: number, pitch = 0.25, depth = 0.035, back = true): THREE.BufferGeometry {\n  const n = Math.max(1, Math.round(w / pitch))\n  const p = w / n\n  const prof: [number, number][] = [[0, 0], [0.3, 0], [0.4, depth], [0.6, depth], [0.7, 0], [1, 0]]\n  const xs: [number, number][] = []\n  for (let i = 0; i < n; i++)\n    for (let j = 0; j < prof.length - (i < n - 1 ? 1 : 0); j++) {\n      if (i > 0 && j === 0) continue\n      xs.push([-w / 2 + (i + prof[j][0]) * p, prof[j][1]])\n    }\n  const pos: number[] = []\n  const nor: number[] = []\n  const uv: number[] = []\n  const push = (q: number[][], nx: number, nz: number) => {\n    for (const v of q) {\n      pos.push(v[0], v[1], v[2])\n      nor.push(nx, 0, nz)\n      uv.push((v[0] + w / 2) / w, v[1] / h)\n    }\n  }\n  for (let i = 0; i < xs.length - 1; i++) {\n    const [x0, z0] = xs[i]\n    const [x1, z1] = xs[i + 1]\n    const l = Math.hypot(x1 - x0, z1 - z0) || 1\n    push([[x0, 0, z0], [x1, 0, z1], [x1, h, z1], [x0, 0, z0], [x1, h, z1], [x0, h, z0]], -(z1 - z0) / l, (x1 - x0) / l)\n  }\n  if (back) push([[w / 2, 0, -0.002], [-w / 2, 0, -0.002], [-w / 2, h, -0.002], [w / 2, 0, -0.002], [-w / 2, h, -0.002], [w / 2, h, -0.002]], 0, -1)\n  const g = new THREE.BufferGeometry()\n  g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3))\n  g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3))\n  g.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))\n  return g\n}\n\n/** Ex e / Ex d cable gland with a shroud, axis along `axis` (gland body from pos outwards), cable size by OD. */\nexport function cableGland(k: Kit, pos: V3, axis: 'y' | '-y' | 'x' | '-x' | 'z' | '-z', cableOD = 0.025) {\n  const d: V3 = axis === 'y' ? [0, 1, 0] : axis === '-y' ? [0, -1, 0] : axis === 'x' ? [1, 0, 0] : axis === '-x' ? [-1, 0, 0] : axis === 'z' ? [0, 0, 1] : [0, 0, -1]\n  const at = (t: number): V3 => [pos[0] + d[0] * t, pos[1] + d[1] * t, pos[2] + d[2] * t]\n  const r = cableOD / 2\n  k.hex('brass', r * 3.4, 0.012, at(0.006), axis)\n  k.cyl('brass', r * 1.55, 0.03, at(0.027), axis, 12)\n  k.hex('brass', r * 3.0, 0.014, at(0.049), axis)\n  k.cyl('rubberBlack', r * 1.45, 0.035, at(0.073), axis, 12, r * 1.1)\n  return at(0.09)\n}\n\n/** Brass earthing boss on a face (normal axis) with a bolted green/yellow bond lug. */\nexport function earthBoss(k: Kit, pos: V3, axis: 'x' | '-x' | 'z' | '-z') {\n  const s = axis.startsWith('-') ? -1 : 1\n  const off = (t: number): V3 => (axis.endsWith('x') ? [pos[0] + s * t, pos[1], pos[2]] : [pos[0], pos[1], pos[2] + s * t])\n  k.cyl('brass', 0.022, 0.03, off(0.015), axis, 12)\n  k.hex('nut', 0.028, 0.012, off(0.036), axis)\n  k.box('copper', axis.endsWith('x') ? [0.006, 0.05, 0.02] : [0.02, 0.05, 0.006], [off(0.032)[0], pos[1] - 0.025, off(0.032)[2]])\n}\n\n/** Weather louvre in a wall frame (outer face at z = 0, +Z out): frame, angled blades, bird mesh behind. */\nexport function louvre(k: Kit, u: number, y: number, w: number, h: number, frame: MatKey = 'structSteelDark', blade: MatKey = 'cladding') {\n  k.box(frame, [w + 0.08, 0.04, 0.08], [u, y + h / 2 + 0.02, 0.03])\n  k.box(frame, [w + 0.08, 0.04, 0.08], [u, y - h / 2 - 0.02, 0.03])\n  for (const s of [-1, 1]) k.box(frame, [0.04, h + 0.08, 0.08], [u + s * (w / 2 + 0.02), y, 0.03])\n  k.box('rubberBlack', [w, h, 0.01], [u, y, 0.004])\n  const n = Math.max(3, Math.round(h / 0.07))\n  for (let i = 0; i < n; i++) k.box(blade, [w, 0.075, 0.006], [u, y - h / 2 + (i + 0.5) * (h / n), 0.04], eul(0.7, 0, 0))\n}\n\n/** Flat louvre panel (texture only), facing +normal, for small enclosures. */\nexport function louvrePanel(k: Kit, w: number, h: number, pos: V3, q: THREE.Quaternion, color = '#c9ccc8') {\n  const g = new THREE.PlaneGeometry(w, h)\n  const uv = g.getAttribute('uv') as THREE.BufferAttribute\n  for (let i = 0; i < uv.count; i++) uv.setY(i, uv.getY(i) * Math.max(1, h / 0.08))\n  k.add(louvreMaterial(color), g, new THREE.Matrix4().compose(new THREE.Vector3(...pos), q, new THREE.Vector3(1, 1, 1)))\n}\n\n/** Multi-cable transit frame (MCT) on a wall face at z = 0, with packed rubber modules. */\nexport function cableTransit(k: Kit, u: number, y: number, cols = 6, rows = 2, frame: MatKey = 'structSteelDark') {\n  const w = cols * 0.075 + 0.08\n  const h = rows * 0.09 + 0.05\n  k.box(frame, [w, h, 0.12], [u, y, 0.06])\n  for (let r = 0; r < rows; r++)\n    for (let c = 0; c < cols; c++) k.box(c % 2 === r % 2 ? 'rubberBlack' : 'gasket', [0.07, 0.08, 0.02], [u - ((cols - 1) * 0.075) / 2 + c * 0.075, y - ((rows - 1) * 0.09) / 2 + r * 0.09, 0.125])\n  k.box('stainless', [w - 0.04, 0.02, 0.03], [u, y + h / 2 - 0.01, 0.125])\n  return { w, h }\n}\n\n/** Status beacon (sounder base + coloured lens), standing on +Y at pos. */\nexport function beacon(k: Kit, pos: V3, lens: MatKey) {\n  k.cyl('plasticGrey', 0.06, 0.06, [pos[0], pos[1] + 0.03, pos[2]], 'y', 14)\n  k.cyl(lens, 0.05, 0.11, [pos[0], pos[1] + 0.115, pos[2]], 'y', 16)\n  k.dome(lens, 0.05, [pos[0], pos[1] + 0.17, pos[2]], 'y', Math.PI / 2, 0.6, 16)\n}\n"
    },
    {
      "path": "electrical/_textures.ts",
      "type": "registry:file",
      "target": "components/deck-twin/electrical/_textures.ts",
      "content": "// Canvas materials for electrical equipment: rating plates, switchgear / MCC fronts, room finishes, indicator lamps.\nimport * as THREE from 'three'\nimport { paint } from '../lib/materials'\n\ntype G = CanvasRenderingContext2D\nconst cache = new Map<string, THREE.MeshStandardMaterial>()\n\nfunction make(key: string, w: number, h: number, draw: (g: G, w: number, h: number) => void, o: { repeat?: boolean; roughness?: number; metalness?: number; emissive?: number } = {}) {\n  const hit = cache.get(key)\n  if (hit) return hit\n  const c = document.createElement('canvas')\n  c.width = w\n  c.height = h\n  draw(c.getContext('2d')!, w, h)\n  const tex = new THREE.CanvasTexture(c)\n  tex.colorSpace = THREE.SRGBColorSpace\n  tex.anisotropy = 8\n  if (o.repeat) tex.wrapS = tex.wrapT = THREE.RepeatWrapping\n  const m = new THREE.MeshStandardMaterial({ map: tex, metalness: o.metalness ?? 0.1, roughness: o.roughness ?? 0.55 })\n  if (o.emissive) {\n    m.emissive = new THREE.Color('#ffffff')\n    m.emissiveMap = tex\n    m.emissiveIntensity = o.emissive\n  }\n  m.name = `el-${key}`\n  cache.set(key, m)\n  return m\n}\n\nfunction rng(seed: number) {\n  let s = seed >>> 0\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296\n}\nfunction speckle(g: G, w: number, h: number, base: string, n: number, dark: string, light: string, seed = 1) {\n  g.fillStyle = base\n  g.fillRect(0, 0, w, h)\n  const r = rng(seed)\n  for (let i = 0; i < n; i++) {\n    g.fillStyle = r() < 0.5 ? dark : light\n    g.fillRect(r() * w, r() * h, 1 + r() * 2, 1 + r() * 2)\n  }\n}\nfunction label(g: G, x: number, y: number, w: number, h: number, text = '', bg = '#f4f4ef', fg = '#111') {\n  g.fillStyle = bg\n  g.fillRect(x, y, w, h)\n  g.strokeStyle = '#333'\n  g.lineWidth = 1\n  g.strokeRect(x, y, w, h)\n  if (!text) return\n  g.fillStyle = fg\n  let size = Math.round(h * 0.62)\n  g.font = `bold ${size}px Arial, sans-serif`\n  while (g.measureText(text).width > w * 0.92 && size > 6) g.font = `bold ${(size -= 1)}px Arial, sans-serif`\n  g.textAlign = 'center'\n  g.textBaseline = 'middle'\n  g.fillText(text, x + w / 2, y + h / 2 + 1)\n}\nfunction seam(g: G, x: number, y: number, w: number, h: number) {\n  g.strokeStyle = 'rgba(30,34,38,0.85)'\n  g.lineWidth = 3\n  g.strokeRect(x, y, w, h)\n  g.strokeStyle = 'rgba(255,255,255,0.45)'\n  g.lineWidth = 1\n  g.strokeRect(x + 3, y + 3, w - 6, h - 6)\n}\nfunction vents(g: G, x: number, y: number, w: number, n: number) {\n  g.fillStyle = 'rgba(28,30,32,0.8)'\n  for (let i = 0; i < n; i++) g.fillRect(x, y + i * 8, w, 4)\n}\nfunction bolt(g: G, x: number, y: number) {\n  g.fillStyle = '#7a8086'\n  g.beginPath()\n  g.arc(x, y, 5, 0, Math.PI * 2)\n  g.fill()\n}\n\n/** Stamped rating plate: a title then key/value lines. */\nexport function ratingPlateMaterial(title: string, lines: string[]) {\n  return make(`plate-${title}-${lines.join('|')}`, 384, 256, (g, w, h) => {\n    const grd = g.createLinearGradient(0, 0, w, h)\n    grd.addColorStop(0, '#d6dadd')\n    grd.addColorStop(1, '#b9bfc4')\n    g.fillStyle = grd\n    g.fillRect(0, 0, w, h)\n    g.strokeStyle = '#555b60'\n    g.lineWidth = 5\n    g.strokeRect(4, 4, w - 8, h - 8)\n    g.fillStyle = '#1b1e21'\n    g.textAlign = 'left'\n    g.font = 'bold 26px Arial, sans-serif'\n    g.fillText(title, 18, 40)\n    g.font = '20px Arial, sans-serif'\n    lines.forEach((t, i) => g.fillText(t, 18, 76 + i * 29))\n    for (const [x, y] of [[14, 14], [w - 14, 14], [14, h - 14], [w - 14, h - 14]]) bolt(g, x, y)\n  }, { metalness: 0.6, roughness: 0.35 })\n}\n\n/** MV metal-clad panel front (UV 0..1 over the whole door face): LV / relay, breaker and cable compartments with mimic. */\nexport function mvFrontMaterial(feeder: string, kv: number, incomer: boolean) {\n  return make(`mv-${feeder}-${kv}-${incomer}`, 256, 736, (g, w, h) => {\n    g.fillStyle = '#cfd2cd'\n    g.fillRect(0, 0, w, h)\n    seam(g, 6, 6, w - 12, 214)\n    seam(g, 6, 228, w - 12, 290)\n    seam(g, 6, 526, w - 12, h - 532)\n    label(g, 40, 18, 176, 26, feeder)\n    // busbar + breaker mimic\n    g.strokeStyle = '#b52a20'\n    g.lineWidth = 7\n    g.beginPath()\n    g.moveTo(0, 252)\n    g.lineTo(w, 252)\n    g.moveTo(128, 252)\n    g.lineTo(128, 300)\n    g.moveTo(128, 340)\n    g.lineTo(128, 500)\n    g.stroke()\n    g.strokeRect(108, 300, 40, 40)\n    if (incomer) {\n      g.beginPath()\n      g.arc(128, 470, 14, 0, Math.PI * 2)\n      g.arc(128, 448, 14, 0, Math.PI * 2)\n      g.stroke()\n    }\n    vents(g, 46, 560, 164, 9)\n    label(g, 46, 650, 164, 24, `DANGER ${kv} kV`, '#f4c414')\n    g.fillStyle = '#111'\n    g.beginPath()\n    g.moveTo(128, 684)\n    g.lineTo(112, 716)\n    g.lineTo(144, 716)\n    g.closePath()\n    g.fill()\n    g.fillStyle = '#f4c414'\n    g.beginPath()\n    g.moveTo(128, 692)\n    g.lineTo(119, 711)\n    g.lineTo(137, 711)\n    g.closePath()\n    g.fill()\n  }, { metalness: 0.2, roughness: 0.5 })\n}\n\n/** LV MCC front: tiers of withdrawable starter drawers with labels. */\nexport function mccFrontMaterial(seed: number, tiers: number) {\n  return make(`mcc-${seed}-${tiers}`, 256, 736, (g, w, h) => {\n    g.fillStyle = '#cfd2cd'\n    g.fillRect(0, 0, w, h)\n    vents(g, 40, 10, 176, 3)\n    const th = (h - 60) / tiers\n    for (let i = 0; i < tiers; i++) {\n      const y = 40 + i * th\n      seam(g, 8, y, w - 16, th - 6)\n      label(g, 120, y + 10, 120, Math.min(20, th * 0.22), `M-${(seed * 10 + i + 101).toString()}`)\n    }\n  }, { metalness: 0.2, roughness: 0.5 })\n}\n\nexport const liningMaterial = () =>\n  make('lining', 128, 256, (g, w, h) => {\n    speckle(g, w, h, '#e1e3df', 600, 'rgba(150,155,150,0.25)', 'rgba(255,255,255,0.3)', 11)\n    g.fillStyle = '#9da29f'\n    g.fillRect(0, 0, 2, h)\n  }, { repeat: true, roughness: 0.65 })\n\nexport const raisedFloorMaterial = () =>\n  make('raisedFloor', 256, 256, (g, w, h) => {\n    speckle(g, w, h, '#a9aeb0', 2600, 'rgba(70,75,80,0.35)', 'rgba(230,232,235,0.35)', 3)\n    g.strokeStyle = '#5c6266'\n    g.lineWidth = 4\n    g.strokeRect(2, 2, w - 4, h - 4)\n  }, { repeat: true, roughness: 0.55 })\n\nexport const ceilingMaterial = () =>\n  make('ceiling', 128, 128, (g, w, h) => {\n    speckle(g, w, h, '#eceeea', 1400, 'rgba(160,165,160,0.35)', 'rgba(255,255,255,0.4)', 13)\n    g.strokeStyle = '#b4b8b4'\n    g.lineWidth = 3\n    g.strokeRect(0, 0, w, h)\n  }, { repeat: true, roughness: 0.85 })\n\n/** GRP panel with a faint woven gel-coat texture. */\nexport const grpMaterial = (color = '#e9e4d4') =>\n  make(`grp-${color}`, 128, 128, (g, w, h) => speckle(g, w, h, color, 900, 'rgba(120,110,90,0.12)', 'rgba(255,255,255,0.25)', 21), { repeat: true, roughness: 0.4, metalness: 0 })\n\n/** Engraved legend plate (white on black or black on white). */\nexport const legendMaterial = (text: string, dark = false) =>\n  make(`legend-${text}-${dark}`, 256, 64, (g, w, h) => label(g, 0, 0, w, h, text, dark ? '#1b1d1f' : '#f2f2ee', dark ? '#f2f2ee' : '#111'), { roughness: 0.5 })\n\n/** Protection relay HMI: small LCD with text + LED column. */\nexport const relayFaceMaterial = () =>\n  make('relayFace', 128, 160, (g, w, h) => {\n    g.fillStyle = '#26292c'\n    g.fillRect(0, 0, w, h)\n    g.fillStyle = '#9ec7d8'\n    g.fillRect(14, 14, 80, 56)\n    g.fillStyle = '#1d3440'\n    g.font = 'bold 11px monospace'\n    for (const [i, t] of ['I 412 A', 'P 7.1 MW', 'CB CLOSED'].entries()) g.fillText(t, 18, 30 + i * 15)\n    const leds = ['#2fbf5f', '#d8a21a', '#c33', '#c33', '#555', '#555']\n    leds.forEach((c, i) => {\n      g.fillStyle = c\n      g.beginPath()\n      g.arc(110, 20 + i * 14, 4, 0, Math.PI * 2)\n      g.fill()\n    })\n    g.fillStyle = '#4a4e52'\n    for (let r = 0; r < 3; r++) for (let q = 0; q < 4; q++) g.fillRect(16 + q * 20, 86 + r * 22, 14, 14)\n    g.fillStyle = '#ddd'\n    g.font = '9px Arial'\n    g.fillText('SEL / ABB REF', 16, h - 6)\n  }, { emissive: 0.25, roughness: 0.3 })\n\n/** Emissive indicator lamp lenses (IEC 60073 colours). */\nexport const lamp = (c: 'red' | 'green' | 'amber' | 'white' | 'blue') => {\n  const hex = { red: '#ff3b2e', green: '#35e06a', amber: '#ffb31a', white: '#fff4dc', blue: '#4aa3ff' }[c]\n  return paint(hex, { emissive: hex, emissiveIntensity: 1.6, roughness: 0.25, metalness: 0, clearcoat: 0.8, weather: 0 })\n}\n\n/** Moving-iron ammeter face (square, 0..max A with 2x overload scale). */\nexport const ammeterMaterial = (max = 100) =>\n  make(`ammeter-${max}`, 128, 128, (g, w, h) => {\n    g.fillStyle = '#f4f4ef'\n    g.fillRect(0, 0, w, h)\n    g.strokeStyle = '#222'\n    g.lineWidth = 2\n    g.strokeRect(2, 2, w - 4, h - 4)\n    const cx = w / 2\n    const cy = h * 0.82\n    const R = h * 0.6\n    g.lineWidth = 3\n    g.beginPath()\n    g.arc(cx, cy, R, Math.PI * 1.25, Math.PI * 1.75)\n    g.stroke()\n    g.strokeStyle = '#c33'\n    g.beginPath()\n    g.arc(cx, cy, R, Math.PI * 1.62, Math.PI * 1.75)\n    g.stroke()\n    g.strokeStyle = '#222'\n    g.font = 'bold 11px Arial'\n    g.fillStyle = '#222'\n    g.textAlign = 'center'\n    for (let i = 0; i <= 4; i++) {\n      const a = Math.PI * (1.25 + (i / 4) * 0.5)\n      g.beginPath()\n      g.moveTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R)\n      g.lineTo(cx + Math.cos(a) * (R - 9), cy + Math.sin(a) * (R - 9))\n      g.stroke()\n      g.fillText(String(Math.round((i * max * 2) / 4)), cx + Math.cos(a) * (R - 18), cy + Math.sin(a) * (R - 18))\n    }\n    g.lineWidth = 2\n    g.beginPath()\n    g.moveTo(cx, cy)\n    const a = Math.PI * 1.43\n    g.lineTo(cx + Math.cos(a) * (R + 4), cy + Math.sin(a) * (R + 4))\n    g.stroke()\n    g.font = 'bold 16px Arial'\n    g.fillText('A', cx, h * 0.62)\n  }, { roughness: 0.3 })\n"
    }
  ],
  "categories": [
    "electrical"
  ],
  "meta": {
    "export": "AnalyserShelter",
    "anchor": "base",
    "standards": [
      "IEC 61285",
      "EEMUA 138",
      "IEC 60079-2 / -13",
      "ISO 10297"
    ]
  }
}