{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "e-house",
  "type": "registry:component",
  "title": "E-house / local equipment room",
  "description": "Prefabricated A60 steel equipment module on an HEB skid with a cable cellar. Profiled or flat cladding, double equipment door and personnel doors with frames, canopies, lights and signage, front walkway and ISO 14122 stairs, roof HVAC packages with condenser fans, wall louvres with fire dampers and intake gas detectors, an overpressure relief damper, manual call points by the doors, a hollow cable cellar, multi-cable transits with a ladder tray, caged roof ladder, earthing bosses and lifting pad-eyes. Length, width, height and cellar drive the geometry; the optional interior holds a switchgear line-up (with a rear access aisle when the room is deep enough) and MCC under a lit ceiling with smoke detectors, visible with doorOpen.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "electrical/e-house.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/electrical/e-house.tsx",
      "content": "// E-house / local equipment room: prefabricated A60 steel module on a skid with cable cellar, doors, stairs, roof HVAC and an optional switchgear interior. ISO 14122, IEC 62271-202.\nimport * as THREE from 'three'\nimport { useEffect, useMemo } from 'react'\nimport { Kit, eul, mat4, uvScale, type Built, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { grating, handrail, ladder, liftingLug, member, tagPlate } from '../lib/parts'\nimport { Rotors, fanRotor, type RotorSpec } from '../lib/rotors'\nimport { stairFlight } from '../lib/standards'\nimport { fanGuardMaterial, signMaterial } from '../lib/textures'\nimport { beacon, cableTransit, earthBoss, louvre, louvrePanel, ribbedPanel } from './_parts'\nimport { lineup, panelSize } from './_panels'\nimport { ceilingMaterial, lamp, liningMaterial, raisedFloorMaterial } from './_textures'\n\nexport interface EHouseProps {\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  /** internal floor above deck (cable cellar under it), m */\n  cellar?: number\n  cladding?: 'corrugated' | 'flat'\n  doorOpen?: boolean\n  /** switchgear and MCC line-ups, lit ceiling, raised floor */\n  interior?: boolean\n  /** switchgear voltage, kV */\n  kv?: number\n  label?: string\n  tag?: string\n  /** spin the roof HVAC condenser fans */\n  running?: boolean\n}\n\nexport const defaults = {\n  length: 12,\n  width: 6,\n  height: 4.4,\n  cellar: 0.9,\n  cladding: 'corrugated' as 'corrugated' | 'flat',\n  doorOpen: false,\n  interior: true,\n  kv: 11,\n  label: 'LOCAL EQUIPMENT ROOM',\n  tag: 'B-9100',\n  running: true,\n}\n\ntype P = Omit<typeof defaults, 'running'>\n\nconst T = 0.14 // wall: cladding + mineral wool + lining (A60)\nconst POST = 0.2\nconst FR = 0.08 // door frame width\nconst OPEN = 1.6\nconst FRAME: MatKey = 'structSteelDark'\nconst CLAD: MatKey = 'cladding'\n\ntype WallId = '+z' | '-z' | '+x' | '-x'\ninterface Wall {\n  id: WallId\n  len: number\n  rotY: number\n  origin: V3\n  m: THREE.Matrix4\n}\ninterface Door {\n  wall: WallId\n  u: number\n  w: number\n  h: number\n  double: boolean\n}\ninterface Leaf {\n  pos: V3\n  rotY: number\n  open: number\n  built: Built\n}\n\nexport function EHouse({ running = true, ...rest }: EHouseProps & GroupProps) {\n  const { length, width, height, cellar, cladding, doorOpen, interior, kv, label, tag, ...group } = { ...defaults, ...rest }\n  const b = useMemo(() => build({ length, width, height, cellar, cladding, doorOpen, interior, kv, label, tag }), [length, width, height, cellar, cladding, doorOpen, interior, kv, label, tag])\n  useEffect(() => () => [b.shell, ...new Set([...b.leaves.map((l) => l.built), ...b.rotors.map((r) => r.built)])].forEach((x) => x.geometry.dispose()), [b])\n  return (\n    <group {...group}>\n      <KitMesh data={b.shell} />\n      {b.leaves.map((l, i) => (\n        <group key={i} position={l.pos} rotation={[0, l.rotY, 0]}>\n          <group rotation={[0, doorOpen ? l.open : 0, 0]}>\n            <KitMesh data={l.built} />\n          </group>\n        </group>\n      ))}\n      <Rotors rotors={b.rotors} running={running} />\n    </group>\n  )\n}\n\nexport default EHouse\n\nfunction wallsOf(SX: number, SZ: number): Wall[] {\n  const mk = (id: WallId, rotY: number, origin: V3, len: number): Wall => ({ id, rotY, origin, len, m: new THREE.Matrix4().makeRotationY(rotY).setPosition(...origin) })\n  return [mk('+z', 0, [0, 0, SZ / 2], SX), mk('-z', Math.PI, [0, 0, -SZ / 2], SX), mk('+x', Math.PI / 2, [SX / 2, 0, 0], SZ), mk('-x', -Math.PI / 2, [-SX / 2, 0, 0], SZ)]\n}\nconst toModule = (w: Wall, p: V3): V3 => new THREE.Vector3(...p).applyMatrix4(w.m).toArray() as V3\n\nfunction build(p: P) {\n  const SX = Math.min(30, Math.max(4, p.length))\n  const SZ = Math.min(12, Math.max(3, p.width))\n  const HF = Math.min(3, Math.max(0.5, p.cellar))\n  const SY = Math.min(HF + 6, Math.max(HF + 2.9, p.height))\n  const k = new Kit()\n  const rotors: RotorSpec[] = []\n  const walls = wallsOf(SX, SZ)\n  const W = (id: WallId) => walls.find((w) => w.id === id)!\n  const onWall = (w: Wall, fn: () => void) => k.with(w.m, fn)\n  const flight = stairFlight(HF)\n\n  // doors: double equipment door and personnel door on the front, escape door on the +X end\n  const dd: Door = { wall: '+z', u: Math.max(-SX / 2 + 1.5, -SX * 0.18), w: 1.8, h: 2.4, double: true }\n  const pd: Door = { wall: '+z', u: Math.min(SX / 2 - 0.9, Math.max(dd.u + 2.2, SX * 0.3)), w: 0.9, h: 2.1, double: false }\n  const ed: Door = { wall: '+x', u: -SZ * 0.15, w: 0.9, h: 2.1, double: false }\n  const doors = [dd, pd, ed]\n\n  // skid: HEB 300 perimeter and cross beams, earthing bosses, side pad-eyes\n  for (const s of [-1, 1]) member(k, [-SX / 2, 0.15, s * (SZ / 2 - 0.15)], [SX / 2, 0.15, s * (SZ / 2 - 0.15)], 'HEB 300', FRAME)\n  const nc = Math.max(2, Math.ceil(SX / 2))\n  for (let i = 0; i <= nc; i++) {\n    const x = -SX / 2 + 0.15 + (i * (SX - 0.3)) / nc\n    member(k, [x, 0.15, -SZ / 2 + 0.3], [x, 0.15, SZ / 2 - 0.3], 'HEB 300', FRAME)\n  }\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) liftingLug(k, [sx * (SX / 2 - 0.6), 0.05, sz * (SZ / 2 + 0.0125)], 'x', 1.4, 'safetyYellow')\n  earthBoss(k, [SX / 2, 0.15, SZ / 2 - 0.6], 'x')\n  earthBoss(k, [-SX / 2, 0.15, -SZ / 2 + 0.6], '-x')\n\n  // cable cellar skirt and inner block, sill ring, corner posts\n  k.box('deckPlate', [SX - 0.1, 0.01, SZ - 0.1], [0, 0.305, 0]) // cellar floor; the cellar itself is open space under the raised floor\n  for (const w of walls)\n    onWall(w, () => {\n      const L = w.len - 2 * POST\n      if (p.cladding === 'corrugated') k.add(CLAD, ribbedPanel(L, HF - 0.32, 0.25, 0.035), mat4([0, 0.3, -0.045]))\n      else k.box(CLAD, [L, HF - 0.32, 0.01], [0, 0.3 + (HF - 0.32) / 2, -0.02])\n      k.box(FRAME, [w.len, 0.06, 0.08], [0, HF - 0.03, -0.03])\n    })\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.box(FRAME, [POST, SY - 0.3, POST], [sx * (SX / 2 - POST / 2), 0.3 + (SY - 0.3) / 2, sz * (SZ / 2 - POST / 2)])\n\n  // walls with real door openings: lining inside, profiled or flat cladding outside\n  const lining = liningMaterial()\n  const wallTop = SY - 0.125\n  const cladTop = SY - 0.35\n  for (const w of walls) {\n    const ext: [number, number] = w.id === '+z' || w.id === '-z' ? [-w.len / 2, w.len / 2] : [-w.len / 2 + T, w.len / 2 - T]\n    const holes = doors.filter((d) => d.wall === w.id).map((d) => ({ a: d.u - d.w / 2 - FR, b: d.u + d.w / 2 + FR, y1: HF + d.h + FR }))\n    holes.sort((a, b) => a.a - b.a)\n    const seg = (a: number, b: number, y0: number, y1: number) => {\n      if (b - a < 0.005 || y1 - y0 < 0.005) return\n      const g = new THREE.BoxGeometry(b - a, y1 - y0, T - 0.05)\n      uvScale(g, (b - a) / 1.2, 1)\n      k.add(lining, g, mat4([(a + b) / 2, (y0 + y1) / 2, -(T + 0.05) / 2]))\n      const ca = Math.max(a, -w.len / 2 + POST)\n      const cb = Math.min(b, w.len / 2 - POST)\n      const cy1 = Math.min(y1, cladTop)\n      if (cb - ca < 0.01 || cy1 - y0 < 0.01) return\n      if (p.cladding === 'corrugated') k.add(CLAD, ribbedPanel(cb - ca, cy1 - y0, 0.25, 0.035), mat4([(ca + cb) / 2, y0, -0.045]))\n      else {\n        k.box(CLAD, [cb - ca, cy1 - y0, 0.04], [(ca + cb) / 2, (y0 + cy1) / 2, -0.03])\n        for (let x = Math.ceil((ca + 0.01) / 1.2) * 1.2; x < cb - 0.01; x += 1.2) k.box(CLAD, [0.03, cy1 - y0, 0.012], [x, (y0 + cy1) / 2, -0.004])\n      }\n    }\n    onWall(w, () => {\n      let cur = ext[0]\n      for (const h of holes) {\n        seg(cur, Math.max(cur, h.a), HF, wallTop)\n        seg(h.a, h.b, h.y1, wallTop)\n        cur = Math.max(cur, h.b)\n      }\n      seg(cur, ext[1], HF, wallTop)\n      k.box(CLAD, [w.len + 0.08, 0.35, 0.05], [0, SY - 0.175, 0.0]) // eaves flashing\n    })\n  }\n\n  // roof: deck plate, profiled roof sheet, perimeter handrail with a gap at the ladder\n  k.box(CLAD, [SX - 0.1, 0.05, SZ - 0.1], [0, SY - 0.1, 0])\n  k.add(CLAD, ribbedPanel(SX - 0.3, SZ - 0.3, 0.3, 0.03, false), new THREE.Matrix4().makeBasis(new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0)).setPosition(0, SY - 0.072, -(SZ - 0.3) / 2))\n  const yr = SY - 0.045\n  const ex = SX / 2 - 0.12\n  const ez = SZ / 2 - 0.12\n  const xl = SX * 0.35 // roof ladder, rear wall\n  handrail(k, [[xl - 0.45, yr, -ez], [-ex, yr, -ez], [-ex, yr, ez], [ex, yr, ez], [ex, yr, -ez], [xl + 0.45, yr, -ez]])\n\n  // floor and interior\n  const IX = SX - 2 * T\n  const IZ = SZ - 2 * T\n  {\n    const g = new THREE.BoxGeometry(IX, 0.05, IZ)\n    uvScale(g, IX / 0.6, IZ / 0.6)\n    k.add(raisedFloorMaterial(), g, mat4([0, HF - 0.025, 0]))\n  }\n  const CH = SY - 0.5\n  if (p.interior) interiorFit(k, p, { SX, SZ, HF, CH, IX, IZ, dd })\n\n  // doors: frames, canopies, lights, signage, leaves\n  const leaves: Leaf[] = []\n  const leafCache = new Map<string, Built>()\n  const leafOf = (w: number, h: number, dir: 1 | -1) => {\n    const key = `${w}|${h}|${dir}`\n    if (!leafCache.has(key)) leafCache.set(key, leafBuilt(w, h, dir))\n    return leafCache.get(key)!\n  }\n  for (const [i, d] of doors.entries()) {\n    const w = W(d.wall)\n    onWall(w, () => doorDressing(k, d, HF, p.interior, i === 0 ? p : null))\n    const hinges: [number, 1 | -1, number][] = d.double ? [[d.u - d.w / 2, 1, d.w / 2], [d.u + d.w / 2, -1, d.w / 2]] : [[d.u + d.w / 2, -1, d.w]]\n    for (const [hu, dir, lw] of hinges) leaves.push({ pos: toModule(w, [hu, HF, 0]), rotY: w.rotY, open: -dir * OPEN, built: leafOf(lw, d.h, dir) })\n  }\n\n  // landings and ISO 14122-3 stairs: front walkway serving both front doors, landing at the escape door\n  onWall(W('+z'), () => access(k, dd.u - dd.w / 2 - 0.45, pd.u + pd.w / 2 + 0.45, 1.6, -1, HF, SX / 2, flight))\n  onWall(W('+x'), () => access(k, ed.u - ed.w / 2 - 0.45, ed.u + ed.w / 2 + 0.45, 1.2, 1, HF, SZ / 2, flight))\n\n  // HVAC wall louvres with fire dampers (rear wall, high level) and the -X end\n  onWall(W('-z'), () => {\n    for (const f of [-0.3, 0.05]) {\n      louvre(k, SX * f, SY - 1.1, 0.9, 0.55, FRAME)\n      // gas detector at the HVAC intake (trips the HVAC / closes the dampers on gas)\n      k.box('stainless', [0.1, 0.12, 0.06], [SX * f - 0.65, SY - 1.2, 0.03])\n      k.cyl('stainless', 0.03, 0.12, [SX * f - 0.65, SY - 1.32, 0.05], 'y', 14)\n      k.box('actuatorRed', [0.18, 0.12, 0.1], [SX * f + 0.6, SY - 0.9, 0.06])\n    }\n  })\n  onWall(W('-x'), () => {\n    louvre(k, 0, SY - 1.1, Math.min(1.2, SZ * 0.3), 0.6, FRAME)\n    // overpressure relief damper (room is held a few mbar above ambient)\n    k.box(FRAME, [0.42, 0.42, 0.12], [SZ * 0.3, HF + 1.0, 0.06])\n    k.box('rubberBlack', [0.34, 0.34, 0.01], [SZ * 0.3, HF + 1.0, 0.125])\n    for (let i = 0; i < 4; i++) k.box('galvanised', [0.34, 0.07, 0.006], [SZ * 0.3, HF + 0.86 + i * 0.09, 0.135], eul(-0.5, 0, 0))\n  })\n\n  // multi-cable transits into the cellar on the rear wall, cables up to a ladder tray on brackets\n  onWall(W('-z'), () => {\n    const yT = HF + 2.0\n    const us = [SX * 0.0, SX * 0.12, SX * 0.24]\n    const t0 = -SX * 0.12\n    const t1 = SX * 0.42\n    const yc = 0.3 + (HF - 0.3) / 2\n    for (const [i, u] of us.entries()) {\n      cableTransit(k, u, yc, 6, HF > 0.75 ? 2 : 1, FRAME)\n      for (let c = 0; c < 4; c++) {\n        const x = u - 0.15 + c * 0.1\n        k.spline('cable', [[x, yc, 0.13], [x, yc, 0.45], [x, yc + 0.6, 0.55], [x, yT - 0.4, 0.6], [x + i * 0.02, yT + 0.02, 0.62]], 0.016, 6, 28)\n      }\n    }\n    for (const s of [-1, 1]) k.box('cableTray', [t1 - t0, 0.1, 0.006], [(t0 + t1) / 2, yT + 0.05, 0.62 + s * 0.25])\n    for (let x = t0 + 0.15; x < t1; x += 0.3) k.box('cableTray', [0.03, 0.02, 0.5], [x, yT + 0.01, 0.62])\n    for (let x = t0 + 0.3; x < t1; x += 1.5) {\n      k.box(FRAME, [0.06, 0.06, 0.92], [x, yT - 0.03, 0.42])\n      k.box(FRAME, [0.06, 0.4, 0.06], [x, yT - 0.26, 0.04])\n    }\n  })\n\n  // roof access ladder with cage (rear wall)\n  onWall(W('-z'), () => {\n    ladder(k, [-xl, 0, 0.25], SY - 0.05, [0, 0, 1])\n    for (const y of [1.5, SY - 1.2]) for (const s of [-1, 1]) k.box('safetyYellow', [0.04, 0.04, 0.25], [-xl + s * 0.225, y, 0.125])\n  })\n\n  // roof: packaged HVAC units with condenser fans, beacons, pad-eyes\n  const nU = SX >= 8 ? 2 : 1\n  const fan = fanRotor('rubberBlack', 0.32, [0, 0, 0], 'y', 220, { blades: 4 })\n  for (let i = 0; i < nU; i++) {\n    const ux = nU === 1 ? -SX * 0.1 : (i ? 1 : -1) * SX * 0.22\n    const hz = -SZ * 0.12\n    const hb = SY - 0.05\n    k.box(FRAME, [2.0, 0.15, 1.3], [ux, hb + 0.075, hz])\n    k.rbox(CLAD, [1.9, 1.0, 1.2], 0.03, [ux, hb + 0.65, hz])\n    for (const s of [-1, 1]) louvrePanel(k, 1.6, 0.6, [ux, hb + 0.6, hz + s * 0.601], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), s > 0 ? 0 : Math.PI))\n    for (const fx of [-0.45, 0.45]) {\n      k.cyl(CLAD, 0.36, 0.14, [ux + fx, hb + 1.21, hz], 'y', 32, undefined, true)\n      k.disc('rubberBlack', 0.355, [ux + fx, hb + 1.145, hz], 'y', 28)\n      k.cyl('nut', 0.05, 0.08, [ux + fx, hb + 1.19, hz], 'y', 12)\n      k.disc(fanGuardMaterial(), 0.355, [ux + fx, hb + 1.285, hz], 'y', 28)\n      rotors.push({ ...fan, pos: [ux + fx, hb + 1.23, hz] })\n    }\n    // supply / return ducts down through the roof, isolator\n    k.box('galvanised', [0.5, 0.15, 0.4], [ux + 0.6, hb + 0.075, hz + 0.85])\n    k.box('galvanised', [0.5, 0.5, 0.15], [ux + 0.6, hb + 0.3, hz + 0.68])\n    k.rbox('stainless', [0.2, 0.25, 0.12], 0.02, [ux - 0.75, hb + 0.25, hz + 0.68])\n  }\n  beacon(k, [-SX / 2 + 0.45, SY - 0.05, SZ / 2 - 0.45], lamp('red'))\n  beacon(k, [-SX / 2 + 0.7, SY - 0.05, SZ / 2 - 0.45], lamp('amber'))\n  for (const sx of [-1, 1]) for (const sz of [-1, 1]) liftingLug(k, [sx * (SX / 2 - 0.4), SY - 0.05, sz * (SZ / 2 - 0.4)], 'x', 1.2, FRAME)\n\n  return { shell: k.build(), leaves, rotors }\n}\n\n/** Landing [u0, u1] × depth on the wall at floor height HF, stair descending along the wall (sd) or straight out if it would overrun the end. */\nfunction access(k: Kit, u0: number, u1: number, depth: number, sd: 1 | -1, HF: number, half: number, f: ReturnType<typeof stairFlight>) {\n  const run = f.steps * f.going\n  const sw = 0.9\n  const edge = sd > 0 ? u1 : u0\n  const along = sd > 0 ? edge + run <= half + 0.2 : edge - run >= -half - 0.2\n  const uc = (u0 + u1) / 2\n  grating(k, [uc, HF, depth / 2 + 0.02], u1 - u0, depth)\n  member(k, [u0, HF - 0.03 - 0.07, depth + 0.02], [u1, HF - 0.03 - 0.07, depth + 0.02], 'UPN 140', FRAME, [0, 0, 1])\n  const np = Math.max(2, Math.ceil((u1 - u0) / 2.5) + 1)\n  for (let i = 0; i < np; i++) {\n    const u = u0 + 0.06 + ((u1 - u0 - 0.12) * i) / (np - 1)\n    member(k, [u, 0, depth - 0.05], [u, HF - 0.17, depth - 0.05], 'SHS 100x100x6.3', FRAME, [0, 0, 1])\n    k.box(FRAME, [0.2, 0.012, 0.2], [u, 0.006, depth - 0.05])\n  }\n  const tread = (c: V3, sx: number, sz: number) => grating(k, c, sx, sz)\n  if (along) {\n    const zc = depth - sw / 2 - 0.05\n    for (let i = 1; i < f.steps; i++) tread([edge + sd * (i - 0.5) * f.going, HF - i * f.riser, zc], f.going - 0.015, sw)\n    for (const o of [zc - sw / 2 - 0.02, zc + sw / 2 + 0.02]) {\n      member(k, [edge, HF - 0.09, o], [edge + sd * run, 0.09, o], 'UPN 160', FRAME, [0, 1, 0])\n      handrail(k, [[edge, HF, o], [edge + sd * (f.steps - 1) * f.going, f.riser, o]])\n    }\n    const far = sd > 0 ? u0 : u1\n    handrail(k, [[edge, HF, 0.06], [edge, HF, zc - sw / 2 - 0.02]])\n    handrail(k, [[edge, HF, depth + 0.02], [far, HF, depth + 0.02], [far, HF, 0.06]])\n  } else {\n    const us = sd > 0 ? u1 - sw / 2 - 0.05 : u0 + sw / 2 + 0.05\n    for (let i = 1; i < f.steps; i++) tread([us, HF - i * f.riser, depth + 0.02 + (i - 0.5) * f.going], sw, f.going - 0.015)\n    for (const o of [us - sw / 2 - 0.02, us + sw / 2 + 0.02]) {\n      member(k, [o, HF - 0.09, depth + 0.02], [o, 0.09, depth + 0.02 + run], 'UPN 160', FRAME, [0, 1, 0])\n      handrail(k, [[o, HF, depth + 0.02], [o, f.riser, depth + 0.02 + (f.steps - 1) * f.going]])\n    }\n    const near = sd > 0 ? us - sw / 2 - 0.02 : us + sw / 2 + 0.02\n    const far = sd > 0 ? u0 : u1\n    handrail(k, [[near, HF, depth + 0.02], [far, HF, depth + 0.02], [far, HF, 0.06]])\n    handrail(k, [[sd > 0 ? u1 : u0, HF, 0.06], [sd > 0 ? u1 : u0, HF, depth + 0.02]])\n  }\n}\n\n/** Frame, threshold, canopy, bulkhead light, signs (wall frame, outer face z = 0), plus exit sign and extinguisher inside. */\nfunction doorDressing(k: Kit, d: Door, HF: number, inside: boolean, first: P | null) {\n  const { u, w: dw, h } = d\n  k.box(FRAME, [dw + 2 * FR, FR, T + 0.04], [u, HF + h + FR / 2, -T / 2 + 0.01])\n  for (const s of [-1, 1]) k.box(FRAME, [FR, h, T + 0.04], [u + s * (dw / 2 + FR / 2), HF + h / 2, -T / 2 + 0.01])\n  k.box(FRAME, [dw, 0.025, 0.03], [u, HF + h - 0.012, -0.09])\n  k.box('stainless', [dw + 2 * FR, 0.012, T + 0.06], [u, HF + 0.006, -T / 2])\n  k.box(CLAD, [dw + 0.6, 0.04, 0.6], [u, HF + h + 0.35, 0.3])\n  k.box(CLAD, [dw + 0.6, 0.15, 0.03], [u, HF + h + 0.29, 0.6])\n  for (const s of [-1, 1]) k.rod(FRAME, [u + s * (dw / 2 + 0.25), HF + h + 0.33, 0.55], [u + s * (dw / 2 + 0.25), HF + h + 0.75, 0.02], 0.012, 6)\n  const lu = u - (dw / 2 + 0.35)\n  k.rbox('stainless', [0.3, 0.12, 0.1], 0.03, [lu, HF + h + 0.15, 0.06])\n  k.box('lampLens', [0.25, 0.07, 0.02], [lu, HF + h + 0.15, 0.115])\n  k.plane(signMaterial('exit'), 0.4, 0.2, [u, HF + h + 0.18, 0.004])\n  const su = u + dw / 2 + 0.6\n  k.plane(signMaterial(first ? 'ler' : 'hv', first ? first.label : undefined), 0.8, 0.4, [su, HF + 1.6, 0.004])\n  k.plane(signMaterial(d.double ? 'hv' : 'noentry'), 0.5, 0.25, [su, HF + 1.1, 0.004])\n  if (first) tagPlate(k, first.tag, [su, HF + 2.1, 0.004], 'z', 0.6)\n  // A60 rating label on the frame, card reader / push button by the personnel door\n  k.box('nameplate', [0.1, 0.05, 0.004], [u - dw / 2 - FR / 2, HF + 1.8, 0.012])\n  // manual alarm call point outside the door (yellow, F&G)\n  k.box('actuatorYellow', [0.1, 0.1, 0.05], [u - dw / 2 - 0.25, HF + 1.4, 0.025])\n  k.box('glass', [0.07, 0.07, 0.004], [u - dw / 2 - 0.25, HF + 1.4, 0.052])\n  if (!inside) return\n  k.box(lamp('green'), [0.36, 0.16, 0.04], [u, HF + h + 0.2, -T - 0.02])\n  k.plane(signMaterial('exit'), 0.34, 0.15, [u, HF + h + 0.2, -T - 0.041], eul(0, Math.PI, 0))\n  const eu = u + dw / 2 + 0.4\n  const red = paint('#c0261c', { roughness: 0.35, metalness: 0.2 })\n  k.box('nut', [0.16, 0.06, 0.06], [eu, HF + 0.95, -T - 0.03])\n  k.cyl(red, 0.085, 0.5, [eu, HF + 0.62, -T - 0.11], 'y', 16)\n  k.dome(red, 0.085, [eu, HF + 0.87, -T - 0.11], 'y', Math.PI / 2, 0.6, 16)\n  k.cyl('nut', 0.025, 0.08, [eu, HF + 0.93, -T - 0.11], 'y', 8)\n  k.box(red, [0.09, 0.09, 0.05], [eu, HF + 1.45, -T - 0.025])\n  k.box('glass', [0.06, 0.06, 0.005], [eu, HF + 1.45, -T - 0.052])\n}\n\n/** Switchgear opposite the double door, MCC on the -X end wall, ceiling with lights, overhead tray. */\nfunction interiorFit(k: Kit, p: P, c: { SX: number; SZ: number; HF: number; CH: number; IX: number; IZ: number; dd: Door }) {\n  const { HF, CH, IX, IZ } = c\n  // ceiling tiles, recessed LED panels\n  const g = new THREE.BoxGeometry(IX, 0.03, IZ)\n  uvScale(g, IX / 0.6, IZ / 0.6)\n  k.add(ceilingMaterial(), g, mat4([0, CH + 0.015, 0]))\n  const light = paint('#ffffff', { emissive: '#fff3dc', emissiveIntensity: 1.5, roughness: 0.4, metalness: 0, clearcoat: 0, weather: 0 })\n  const nx = Math.max(1, Math.round(IX / 2.2))\n  const nz = Math.max(1, Math.round(IZ / 2.4))\n  for (let i = 0; i < nx; i++)\n    for (let j = 0; j < nz; j++) {\n      const x = -IX / 2 + ((i + 0.5) * IX) / nx\n      const z = -IZ / 2 + ((j + 0.5) * IZ) / nz\n      k.box('aluminium', [0.64, 0.02, 1.24], [x, CH - 0.01, z])\n      k.box(light, [0.58, 0.012, 1.18], [x, CH - 0.022, z])\n    }\n  // optical smoke detectors on the ceiling grid, between the light panels\n  for (let i = 0; i < nx; i++) {\n    const x = -IX / 2 + (i * IX) / nx + IX / nx\n    if (i === nx - 1) continue\n    for (const z of [-IZ * 0.2, IZ * 0.2]) {\n      k.cyl('nameplate', 0.05, 0.025, [x, CH - 0.0125, z], 'y', 20)\n      k.cyl(lamp('red'), 0.005, 0.006, [x + 0.03, CH - 0.026, z], 'y', 8)\n    }\n  }\n  // MV switchgear against the rear wall, facing the double door\n  const mvKind = IZ - 0.1 - panelSize('mv', p.kv)[2] >= 1.4 ? 'mv' : 'lv'\n  const [pw, , D] = panelSize(mvKind, p.kv)\n  const mcc = IX >= 6\n  const x0 = -IX / 2 + (mcc ? 1.8 : 0.3)\n  const x1 = IX / 2 - 0.3\n  const n = Math.max(1, Math.min(14, Math.floor((x1 - x0 - 0.2) / pw)))\n  const xm = (x0 + x1) / 2\n  const rear = IZ - D - 0.8 >= 2.2 ? 0.8 : 0.1 // rear access aisle for metal-clad gear, else wall-standing front-access gear\n  const zf = -IZ / 2 + rear + D\n  k.at([xm, 0, zf], undefined, () => lineup(k, { kind: mvKind, panels: n, kv: p.kv, y0: HF, tag: 'SWBD-1' }))\n  // MCC along the -X end wall, facing +X\n  if (mcc) {\n    const nm = Math.max(1, Math.min(10, Math.floor((IZ - 0.9) / 0.6)))\n    k.at([-IX / 2 + 0.1 + 0.6, 0, -IZ / 2 + 0.25 + (nm * 0.6 + 0.1) / 2], eul(0, Math.PI / 2, 0), () => lineup(k, { kind: 'mcc', panels: nm, kv: 0.69, y0: HF, tag: 'MCC-1' }))\n  }\n  // ladder tray over the switchgear, rubber mat in front\n  const yT = CH - 0.3\n  for (const s of [-1, 1]) k.box('cableTray', [n * pw, 0.1, 0.006], [xm, yT, zf - D / 2 + s * 0.3])\n  for (let x = xm - (n * pw) / 2 + 0.15; x < xm + (n * pw) / 2; x += 0.3) k.box('cableTray', [0.03, 0.02, 0.6], [x, yT - 0.04, zf - D / 2])\n  for (let x = xm - (n * pw) / 2 + 0.4; x < xm + (n * pw) / 2; x += 1.5) for (const s of [-1, 1]) k.rod('nut', [x, yT - 0.05, zf - D / 2 + s * 0.3], [x, CH, zf - D / 2 + s * 0.3], 0.006, 5)\n  k.box('rubberBlack', [n * pw, 0.008, 1.0], [xm, HF + 0.004, zf + 0.6])\n}\n\n/** A60 steel door leaf from its hinge along dir·X, sitting just inside the outer face. */\nfunction leafBuilt(w: number, h: number, dir: 1 | -1): Built {\n  const k = new Kit()\n  const DOOR = paint('#cbd0cc', { metalness: 0.2, roughness: 0.45 })\n  const t = 0.05\n  const zc = -0.045\n  const X = (x: number) => dir * x\n  const lw = w - 0.012\n  const x0 = 0.006\n  const ww = Math.min(0.3, lw * 0.35)\n  const wy0 = h * 0.6\n  const wy1 = Math.min(h - 0.25, wy0 + 0.55)\n  const wc = x0 + lw / 2\n  const piece = (a: number, b: number, y0: number, y1: number) => k.box(DOOR, [b - a, y1 - y0, t], [X((a + b) / 2), (y0 + y1) / 2, zc])\n  piece(x0, x0 + lw, 0.005, wy0)\n  piece(x0, x0 + lw, wy1, h - 0.005)\n  piece(x0, wc - ww / 2, wy0, wy1)\n  piece(wc + ww / 2, x0 + lw, wy0, wy1)\n  k.box('glass', [ww, wy1 - wy0, 0.012], [X(wc), (wy0 + wy1) / 2, zc])\n  for (const zz of [zc + t / 2 + 0.004, zc - t / 2 - 0.004]) {\n    for (const yy of [wy0, wy1]) k.box('stainless', [ww + 0.04, 0.02, 0.008], [X(wc), yy, zz])\n    for (const s of [-1, 1]) k.box('stainless', [0.02, wy1 - wy0 + 0.04, 0.008], [X(wc + s * (ww / 2)), (wy0 + wy1) / 2, zz])\n    k.box('stainless', [lw * 0.95, 0.22, 0.004], [X(wc), 0.13, zz])\n  }\n  for (const hy of [0.22, h / 2, h - 0.25]) k.cyl('nut', 0.016, 0.12, [X(0.004), hy, -0.012], 'y', 10)\n  const zi = zc - t / 2\n  k.rod('stainless', [X(0.12), 1.0, zi - 0.075], [X(lw - 0.06), 1.0, zi - 0.075], 0.017, 10)\n  for (const bx of [0.12, lw - 0.09]) k.rbox('nut', [0.08, 0.1, 0.07], 0.01, [X(bx), 1.0, zi - 0.035])\n  const zo = zc + t / 2\n  const hx = lw - 0.08\n  k.rod('stainless', [X(hx), 0.85, zo + 0.05], [X(hx), 1.25, zo + 0.05], 0.014, 8)\n  for (const yy of [0.88, 1.22]) k.rod('stainless', [X(hx), yy, zo], [X(hx), yy, zo + 0.05], 0.01, 6)\n  k.cyl('brass', 0.016, 0.02, [X(hx), 1.32, zo + 0.008], 'z', 12)\n  k.rbox('nut', [0.3, 0.065, 0.06], 0.015, [X(0.25), h - 0.1, zi - 0.03])\n  k.box('nut', [0.25, 0.015, 0.025], [X(0.38), h - 0.05, zi - 0.075], eul(0, dir * 0.6, 0))\n  k.box('nameplate', [0.12, 0.05, 0.003], [X(0.1), h - 0.3, zo + 0.002])\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/_panels.ts",
      "type": "registry:file",
      "target": "components/deck-twin/electrical/_panels.ts",
      "content": "// Switchgear / MCC line-up builder shared by the switchgear component and the e-house interior. Fronts face +Z, line-up runs along X.\nimport * as THREE from 'three'\nimport type { Kit, MatKey, V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { gauge } from '../lib/parts'\nimport { textMaterial } from '../lib/textures'\nimport { lamp, mccFrontMaterial, mvFrontMaterial, relayFaceMaterial } from './_textures'\n\nexport type LineupKind = 'mv' | 'lv' | 'mcc'\n\nexport interface LineupOpts {\n  kind: LineupKind\n  panels: number\n  /** line voltage, kV */\n  kv: number\n  /** floor level the base frame stands on */\n  y0?: number\n  tag?: string\n}\n\n/** Panel width, height and depth for a line-up type and voltage (IEC 62271-200 / IEC 61439 typical). */\nexport function panelSize(kind: LineupKind, kv: number): V3 {\n  if (kind === 'mv') {\n    const w = kv > 24 ? 1.0 : kv > 12 ? 0.8 : 0.65\n    return [w, kv > 24 ? 2.4 : 2.3, kv > 24 ? 2.3 : kv > 12 ? 1.75 : 1.5] // typical\n  }\n  return kind === 'mcc' ? [0.6, 2.2, 0.6] : [0.8, 2.2, 0.8] // typical\n}\n\nexport function lineupWidth(o: LineupOpts) {\n  return panelSize(o.kind, o.kv)[0] * o.panels + 0.1\n}\n\nconst front = new THREE.Quaternion()\n\n/** Builds the line-up into k: base frame, panels with doors and devices, end covers, arc duct. Returns overall size. */\nexport function lineup(k: Kit, o: LineupOpts): { w: number; h: number; d: number } {\n  const [pw, H, D] = panelSize(o.kind, o.kv)\n  const y0 = o.y0 ?? 0\n  const n = Math.max(1, Math.round(o.panels))\n  const Wt = pw * n\n  const body = paint('#cdd1cc', { metalness: 0.25, roughness: 0.45, clearcoat: 0.2, weather: 0 })\n  const dark: MatKey = paint('#3c4146', { metalness: 0.3, roughness: 0.5, clearcoat: 0, weather: 0 })\n  const yb = y0 + 0.1 // base frame top\n  const Hf = H - 0.1\n  // base frame (channel) and kick plate\n  k.box('structSteelDark', [Wt + 0.1, 0.1, D], [0, y0 + 0.05, -D / 2])\n  k.box(dark, [Wt + 0.1, 0.09, 0.006], [0, y0 + 0.05, 0.003])\n  // end covers\n  for (const s of [-1, 1]) k.box(body, [0.05, H, D], [s * (Wt / 2 + 0.025), y0 + H / 2, -D / 2])\n  const yAt = (f: number) => yb + Hf * (1 - f) // canvas fraction from the top -> y\n\n  for (let i = 0; i < n; i++) {\n    const xc = -Wt / 2 + pw * (i + 0.5)\n    k.box(body, [pw - 0.006, Hf, D - 0.02], [xc, yb + Hf / 2, -D / 2 + 0.01])\n    // door hinges on the left jamb\n    if (o.kind === 'mv') {\n      const incomer = i === 0 || (i === n - 1 && n > 3)\n      const busSection = n >= 6 && i === Math.floor(n / 2)\n      const name = incomer ? `INCOMER ${i === 0 ? 1 : 2}` : busSection ? 'BUS SECTION' : `FEEDER ${String(i).padStart(2, '0')}`\n      k.plane(mvFrontMaterial(name, o.kv, incomer), pw - 0.02, Hf, [xc, yb + Hf / 2, 0.003], front)\n      // LV / relay compartment: protection relay IED, ammeter, lamps, selector, MCB window\n      const yr = yAt(0.16)\n      k.box(dark, [0.2, 0.26, 0.08], [xc - pw * 0.18, yr, 0.04])\n      k.plane(relayFaceMaterial(), 0.18, 0.225, [xc - pw * 0.18, yr, 0.0815], front)\n      gauge(k, [xc + pw * 0.2, yAt(0.1), 0.003], 'z', 0.08, 'bar', 600)\n      const cols = ['green', 'red', 'amber', 'white'] as const\n      cols.forEach((c, j) => {\n        const lx = xc + pw * 0.08 + j * 0.045\n        k.cyl('nut', 0.014, 0.02, [lx, yAt(0.23), 0.012], 'z', 12)\n        k.cyl(lamp(c), 0.011, 0.012, [lx, yAt(0.23), 0.026], 'z', 12)\n      })\n      k.cyl('nut', 0.022, 0.02, [xc + pw * 0.28, yAt(0.23), 0.012], 'z', 14) // local / remote selector\n      k.box('rubberBlack', [0.012, 0.05, 0.02], [xc + pw * 0.28, yAt(0.23), 0.03])\n      // breaker compartment: viewing window, racking hole, push buttons, position indicator, handle\n      k.box('nut', [pw * 0.7, 0.15, 0.012], [xc, yAt(0.6), 0.006])\n      k.box('glass', [pw * 0.66, 0.12, 0.006], [xc, yAt(0.6), 0.014])\n      k.cyl('rubberBlack', 0.02, 0.02, [xc, yAt(0.5), 0.01], 'z', 14)\n      k.cyl(lamp('green'), 0.016, 0.018, [xc - 0.06, yAt(0.45), 0.012], 'z', 14)\n      k.cyl('actuatorRed', 0.016, 0.018, [xc + 0.06, yAt(0.45), 0.012], 'z', 14)\n      k.rod('stainless', [xc + pw * 0.4, yAt(0.36), 0.04], [xc + pw * 0.4, yAt(0.52), 0.04], 0.012, 8)\n      for (const f of [0.37, 0.51]) k.box('stainless', [0.02, 0.02, 0.04], [xc + pw * 0.4, yAt(f), 0.02])\n      // cable compartment: bolted cover, voltage presence indicators, interlock\n      for (const f of [0.73, 0.98]) for (const s of [-1, 1]) k.hex('nut', 0.022, 0.012, [xc + s * pw * 0.42, yAt(f), 0.009], 'z')\n      for (let j = 0; j < 3; j++) k.cyl(lamp('amber'), 0.008, 0.012, [xc - 0.04 + j * 0.04, yAt(0.84), 0.009], 'z', 10)\n      for (const f of [0.08, 0.5, 0.86]) k.cyl('nut', 0.012, 0.08, [xc - pw / 2 + 0.02, yAt(f), 0.01], 'y', 8)\n    } else {\n      const tiers = o.kind === 'mcc' ? 7 : 4\n      k.plane(mccFrontMaterial(i + 1, tiers), pw - 0.02, Hf, [xc, yb + Hf / 2, 0.003], front)\n      const th = (Hf * (1 - 60 / 736)) / tiers\n      for (let t = 0; t < tiers; t++) {\n        const yt = yAt(40 / 736) - th * (t + 0.5)\n        // rotary isolator: yellow plate, red handle\n        k.box('actuatorYellow', [0.07, 0.07, 0.01], [xc - pw * 0.3, yt, 0.008])\n        k.cyl('nut', 0.018, 0.02, [xc - pw * 0.3, yt, 0.02], 'z', 12)\n        k.box('actuatorRed', [0.018, 0.075, 0.02], [xc - pw * 0.3, yt, 0.035], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), t % 3 === 2 ? Math.PI / 2 : 0))\n        const cs = t % 3 === 2 ? (['red', 'amber'] as const) : (['green', 'white'] as const)\n        cs.forEach((c, j) => {\n          k.cyl('nut', 0.012, 0.016, [xc + pw * 0.08 + j * 0.04, yt - th * 0.18, 0.01], 'z', 10)\n          k.cyl(lamp(c), 0.009, 0.01, [xc + pw * 0.08 + j * 0.04, yt - th * 0.18, 0.022], 'z', 10)\n        })\n        if (o.kind === 'lv' && t === 0) gauge(k, [xc + pw * 0.3, yt, 0.003], 'z', 0.07, 'bar', 600)\n        for (const s of [-1, 1]) k.box('stainless', [0.03, 0.012, 0.02], [xc + s * pw * 0.42, yt + th * 0.3, 0.012])\n      }\n    }\n    // rear: bolted cable / busbar compartment covers with warning label (rear access for termination and CT work)\n    {\n      const zr = -D - 0.003\n      const covers = o.kind === 'mv' ? [[0.08, 0.52], [0.56, 0.97]] : [[0.1, 0.95]]\n      for (const [f0, f1] of covers) {\n        const yc = (yAt(f0) + yAt(f1)) / 2\n        const hh = yAt(f0) - yAt(f1)\n        k.box(body, [pw - 0.06, hh, 0.006], [xc, yc, zr])\n        for (const sy of [-1, 1]) for (const sx of [-1, 0, 1]) k.hex('nut', 0.02, 0.01, [xc + sx * (pw / 2 - 0.06), yc + sy * (hh / 2 - 0.04), zr - 0.006], 'z')\n      }\n      k.plane(textMaterial('DANGER  HV' , '#f4c414', '#111111', 256, 64), 0.16, 0.04, [xc, yAt(0.3), zr - 0.004], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI))\n    }\n    if (o.kind === 'mv') {\n      // earthing-switch operating socket with mechanical position indicator and padlockable shutter\n      k.cyl('nut', 0.022, 0.012, [xc - pw * 0.25, yAt(0.65), 0.006], 'z', 14)\n      k.cyl('rubberBlack', 0.012, 0.014, [xc - pw * 0.25, yAt(0.65), 0.008], 'z', 12)\n      k.box('actuatorYellow', [0.05, 0.02, 0.006], [xc - pw * 0.25, yAt(0.62), 0.006])\n    }\n    // top: pressure-relief flap (MV) or ventilation hood\n    if (o.kind === 'mv') k.box(body, [pw * 0.8, 0.012, D * 0.4], [xc, yb + Hf + 0.006, -D * 0.3], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -0.08))\n    else k.box(dark, [pw * 0.7, 0.05, D * 0.5], [xc, yb + Hf + 0.025, -D * 0.5])\n  }\n  // arc-gas exhaust duct (MV) or busbar trunking (LV) along the top rear\n  if (o.kind === 'mv') {\n    k.box(body, [Wt + 0.1, 0.35, 0.45], [0, yb + Hf + 0.2, -D + 0.23])\n  }\n  // line-up designation strip\n  if (o.tag) {\n    const yt = o.kind === 'mv' ? yb + Hf + 0.2 : yb + Hf - 0.06\n    const zt = o.kind === 'mv' ? -D + 0.457 : 0.006\n    k.plane(textMaterial(o.tag, '#1f4e9a', '#ffffff', 512, 64), Math.min(Wt * 0.5, 1.6), 0.09, [0, yt, zt], front)\n  }\n  return { w: Wt + 0.1, h: H + (o.kind === 'mv' ? 0.37 : 0.05), d: D }\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": "EHouse",
    "anchor": "base",
    "standards": [
      "IEC 62271-202",
      "IEC 61439",
      "ISO 14122-3/4",
      "SOLAS / IMO FTP Code (A60)",
      "NORSOK C-002"
    ]
  }
}