{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "room",
  "type": "registry:component",
  "title": "Room shell (control room, LER, office)",
  "description": "Walk-through room: lined walls with real openings from a `doors` array (wall side, offset, kind, open…) and a `windows` array, each filled with the door and window components. Raised access floor (slab at y = 0, finished floor at `floorHeight`, 600 mm tiles, pedestals and cables visible at the cut, perforated supply tiles) or carpet tiles; suspended 600 mm ceiling with LED panels, slot diffusers, smoke detectors, PA speakers and gas-suppression nozzles; ladder tray in the ceiling void; cove skirting; exit sign, call point, switch and CO2 extinguisher inside each door. `cutaway` leaves out the ceiling and the +Z wall so the twin can look in. Place consoles, video walls and cabinets inside on the finished floor.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json",
    "https://deck-twin-library.demoareeb.workers.dev/r/door.json",
    "https://deck-twin-library.demoareeb.workers.dev/r/window.json"
  ],
  "files": [
    {
      "path": "interiors/room.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/room.tsx",
      "content": "// Walk-through room shell: lined walls with real door and window openings, raised access floor, suspended ceiling with LED panels and diffusers, cutaway view. ISO 11064-3, EN 12825.\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport { Kit, eul, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport Door, { doorOpening, type DoorKind } from './door'\nimport Window, { windowOpening } from './window'\nimport { furnitureMats } from './_furniture'\nimport { ceilingMaterial, diffuserMaterial, exitSignMaterial, ledPanelMaterial, liningMaterial, raisedFloorMaterial, carpetMaterial } from './_textures'\nimport { wallWithHoles, type Hole } from './_wall'\n\nexport type WallSide = '+z' | '-z' | '+x' | '-x'\n\n/** Door in a wall: `offset` runs along the wall, left to right seen from outside; leaves swing outwards. */\nexport interface RoomDoor {\n  wall: WallSide\n  offset?: number\n  kind?: DoorKind\n  width?: number\n  height?: number\n  open?: number\n  hand?: 'left' | 'right'\n  hardware?: 'lever' | 'panic'\n  tag?: string\n}\nexport interface RoomWindow {\n  wall: WallSide\n  offset?: number\n  width?: number\n  height?: number\n  sill?: number\n  kind?: 'fixed' | 'blast'\n  panes?: number\n  sunshade?: boolean\n}\n\nexport interface RoomProps {\n  /** clear inside width along X, m */\n  width?: number\n  /** clear inside depth along Z, m */\n  depth?: number\n  /** finished floor to ceiling, m */\n  height?: number\n  wallThickness?: number\n  /** raised access floor; the slab is y = 0 and the finished floor is at `floorHeight` */\n  raisedFloor?: boolean\n  floorHeight?: number\n  /** suspended ceiling with LED panels, diffusers, detectors */\n  ceiling?: boolean\n  /** ladder tray in the ceiling void */\n  cableTray?: boolean\n  /** leave out the ceiling and the +Z wall so you can look in */\n  cutaway?: boolean\n  doors?: RoomDoor[]\n  windows?: RoomWindow[]\n  fireRating?: 'A60' | 'H120' | 'none'\n  label?: string\n  tag?: string\n}\n\nexport const defaults = {\n  width: 8,\n  depth: 6,\n  height: 3,\n  wallThickness: 0.15,\n  raisedFloor: true,\n  floorHeight: 0.3,\n  ceiling: true,\n  cableTray: true,\n  cutaway: true,\n  doors: [\n    { wall: '-x', offset: 1.4, kind: 'single', open: 0.5, hardware: 'panic' },\n    { wall: '+x', offset: -1.2, kind: 'double', open: 0 },\n  ] as RoomDoor[],\n  windows: [{ wall: '-z', offset: 1.6, width: 1.8, height: 0.9, sill: 1.0 }] as RoomWindow[],\n  fireRating: 'A60' as 'A60' | 'H120' | 'none',\n  label: 'CENTRAL CONTROL ROOM',\n  tag: 'B-300',\n}\n\nconst PLENUM = 0.6\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\ntype P = typeof defaults\n\n/** Wall frames: centre-line origin, rotation so local +Z points out of the room, length, which spans the corners. */\nfunction walls(W: number, D: number, T: number) {\n  const mk = (id: WallSide, rotY: number, pos: V3, len: number) => ({ id, rotY, pos, len, m: new THREE.Matrix4().makeRotationY(rotY).setPosition(...pos) })\n  return [mk('+z', 0, [0, 0, D / 2 + T / 2], W + 2 * T), mk('-z', Math.PI, [0, 0, -D / 2 - T / 2], W + 2 * T), mk('+x', Math.PI / 2, [W / 2 + T / 2, 0, 0], D), mk('-x', -Math.PI / 2, [-W / 2 - T / 2, 0, 0], D)]\n}\n\nexport function Room(props: RoomProps & GroupProps) {\n  const { width, depth, height, wallThickness, raisedFloor, floorHeight, ceiling, cableTray, cutaway, doors, windows, fireRating, label, tag, ...group } = { ...defaults, ...props }\n  const doorsKey = JSON.stringify(doors)\n  const winKey = JSON.stringify(windows)\n  const p: P = { width, depth, height, wallThickness, raisedFloor, floorHeight, ceiling, cableTray, cutaway, doors, windows, fireRating, label, tag }\n  const b = useMemo(() => build(p), [width, depth, height, wallThickness, raisedFloor, floorHeight, ceiling, cableTray, cutaway, doorsKey, winKey, fireRating, label, tag]) // eslint-disable-line react-hooks/exhaustive-deps\n  useEffect(() => () => b.built.geometry.dispose(), [b])\n  return (\n    <group {...group}>\n      <KitMesh data={b.built} />\n      {b.doors.map((d, i) => (\n        <Door key={`d${i}`} {...d.props} wall=\"none\" wallThickness={b.T} sign=\"room\" label={label} fireRating={fireRating} position={d.pos} rotation={[0, d.rotY, 0]} />\n      ))}\n      {b.windows.map((w, i) => (\n        <Window key={`w${i}`} {...w.props} wall=\"none\" wallThickness={b.T} fireRating={fireRating} position={w.pos} rotation={[0, w.rotY, 0]} />\n      ))}\n    </group>\n  )\n}\n\nexport default Room\n\nfunction build(p: P) {\n  const k = new Kit()\n  const M = furnitureMats()\n  const W = clamp(p.width, 2, 40)\n  const D = clamp(p.depth, 2, 30)\n  const T = clamp(p.wallThickness, 0.06, 0.4)\n  const FH = p.raisedFloor ? clamp(p.floorHeight, 0.1, 1.2) : 0\n  const CH = FH + clamp(p.height, 2.3, 6)\n  const top = CH + PLENUM\n  const ws = walls(W, D, T)\n  const skirt = paint('#3a3d40', { metalness: 0, roughness: 0.7, clearcoat: 0, weather: 0 })\n  const doors: { props: Omit<RoomDoor, 'wall' | 'offset'>; pos: V3; rotY: number }[] = []\n  const wins: { props: Omit<RoomWindow, 'wall' | 'offset'>; pos: V3; rotY: number }[] = []\n\n  for (const w of ws) {\n    if (p.cutaway && w.id === '+z') continue\n    const holes: Hole[] = []\n    const inner: { u: number; hw: number; top: number; door: boolean; hand: number }[] = []\n    for (const d of p.doors.filter((x) => x.wall === w.id)) {\n      const o = doorOpening(d)\n      const u = clamp(d.offset ?? 0, -w.len / 2 + o.holeW / 2 + 0.05, w.len / 2 - o.holeW / 2 - 0.05)\n      holes.push({ u, w: o.holeW, y0: FH, y1: FH + o.holeH })\n      inner.push({ u, hw: o.holeW, top: FH + (d.kind === 'roller-shutter' ? o.H : o.holeH), door: true, hand: d.hand === 'right' ? -1 : 1 })\n      doors.push({ props: strip(d), pos: new THREE.Vector3(u, FH, 0).applyMatrix4(w.m).toArray() as V3, rotY: w.rotY })\n    }\n    for (const win of p.windows.filter((x) => x.wall === w.id)) {\n      const o = windowOpening(win)\n      const u = clamp(win.offset ?? 0, -w.len / 2 + o.holeW / 2 + 0.05, w.len / 2 - o.holeW / 2 - 0.05)\n      holes.push({ u, w: o.holeW, y0: FH + o.y0, y1: FH + o.y1 })\n      wins.push({ props: strip(win), pos: new THREE.Vector3(u, FH, 0).applyMatrix4(w.m).toArray() as V3, rotY: w.rotY })\n    }\n    k.with(w.m, () => {\n      wallWithHoles(k, w.len, top, T, holes, liningMaterial())\n      // cove skirting on the inside face, broken at door openings\n      const zi = -T / 2 - 0.006\n      const cuts = holes.filter((h) => h.y0 <= FH + 0.01).sort((a, b) => a.u - b.u)\n      const inL = w.id === '+z' || w.id === '-z' ? W : D\n      let cur = -inL / 2\n      for (const h of [...cuts, { u: inL / 2 + 1, w: 0, y0: 0, y1: 0 }]) {\n        const a = cur\n        const b = Math.min(inL / 2, h.u - h.w / 2)\n        if (b - a > 0.02) k.box(skirt, [b - a, 0.1, 0.012], [(a + b) / 2, FH + 0.05, zi])\n        cur = h.u + h.w / 2\n      }\n      // inside each door: maintained exit sign, manual call point, light switch, CO2 extinguisher\n      for (const d of inner) {\n        const back = eul(0, Math.PI, 0)\n        k.rbox(M.housing, [0.42, 0.22, 0.03], 0.01, [d.u, d.top + 0.25, zi - 0.009])\n        k.plane(exitSignMaterial(), 0.4, 0.2, [d.u, d.top + 0.25, zi - 0.0245], back)\n        const lx = d.u + d.hand * (d.hw / 2 + 0.25)\n        k.rbox('actuatorRed', [0.1, 0.1, 0.05], 0.01, [lx, FH + 1.4, zi - 0.019])\n        k.box('glass', [0.06, 0.06, 0.004], [lx, FH + 1.4, zi - 0.045])\n        k.rbox('plasticGrey', [0.08, 0.08, 0.015], 0.006, [lx, FH + 1.05, zi - 0.0015])\n        const ex = d.u - d.hand * (d.hw / 2 + 0.35)\n        const co2 = paint('#1c1e20', { roughness: 0.4, weather: 0 })\n        k.box(M.housing, [0.14, 0.05, 0.05], [ex, FH + 1.0, zi - 0.019])\n        k.cyl(co2, 0.075, 0.6, [ex, FH + 0.62, zi - 0.1], 'y', 18)\n        k.dome(co2, 0.075, [ex, FH + 0.92, zi - 0.1], 'y', Math.PI / 2, 0.5, 16)\n        k.cyl('actuatorRed', 0.012, 0.12, [ex, FH + 1.0, zi - 0.1], 'y', 8)\n        k.rod('rubberBlack', [ex + 0.03, FH + 0.98, zi - 0.1], [ex + 0.12, FH + 0.6, zi - 0.13], 0.012, 8)\n      }\n    })\n  }\n  // outside: room tag on the -X end\n  tagPlate(k, p.tag, [-W / 2 - T - 0.001, FH + 2.6, -D / 2 + 0.6], '-x', 0.3)\n\n  // floor: raised access floor tiles on pedestals (void visible at the cut), or carpet tiles on the slab\n  if (p.raisedFloor) {\n    const g = new THREE.BoxGeometry(W, 0.03, D)\n    remapTop(g, W, D, 0.6)\n    k.add(raisedFloorMaterial(), g, new THREE.Matrix4().makeTranslation(0, FH - 0.015, 0))\n    // pedestals and stringers along the front rows; a few cable bundles in the void\n    const rows = p.cutaway ? 3 : 1\n    for (let r = 0; r < rows; r++) {\n      const z = D / 2 - 0.3 - r * 0.6\n      for (let x = -W / 2 + 0.6; x < W / 2 - 0.01; x += 0.6) {\n        k.box('galvanised', [0.1, 0.004, 0.1], [x, 0.002, z])\n        k.cyl('galvanised', 0.012, FH - 0.04, [x, (FH - 0.04) / 2, z], 'y', 8)\n        k.box('galvanised', [0.08, 0.008, 0.08], [x, FH - 0.034, z])\n      }\n      k.box('galvanised', [W, 0.025, 0.02], [0, FH - 0.0425, z])\n    }\n    k.box('aluminium', [W, 0.035, 0.012], [0, FH - 0.0175, D / 2 + 0.006])\n    for (let c = 0; c < 6; c++) {\n      const z = D / 2 - 0.9 - (c % 3) * 0.08\n      k.rod('cable', [-W / 2, 0.05 + Math.floor(c / 3) * 0.05, z], [W / 2, 0.05 + Math.floor(c / 3) * 0.05, z], 0.022, 8)\n    }\n    // perforated supply tiles in front of where consoles go\n    for (const x of [-1.2, 1.2]) k.box(paint('#6b7176', { weather: 0 }), [0.58, 0.002, 0.58], [x, FH + 0.001, 0.9])\n  } else {\n    const g = new THREE.BoxGeometry(W, 0.01, D)\n    remapTop(g, W, D, 0.5)\n    k.add(carpetMaterial(), g, new THREE.Matrix4().makeTranslation(0, 0.005, 0))\n  }\n\n  // suspended ceiling with LED panels, slot diffusers, smoke detectors, PA speakers, gas suppression nozzles\n  if (p.ceiling && !p.cutaway) {\n    const g = new THREE.BoxGeometry(W, 0.02, D)\n    remapTop(g, W, D, 0.6)\n    k.add(ceilingMaterial(), g, new THREE.Matrix4().makeTranslation(0, CH + 0.01, 0))\n    const nx = Math.max(1, Math.round(W / 1.8))\n    const nz = Math.max(1, Math.round(D / 1.8))\n    for (let i = 0; i < nx; i++)\n      for (let j = 0; j < nz; j++) {\n        const x = snap(-W / 2 + ((i + 0.5) * W) / nx)\n        const z = snap(-D / 2 + ((j + 0.5) * D) / nz)\n        k.box(M.alu, [0.6, 0.012, 0.6], [x, CH - 0.006, z])\n        k.plane(ledPanelMaterial(), 0.57, 0.57, [x, CH - 0.0125, z], eul(Math.PI / 2, 0, 0))\n      }\n    for (let i = 0; i < Math.max(1, nx - 1); i++) {\n      const x = snap(-W / 2 + ((i + 1) * W) / nx)\n      for (const z of [-D / 4, D / 4]) k.plane(diffuserMaterial(), 1.2, 0.12, [x, CH - 0.002, snap(z)], eul(Math.PI / 2, 0, Math.PI / 2))\n    }\n    for (const [x, z] of [[-W / 4, 0], [W / 4, 0]] as const) {\n      k.cyl('plasticGrey', 0.05, 0.04, [x, CH - 0.02, z + 0.3], 'y', 18)\n      k.cyl(M.ledRed, 0.006, 0.004, [x + 0.03, CH - 0.042, z + 0.3], 'y', 8)\n      k.cyl('plasticGrey', 0.09, 0.02, [x + 0.6, CH - 0.01, z - 0.3], 'y', 24)\n      k.cyl('stainless', 0.02, 0.05, [x - 0.6, CH - 0.025, z], 'y', 10)\n    }\n  }\n  // ceiling void: ladder tray on unistrut trapezes spanning the room (cut away with the ceiling)\n  if (p.cableTray && !p.cutaway) {\n    const yT = CH + 0.25\n    const tw = 0.45\n    const zc = -D / 2 + 0.6\n    for (const s of [-1, 1]) k.box('cableTray', [W, 0.1, 0.006], [0, yT + 0.05, zc + (s * tw) / 2])\n    for (let x = -W / 2 + 0.15; x < W / 2; x += 0.3) k.box('cableTray', [0.03, 0.02, tw], [x, yT + 0.01, zc])\n    for (let x = -W / 2 + 1; x < W / 2; x += 1.5) {\n      k.box('galvanised', [0.041, 0.041, D], [x, yT - 0.0205, 0])\n    }\n    for (let c = 0; c < 8; c++) k.rod('cable', [-W / 2, yT + 0.03 + (c % 2) * 0.03, zc - 0.17 + c * 0.045], [W / 2, yT + 0.03 + (c % 2) * 0.03, zc - 0.17 + c * 0.045], 0.014, 6)\n  }\n  return { built: k.build(), doors, windows: wins, T }\n}\n\nconst strip = <T extends { wall: WallSide; offset?: number }>({ wall: _w, offset: _o, ...rest }: T) => rest\nconst snap = (v: number) => Math.round(v / 0.6 - 0.5) * 0.6 + 0.3\n\n/** UVs on a flat box so the top face tiles at `module` metres. */\nfunction remapTop(g: THREE.BufferGeometry, W: number, D: number, module: number) {\n  const pos = g.getAttribute('position') as THREE.BufferAttribute\n  const uv = g.getAttribute('uv') as THREE.BufferAttribute\n  for (let i = 0; i < pos.count; i++) uv.setXY(i, (pos.getX(i) + W / 2) / module, (pos.getZ(i) + D / 2) / module)\n}\n"
    },
    {
      "path": "interiors/_furniture.ts",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/_furniture.ts",
      "content": "// Shared control-room furniture parts: monitors, keyboards, phones, radios, task lights and the 24/7 operator chair.\nimport * as THREE from 'three'\nimport { Kit, eul, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport type { ScreenKind, ScreenPlacement } from './_screens'\nimport { keyboardMaterial } from './_textures'\n\nexport const INCH = 0.0254\n\n/** Active area of a 16:9 display of diagonal `d` inches, metres. */\nexport function panelSize(d: number): [number, number] {\n  const k = (d * INCH) / Math.hypot(16, 9)\n  return [16 * k, 9 * k]\n}\n\n/** Matrix for a position plus an XYZ Euler rotation (radians). */\nexport const frame = (pos: V3, rot: V3 = [0, 0, 0]) => new THREE.Matrix4().compose(new THREE.Vector3(...pos), new THREE.Quaternion().setFromEuler(new THREE.Euler(...rot)), new THREE.Vector3(1, 1, 1))\n\n/** Screen placement for a face at `local` inside frame `m`. */\nexport function placeScreen(m: THREE.Matrix4, local: V3, w: number, h: number, kind: ScreenKind): ScreenPlacement {\n  const mm = m.clone().multiply(new THREE.Matrix4().makeTranslation(...local))\n  const p = new THREE.Vector3()\n  const q = new THREE.Quaternion()\n  mm.decompose(p, q, new THREE.Vector3())\n  const e = new THREE.Euler().setFromQuaternion(q)\n  return { pos: p.toArray() as V3, rot: [e.x, e.y, e.z], w, h, kind }\n}\n\nexport const furnitureMats = () => ({\n  housing: paint('#1d1f22', { metalness: 0.2, roughness: 0.55, clearcoat: 0, weather: 0 }),\n  bezel: paint('#101113', { metalness: 0.1, roughness: 0.4, clearcoat: 0.2, weather: 0 }),\n  alu: paint('#c4c8cb', { metalness: 0.75, roughness: 0.32, clearcoat: 0, weather: 0 }),\n  fabric: paint('#2b2f35', { metalness: 0, roughness: 0.95, clearcoat: 0, weather: 0 }),\n  mesh: paint('#1e2124', { metalness: 0, roughness: 0.9, clearcoat: 0, weather: 0 }),\n  led: paint('#ffffff', { emissive: '#f4fbff', emissiveIntensity: 2.2, roughness: 0.3, metalness: 0, clearcoat: 0, weather: 0 }),\n  ledGreen: paint('#3bd16f', { emissive: '#36e070', emissiveIntensity: 1.6, roughness: 0.3, metalness: 0, clearcoat: 0, weather: 0 }),\n  ledAmber: paint('#ffb020', { emissive: '#ffa500', emissiveIntensity: 1.6, roughness: 0.3, metalness: 0, clearcoat: 0, weather: 0 }),\n  ledRed: paint('#ff3020', { emissive: '#ff2a10', emissiveIntensity: 1.6, roughness: 0.3, metalness: 0, clearcoat: 0, weather: 0 }),\n})\n\n/**\n * Narrow-bezel desktop monitor in frame `m`: screen face centred on the origin facing +Z, housing behind, VESA 100 plate.\n * Returns the screen placement. `arm` adds a short arm stub back to z = -armLen.\n */\nexport function monitor(k: Kit, m: THREE.Matrix4, diag: number, kind: ScreenKind, o: { arm?: number; stand?: boolean } = {}): ScreenPlacement {\n  const M = furnitureMats()\n  const [w, h] = panelSize(diag)\n  const b = 0.007 // bezel\n  const t = 0.018 + diag * 0.0006\n  k.with(m, () => {\n    k.rbox(M.bezel, [w + 2 * b, h + 2 * b + 0.004, 0.012], 0.004, [0, -0.002, -0.006])\n    k.rbox(M.housing, [w * 0.92, h * 0.86, t], 0.012, [0, -0.004, -0.012 - t / 2])\n    k.box(M.housing, [w * 0.6, h * 0.5, 0.02], [0, 0, -0.012 - t - 0.008])\n    k.box('aluminium', [0.11, 0.11, 0.006], [0, 0, -0.012 - t - 0.021])\n    k.box(M.ledGreen, [0.004, 0.003, 0.002], [w / 2 - 0.02, -h / 2 - b + 0.001, 0.001])\n    const back = -0.012 - t - 0.024\n    if (o.arm) {\n      k.rod(M.alu, [0, 0, back], [0, 0, back - o.arm], 0.014, 12)\n      k.cyl(M.alu, 0.022, 0.05, [0, 0, back - 0.01], 'z', 14)\n    }\n    if (o.stand) {\n      const y0 = -h / 2 - b - 0.12\n      k.rbox(M.housing, [0.06, h * 0.75, 0.025], 0.008, [0, y0 + (h * 0.75) / 2 + 0.01, back - 0.02])\n      k.rbox(M.housing, [0.26, 0.012, 0.2], 0.005, [0, y0 + 0.006, back - 0.02])\n    }\n  })\n  return placeScreen(m, [0, 0, 0.0008], w, h, kind)\n}\n\n/** Full-size keyboard (front edge towards +Z) and mouse, standing on y = 0 of frame m. */\nexport function keyboardAndMouse(k: Kit, m: THREE.Matrix4, mouseX = 0.3) {\n  const M = furnitureMats()\n  k.with(m, () => {\n    k.rbox(M.housing, [0.44, 0.022, 0.135], 0.006, [0, 0.011, 0], eul(0.06, 0, 0))\n    k.plane(keyboardMaterial(), 0.42, 0.115, [0, 0.0235, -0.002], eul(-Math.PI / 2 + 0.06, 0, 0))\n    k.sphere(M.housing, 0.032, [mouseX, 0.014, 0.01], [0.95, 0.42, 1.7], 14)\n    k.box('rubberBlack', [0.22, 0.003, 0.19], [mouseX, 0.0015, 0.0])\n  })\n}\n\n/** Desk phone with handset, on y = 0 of frame m, facing +Z. */\nexport function deskPhone(k: Kit, m: THREE.Matrix4) {\n  const M = furnitureMats()\n  k.with(m, () => {\n    k.rbox(M.housing, [0.2, 0.05, 0.19], 0.012, [0, 0.03, 0], eul(-0.25, 0, 0))\n    k.rbox(M.housing, [0.05, 0.035, 0.21], 0.015, [-0.065, 0.075, -0.005], eul(-0.25, 0, 0))\n    k.box('glass', [0.09, 0.04, 0.002], [0.035, 0.07, -0.02], eul(-0.25 - Math.PI / 2 + 1.3, 0, 0))\n    for (let r = 0; r < 4; r++) for (let c = 0; c < 3; c++) k.box('plasticGrey', [0.018, 0.006, 0.012], [0.01 + c * 0.024, 0.052 + r * -0.008 + 0.012, 0.03 + r * 0.02])\n  })\n}\n\n/** UHF desk radio base station with fist microphone, on y = 0 of frame m. */\nexport function deskRadio(k: Kit, m: THREE.Matrix4) {\n  const M = furnitureMats()\n  k.with(m, () => {\n    k.rbox(M.housing, [0.24, 0.07, 0.2], 0.01, [0, 0.035, 0])\n    k.box(M.bezel, [0.1, 0.035, 0.002], [-0.04, 0.04, 0.1])\n    k.box(M.ledAmber, [0.08, 0.02, 0.001], [-0.04, 0.043, 0.1015])\n    k.cyl('plasticGrey', 0.018, 0.02, [0.07, 0.04, 0.105], 'z', 16)\n    k.rbox(M.housing, [0.055, 0.08, 0.03], 0.012, [0.16, 0.05, 0.03], eul(0, 0, 0.2))\n    k.spline('cable', [[0.15, 0.015, 0.03], [0.13, 0.02, 0.09], [0.1, 0.03, 0.1]], 0.003, 5, 12)\n    k.rod('rubberBlack', [0.09, 0.07, -0.08], [0.09, 0.2, -0.08], 0.005, 6)\n  })\n}\n\n/** Clamp-on LED task light on an articulated arm, base at the origin of m, head reaching +Z. */\nexport function taskLight(k: Kit, m: THREE.Matrix4) {\n  const M = furnitureMats()\n  k.with(m, () => {\n    k.cyl(M.housing, 0.035, 0.03, [0, 0.015, 0], 'y', 16)\n    const a: V3 = [0, 0.03, 0]\n    const b: V3 = [0, 0.38, -0.04]\n    const c: V3 = [0, 0.46, 0.3]\n    k.rod(M.alu, a, b, 0.008, 8)\n    k.sphere(M.housing, 0.014, b, undefined, 10)\n    k.rod(M.alu, b, c, 0.007, 8)\n    k.rbox(M.housing, [0.26, 0.016, 0.05], 0.006, [0, c[1] - 0.006, c[2] + 0.02])\n    k.box(M.led, [0.23, 0.002, 0.03], [0, c[1] - 0.015, c[2] + 0.02])\n  })\n}\n\nexport interface ChairOpts {\n  /** seat top height, m */\n  seat?: number\n  headrest?: boolean\n  arms?: boolean\n  upholstery?: MatKey | THREE.Material\n  /** draw only the static base or only the swivelling part */\n  part?: 'all' | 'base' | 'top'\n}\n\n/**\n * 24/7 intensive-use control-room chair facing +Z: five-star base with twin-wheel castors, gas lift,\n * synchro mechanism, upholstered seat, tall back with lumbar shaping, headrest and height-adjustable arms.\n */\nexport function chair(k: Kit, m: THREE.Matrix4, o: ChairOpts = {}) {\n  const M = furnitureMats()\n  const seatY = o.seat ?? 0.5\n  const up = o.upholstery ?? M.fabric\n  const part = o.part ?? 'all'\n  const hubY = 0.1\n  const top = seatY - 0.11\n  k.with(m, () => {\n    if (part === 'top') return\n    // base: 5 polished aluminium legs (700 mm span), castors\n    k.cyl(M.alu, 0.045, 0.06, [0, hubY, 0], 'y', 20)\n    for (let i = 0; i < 5; i++) {\n      const a = (i / 5) * Math.PI * 2 + Math.PI / 2\n      const c = Math.cos(a)\n      const s = Math.sin(a)\n      const R = 0.33\n      k.add(M.alu, legGeo(R), new THREE.Matrix4().makeRotationY(-a).setPosition(0, hubY, 0))\n      const cx = c * R\n      const cz = s * R\n      k.cyl(M.housing, 0.012, 0.035, [cx, 0.075, cz], 'y', 10)\n      k.rbox(M.housing, [0.055, 0.03, 0.03], 0.008, [cx, 0.06, cz])\n      for (const sd of [-1, 1]) k.cyl('rubberBlack', 0.03, 0.014, [cx + -s * sd * 0.016, 0.03, cz + c * sd * 0.016], [-s, 0, c] as V3, 14)\n    }\n    // gas lift: column, telescoping shroud\n    k.cyl(M.housing, 0.028, Math.max(0.05, top - 0.25), [0, 0.13 + Math.max(0.05, top - 0.25) / 2, 0], 'y', 16)\n    k.cyl('stainless', 0.014, top - hubY, [0, (top + hubY) / 2, 0], 'y', 12)\n  })\n  if (part === 'base') return\n  k.with(m, () => {\n    // mechanism and seat pan\n    k.rbox(M.housing, [0.26, 0.06, 0.3], 0.015, [0, top + 0.02, -0.01])\n    k.rod(M.housing, [0.12, top + 0.03, 0.08], [0.2, top + 0.0, 0.12], 0.008, 6)\n    k.rbox(M.housing, [0.5, 0.025, 0.47], 0.01, [0, seatY - 0.075, 0.02])\n    k.rbox(up, [0.52, 0.075, 0.5], 0.035, [0, seatY - 0.035, 0.02], undefined, 4)\n    k.cyl(up, 0.035, 0.5, [0, seatY - 0.04, 0.26], 'x', 14) // waterfall front edge\n    // back: shell with lumbar, upholstered front\n    const by = seatY + 0.05\n    const bh = 0.62\n    k.box(M.housing, [0.08, 0.3, 0.03], [0, seatY - 0.08 + 0.12, -0.28], eul(-0.35, 0, 0))\n    k.with(new THREE.Matrix4().compose(new THREE.Vector3(0, by, -0.26), new THREE.Quaternion().setFromEuler(new THREE.Euler(-0.14, 0, 0)), new THREE.Vector3(1, 1, 1)), () => {\n      k.rbox(M.housing, [0.47, bh, 0.04], 0.03, [0, bh / 2, -0.04], undefined, 4)\n      k.rbox(up, [0.49, bh * 0.55, 0.08], 0.035, [0, bh * 0.68, 0.0], undefined, 4)\n      k.rbox(up, [0.48, bh * 0.42, 0.1], 0.04, [0, bh * 0.22, 0.012], undefined, 4) // lumbar\n      if (o.headrest !== false) {\n        k.box(M.alu, [0.03, 0.14, 0.02], [0, bh + 0.05, -0.05])\n        k.rbox(up, [0.3, 0.16, 0.08], 0.035, [0, bh + 0.15, -0.01], eul(0.15, 0, 0), 4)\n      }\n    })\n    // arms: posts from under the seat, height-adjustable, soft pads\n    if (o.arms !== false)\n      for (const s of [-1, 1]) {\n        k.box(M.housing, [0.04, 0.02, 0.18], [s * 0.2, seatY - 0.1, -0.02])\n        k.box(M.housing, [0.2, 0.02, 0.04], [s * 0.15, seatY - 0.1, -0.02])\n        k.rbox(M.housing, [0.035, 0.24, 0.06], 0.01, [s * 0.285, seatY + 0.03, -0.02])\n        k.rbox('rubberBlack', [0.08, 0.035, 0.26], 0.015, [s * 0.285, seatY + 0.165, 0.0])\n      }\n  })\n}\n\n/** Tapered chair leg from the hub out along +X to radius R, curving down at the tip. */\nfunction legGeo(R: number) {\n  const s = new THREE.Shape()\n  s.moveTo(0.03, 0.02)\n  s.lineTo(R, -0.005)\n  s.lineTo(R + 0.02, -0.02)\n  s.lineTo(R, -0.03)\n  s.lineTo(0.03, -0.025)\n  s.closePath()\n  const g = new THREE.ExtrudeGeometry(s, { depth: 0.045, bevelEnabled: true, bevelSize: 0.006, bevelThickness: 0.006, bevelSegments: 2 })\n  g.translate(0, 0, -0.0225)\n  return g\n}\n"
    },
    {
      "path": "interiors/_screens.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/_screens.tsx",
      "content": "// Procedural ISA-101 style HMI screen textures (sample data, clearly labelled) and the screen-plane renderer with live-texture overrides.\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport type { V3 } from '../lib/kit'\n\nexport type ScreenKind = 'overview' | 'mimic' | 'trend' | 'alarm' | 'fg' | 'esd' | 'cctv' | 'weather' | 'helideck' | 'kpi' | 'off'\nexport const SCREEN_KINDS: ScreenKind[] = ['overview', 'mimic', 'trend', 'alarm', 'fg', 'esd', 'cctv', 'weather', 'helideck', 'kpi', 'off']\n\n/** One screen face: centre, Euler rotation (XYZ, rad), visible width and height in metres. Faces +Z of its own frame. */\nexport interface ScreenPlacement {\n  pos: V3\n  rot?: V3\n  w: number\n  h: number\n  kind: ScreenKind\n}\n\ntype G = CanvasRenderingContext2D\nconst W = 1024\nconst H = 576\nconst HEAD = 44\n\nconst C = {\n  bg: '#c4c7ca',\n  panel: '#b5b9bd',\n  panelDark: '#a4a9ae',\n  well: '#d6d8da',\n  line: '#5f656b',\n  lineLight: '#80868c',\n  text: '#1d2125',\n  dim: '#4b5157',\n  faint: '#6f757b',\n  value: '#0f2f5c',\n  header: '#9aa0a6',\n  white: '#eef0f1',\n  p1: '#d42a1f',\n  p2: '#ee8a00',\n  p3: '#efd000',\n  diag: '#a3369a',\n  info: '#2f66b8',\n}\nconst mono = (px: number, bold = true) => `${bold ? 'bold ' : ''}${px}px Menlo, Consolas, \"DejaVu Sans Mono\", \"Courier New\", monospace`\nconst sans = (px: number, bold = false) => `${bold ? 'bold ' : ''}${px}px \"Helvetica Neue\", Arial, sans-serif`\n\nfunction rng(seed: number) {\n  let s = seed >>> 0\n  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296\n}\nfunction text(g: G, s: string, x: number, y: number, o: { font?: string; color?: string; align?: CanvasTextAlign; max?: number } = {}) {\n  g.font = o.font ?? sans(18)\n  g.fillStyle = o.color ?? C.text\n  g.textAlign = o.align ?? 'left'\n  g.textBaseline = 'middle'\n  if (o.max) g.fillText(s, x, y, o.max)\n  else g.fillText(s, x, y)\n}\nfunction rect(g: G, x: number, y: number, w: number, h: number, fill?: string, stroke?: string, lw = 2) {\n  if (fill) {\n    g.fillStyle = fill\n    g.fillRect(x, y, w, h)\n  }\n  if (stroke) {\n    g.strokeStyle = stroke\n    g.lineWidth = lw\n    g.strokeRect(x + lw / 2, y + lw / 2, w - lw, h - lw)\n  }\n}\nfunction line(g: G, pts: [number, number][], color = C.line, lw = 3) {\n  g.strokeStyle = color\n  g.lineWidth = lw\n  g.beginPath()\n  pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)))\n  g.stroke()\n}\nfunction arrow(g: G, x: number, y: number, dx: number, dy: number, color = C.line, s = 9) {\n  const l = Math.hypot(dx, dy) || 1\n  const ux = dx / l\n  const uy = dy / l\n  g.fillStyle = color\n  g.beginPath()\n  g.moveTo(x, y)\n  g.lineTo(x - ux * s - uy * s * 0.6, y - uy * s + ux * s * 0.6)\n  g.lineTo(x - ux * s + uy * s * 0.6, y - uy * s - ux * s * 0.6)\n  g.closePath()\n  g.fill()\n}\nfunction header(g: G, title: string, alert?: 'red' | 'amber') {\n  const bg = alert === 'red' ? C.p1 : alert === 'amber' ? C.p2 : C.header\n  const fg = alert === 'red' ? '#fff' : C.text\n  rect(g, 0, 0, W, HEAD, bg)\n  rect(g, 0, HEAD - 2, W, 2, C.line)\n  text(g, 'SAMPLE FIELD', 14, HEAD / 2, { font: sans(19, true), color: fg })\n  rect(g, 162, 10, 2, HEAD - 20, fg)\n  text(g, title, 176, HEAD / 2, { font: sans(19), color: fg, max: W - 420 })\n  text(g, '14:32:07', W - 14, HEAD / 2, { font: mono(21), color: fg, align: 'right' })\n  g.fillStyle = fg\n  g.beginPath()\n  g.moveTo(W - 136, 14)\n  g.lineTo(W - 124, 22)\n  g.lineTo(W - 136, 30)\n  g.closePath()\n  g.fill()\n}\n/** Honesty strip: every procedural screen says it is sample content. */\nfunction footer(g: G, dark = false) {\n  const s = 'SAMPLE DATA · SIMULATED'\n  g.font = sans(13, true)\n  const w = g.measureText(s).width + 16\n  rect(g, W - w - 6, H - 24, w, 20, dark ? 'rgba(10,12,14,0.6)' : C.panelDark)\n  text(g, s, W - 14, H - 14, { font: sans(13, true), color: dark ? '#e6eaee' : C.diag, align: 'right' })\n}\nfunction box(g: G, x: number, y: number, w: number, h: number, caption?: string) {\n  rect(g, x, y, w, h, C.panel, C.lineLight, 1.5)\n  if (caption) text(g, caption, x + 10, y + 14, { font: sans(14, true), color: C.dim })\n}\nfunction valueBox(g: G, x: number, y: number, w: number, h: number, value: string, unit = '', abn?: string) {\n  rect(g, x, y, w, h, C.well, abn ?? C.lineLight, abn ? 3 : 1.5)\n  const px = Math.round(h * 0.62)\n  const uw = unit ? Math.max(30, unit.length * px * 0.38 + 8) : 0\n  text(g, value, x + w - uw - 8, y + h / 2 + 1, { font: mono(px), color: C.value, align: 'right' })\n  if (unit) text(g, unit, x + w - uw + 2, y + h / 2 + 1, { font: sans(Math.round(px * 0.6)), color: C.dim })\n  if (abn) rect(g, x - 10, y, 10, h, abn)\n}\nfunction hbar(g: G, x: number, y: number, w: number, h: number, f: number, marks: number[] = [], abn?: string) {\n  rect(g, x, y, w, h, C.well, C.lineLight, 1)\n  rect(g, x + 1, y + 1, (w - 2) * Math.max(0, Math.min(1, f)), h - 2, abn ?? '#6d747a')\n  for (const m of marks) rect(g, x + w * m - 1, y - 4, 2, h + 8, C.text)\n}\nfunction capsule(g: G, x: number, y: number, w: number, h: number, level?: number) {\n  g.save()\n  g.beginPath()\n  g.roundRect(x, y, w, h, Math.min(w, h) / 2)\n  g.fillStyle = C.panel\n  g.fill()\n  if (level !== undefined) {\n    g.clip()\n    g.fillStyle = '#8e9aa6'\n    g.fillRect(x, y + h * (1 - level), w, h * level)\n  }\n  g.restore()\n  g.beginPath()\n  g.roundRect(x, y, w, h, Math.min(w, h) / 2)\n  g.strokeStyle = C.text\n  g.lineWidth = 2.5\n  g.stroke()\n}\nfunction valve(g: G, x: number, y: number, open: boolean, tag?: string, abn?: boolean, vertical = false) {\n  const s = 12\n  g.save()\n  g.translate(x, y)\n  if (vertical) g.rotate(Math.PI / 2)\n  for (const sg of [-1, 1]) {\n    g.beginPath()\n    g.moveTo(0, 0)\n    g.lineTo(sg * s, -s * 0.7)\n    g.lineTo(sg * s, s * 0.7)\n    g.closePath()\n    g.fillStyle = open ? C.text : C.white\n    g.fill()\n    g.strokeStyle = abn ? C.p1 : C.text\n    g.lineWidth = abn ? 3.5 : 2\n    g.stroke()\n  }\n  g.strokeStyle = C.text\n  g.lineWidth = 2\n  g.beginPath()\n  g.moveTo(0, 0)\n  g.lineTo(0, -s * 1.2)\n  g.stroke()\n  g.fillStyle = C.panelDark\n  g.fillRect(-s * 0.55, -s * 1.7, s * 1.1, s * 0.55)\n  g.restore()\n  if (tag) text(g, tag, x, y - s * 2.5, { font: sans(13, true), color: abn ? C.p1 : C.dim, align: 'center' })\n}\nfunction pumpSym(g: G, x: number, y: number, r: number, running: boolean, tag: string) {\n  g.beginPath()\n  g.arc(x, y, r, 0, Math.PI * 2)\n  g.fillStyle = running ? '#4f565c' : C.white\n  g.fill()\n  g.strokeStyle = C.text\n  g.lineWidth = 2\n  g.stroke()\n  line(g, [[x, y - r], [x + r, y - r], [x + r, y - r * 0.4]], C.text, 2)\n  text(g, tag, x, y + r + 13, { font: sans(13, true), color: C.dim, align: 'center' })\n  text(g, running ? 'RUN' : 'STBY', x, y + r + 28, { font: sans(12, true), color: C.dim, align: 'center' })\n}\nfunction priTile(g: G, x: number, y: number, p: number) {\n  const s = 22\n  const col = [C.info, C.p1, C.p2, C.p3][p]\n  g.beginPath()\n  if (p === 1) g.rect(x, y, s, s)\n  else if (p === 2) {\n    g.moveTo(x + s / 2, y)\n    g.lineTo(x + s, y + s)\n    g.lineTo(x, y + s)\n  } else {\n    g.moveTo(x, y)\n    g.lineTo(x + s, y)\n    g.lineTo(x + s / 2, y + s)\n  }\n  g.closePath()\n  g.fillStyle = col\n  g.fill()\n  g.strokeStyle = C.text\n  g.lineWidth = 1.5\n  g.stroke()\n  text(g, String(p), x + s / 2, y + (p === 2 ? s * 0.62 : p === 3 ? s * 0.38 : s / 2), { font: sans(12, true), color: p === 1 ? '#fff' : C.text, align: 'center' })\n}\n\n// ------------------------------------------------------------------ screens\n\nfunction overview(g: G) {\n  header(g, 'FIELD OVERVIEW')\n  const plats = [\n    { id: 'WHP-A', role: 'Wellhead', oil: '12 480', gas: '18.2', wat: '3 120', f: 0.62 },\n    { id: 'CPF', role: 'Process & export', oil: '38 950', gas: '61.4', wat: '9 860', f: 0.78 },\n    { id: 'WI', role: 'Water injection', oil: '-', gas: '-', wat: '40 100', f: 0.71 },\n  ]\n  plats.forEach((p, i) => {\n    const x = 16 + i * 336\n    box(g, x, 58, 324, 250, `${p.id} · ${p.role}`)\n    const rows: [string, string, string][] = [['Oil', p.oil, 'bbl/d'], ['Gas', p.gas, 'MMscfd'], ['Water', p.wat, 'bbl/d']]\n    rows.forEach(([l, v, u], j) => {\n      text(g, l, x + 14, 104 + j * 46, { font: sans(16, true), color: C.dim })\n      valueBox(g, x + 92, 88 + j * 46, 218, 32, v, u)\n    })\n    text(g, 'Capacity', x + 14, 256, { font: sans(14, true), color: C.dim })\n    hbar(g, x + 92, 248, 218, 16, p.f, [0.9])\n    text(g, 'NORMAL', x + 310, 288, { font: sans(14, true), color: C.dim, align: 'right' })\n  })\n  // export summary and link diagram\n  box(g, 16, 322, 660, 236, 'EXPORT')\n  const cy = 420\n  for (const [x, l] of [[60, 'WHP-A'], [260, 'CPF'], [460, 'RISER']] as const) {\n    rect(g, x, cy - 26, 120, 52, C.well, C.text, 2)\n    text(g, l, x + 60, cy, { font: sans(17, true), align: 'center' })\n  }\n  line(g, [[180, cy], [260, cy]], C.line, 4)\n  arrow(g, 258, cy, 1, 0)\n  line(g, [[380, cy], [460, cy]], C.line, 4)\n  arrow(g, 458, cy, 1, 0)\n  line(g, [[580, cy], [650, cy]], C.line, 4)\n  arrow(g, 650, cy, 1, 0)\n  text(g, 'Oil export', 40, 492, { font: sans(15, true), color: C.dim })\n  valueBox(g, 140, 478, 200, 30, '49 830', 'bbl/d')\n  text(g, 'Gas export', 360, 492, { font: sans(15, true), color: C.dim })\n  valueBox(g, 460, 478, 200, 30, '48.6', 'MMscfd')\n  text(g, 'Flare', 40, 532, { font: sans(15, true), color: C.dim })\n  valueBox(g, 140, 518, 200, 30, '2.1', 'MMscfd')\n  box(g, 690, 322, 318, 236, 'STATUS')\n  const st: [string, string, string | undefined][] = [\n    ['Shutdown level', 'NONE', undefined],\n    ['Fire & gas', 'HEALTHY', undefined],\n    ['Main power', '2 × 25 MW', undefined],\n    ['Instrument air', '8.6 barg', undefined],\n    ['Active alarms', '7', C.p2],\n  ]\n  st.forEach(([l, v, a], j) => {\n    text(g, l, 704, 356 + j * 36, { font: sans(15, true), color: C.dim })\n    text(g, v, 994, 356 + j * 36, { font: mono(17), color: a ?? C.value, align: 'right' })\n  })\n  footer(g)\n}\n\nfunction mimic(g: G) {\n  header(g, 'PROCESS MIMIC: INLET & SEPARATION')\n  // inlet manifold\n  const y = 230\n  rect(g, 30, y - 70, 70, 140, C.panel, C.text, 2.5)\n  text(g, 'M-100', 65, y + 88, { font: sans(13, true), color: C.dim, align: 'center' })\n  for (let i = 0; i < 4; i++) line(g, [[0, y - 52 + i * 34], [30, y - 52 + i * 34]], C.line, 3)\n  line(g, [[100, y], [190, y]], C.line, 4)\n  valve(g, 140, y, true, 'ESDV-101')\n  // HP separator\n  capsule(g, 190, y - 55, 300, 110, 0.48)\n  text(g, 'V-110 HP SEPARATOR', 340, y - 78, { font: sans(15, true), align: 'center' })\n  valueBox(g, 230, y - 30, 120, 30, '35.2', 'barg')\n  valueBox(g, 360, y - 30, 110, 30, '52', '%')\n  // gas off top\n  line(g, [[440, y - 55], [440, 100], [640, 100]], C.line, 4)\n  valve(g, 520, 100, true, 'PCV-111')\n  rect(g, 640, 66, 60, 120, C.panel, C.text, 2.5)\n  text(g, 'V-210', 670, 198, { font: sans(13, true), color: C.dim, align: 'center' })\n  line(g, [[700, 100], [770, 100]], C.line, 4)\n  g.beginPath()\n  g.moveTo(770, 76)\n  g.lineTo(850, 88)\n  g.lineTo(850, 112)\n  g.lineTo(770, 124)\n  g.closePath()\n  g.fillStyle = '#4f565c'\n  g.fill()\n  g.strokeStyle = C.text\n  g.lineWidth = 2\n  g.stroke()\n  text(g, 'K-220 RUN', 810, 140, { font: sans(13, true), color: C.dim, align: 'center' })\n  line(g, [[850, 100], [1010, 100]], C.line, 4)\n  arrow(g, 1010, 100, 1, 0)\n  text(g, 'GAS EXPORT', 1004, 76, { font: sans(13, true), color: C.dim, align: 'right' })\n  valueBox(g, 880, 118, 120, 28, '138.5', 'barg')\n  // oil to LP separator, pumps\n  line(g, [[420, y + 55], [420, 360], [520, 360]], C.line, 4)\n  valve(g, 470, 360, true, 'LV-111')\n  capsule(g, 520, 320, 200, 80, 0.55)\n  text(g, 'V-130 LP SEPARATOR', 620, 420, { font: sans(14, true), align: 'center' })\n  line(g, [[720, 360], [800, 360], [800, 440]], C.line, 4)\n  line(g, [[800, 440], [780, 440], [780, 470]], C.line, 3)\n  line(g, [[800, 440], [880, 440], [880, 470]], C.line, 3)\n  pumpSym(g, 780, 490, 20, true, 'P-150A')\n  pumpSym(g, 880, 490, 20, false, 'P-150B')\n  line(g, [[900, 470], [960, 470], [960, 360], [1010, 360]], C.line, 4)\n  arrow(g, 1010, 360, 1, 0)\n  text(g, 'OIL EXPORT', 1004, 336, { font: sans(13, true), color: C.dim, align: 'right' })\n  // water from the boot\n  line(g, [[250, y + 55], [250, 470], [400, 470]], C.line, 3)\n  valve(g, 320, 470, true, 'LV-112')\n  text(g, 'PRODUCED WATER', 404, 470, { font: sans(13, true), color: C.dim })\n  valueBox(g, 60, 500, 130, 30, '790', 'mm')\n  text(g, 'Interface', 66, 486, { font: sans(13, true), color: C.dim })\n  valueBox(g, 540, 500, 140, 30, '9 860', 'bbl/d', C.p3)\n  footer(g)\n}\n\nconst PENS = [\n  [\n    { l: 'Oil export', u: 'bbl/d', c: '#1d4f9a', v: '49 830', base: 0.72, amp: 0.05 },\n    { l: 'Gas export', u: 'MMscfd', c: '#24704a', v: '48.6', base: 0.55, amp: 0.06 },\n    { l: 'Flare', u: 'MMscfd', c: '#7a4310', v: '2.1', base: 0.1, amp: 0.04 },\n  ],\n  [\n    { l: 'HP sep level', u: '%', c: '#1d4f9a', v: '52', base: 0.52, amp: 0.08 },\n    { l: 'HP sep press', u: 'barg', c: '#5e2a8c', v: '35.2', base: 0.6, amp: 0.03 },\n    { l: 'Inst air', u: 'barg', c: '#0f6359', v: '8.6', base: 0.84, amp: 0.02 },\n  ],\n]\nfunction trend(g: G) {\n  header(g, 'TRENDS: EXPORT, FLARE, HP SEPARATOR')\n  const r = rng(11)\n  PENS.forEach((pens, i) => {\n    const x = 70\n    const y0 = 60 + i * 250\n    const w = 710\n    const h = 200\n    rect(g, x, y0, w, h, C.well, C.lineLight, 1.5)\n    g.strokeStyle = 'rgba(80,86,92,0.25)'\n    g.lineWidth = 1\n    for (let k = 1; k < 4; k++) line(g, [[x, y0 + (h * k) / 4], [x + w, y0 + (h * k) / 4]], 'rgba(80,86,92,0.25)', 1)\n    for (let k = 1; k < 6; k++) line(g, [[x + (w * k) / 6, y0], [x + (w * k) / 6, y0 + h]], 'rgba(80,86,92,0.25)', 1)\n    for (let k = 0; k <= 4; k++) text(g, String(100 - k * 25), x - 8, y0 + (h * k) / 4, { font: mono(12, false), color: C.dim, align: 'right' })\n    if (i === 1) for (let k = 0; k <= 6; k++) text(g, `${String(13 + Math.floor((k * 10 + 32) / 60)).padStart(2, '0')}:${String((k * 10 + 32) % 60).padStart(2, '0')}`, x + (w * k) / 6, y0 + h + 14, { font: mono(13, false), color: C.dim, align: 'center' })\n    for (const p of pens) {\n      let v = p.base\n      g.strokeStyle = p.c\n      g.lineWidth = 2.5\n      g.beginPath()\n      for (let s = 0; s <= 120; s++) {\n        v += (r() - 0.5) * p.amp * 0.5 + (p.base - v) * 0.08\n        if (i === 0 && p.l === 'Flare' && s > 70 && s < 82) v += 0.04\n        const px = x + (s / 120) * w\n        const py = y0 + h - Math.max(0.02, Math.min(0.98, v)) * h\n        if (s) g.lineTo(px, py)\n        else g.moveTo(px, py)\n      }\n      g.stroke()\n    }\n    rect(g, 796, y0, 216, h, C.panel, C.lineLight, 1.5)\n    pens.forEach((p, j) => {\n      const yy = y0 + 12 + j * ((h - 16) / pens.length)\n      rect(g, 806, yy + 6, 22, 6, p.c)\n      text(g, p.l, 836, yy + 9, { font: sans(15, true) })\n      text(g, p.v, 1012 - 70, yy + 36, { font: mono(22), color: C.value, align: 'right' })\n      text(g, p.u, 1012 - 64, yy + 36, { font: sans(13), color: C.dim })\n    })\n  })\n  footer(g)\n}\n\nconst ALARMS: [number, string, string, string, string, string][] = [\n  [1, '14:31:52', 'PAHH-110', 'HP separator pressure high-high', '40.3 barg', 'UNACK'],\n  [2, '14:31:40', 'LAH-110', 'HP separator level high', '72 %', 'UNACK'],\n  [2, '14:29:13', 'TAH-630', 'Cooling medium supply temp high', '45.6 °C', 'ACK'],\n  [3, '14:27:02', 'PDAH-210', 'Suction scrubber dP high', '252 mbar', 'ACK'],\n  [3, '14:22:48', 'XA-150B', 'Export pump B standby not ready', '', 'ACK'],\n  [2, '14:18:31', 'GD-A-12', 'Gas detector 20 % LEL', '21 %LEL', 'RTN UNACK'],\n  [3, '14:11:05', 'AAH-530', 'Export gas dew point high', '-9.4 °C', 'ACK'],\n  [3, '14:02:44', 'LAL-240', 'Flare KO drum level low', '310 mm', 'ACK'],\n  [1, '13:58:20', 'XA-FG-03', 'F&G node 3 comms fault', '', 'ACK'],\n  [3, '13:51:17', 'PAL-460', 'Instrument air header low', '6.4 barg', 'RTN'],\n  [3, '13:44:59', 'FAL-W40', 'Injection flow low', '14 800 bwpd', 'ACK'],\n  [2, '13:40:36', 'ZSC-101', 'ESDV-101 travel time exceeded', '', 'ACK'],\n]\nfunction alarm(g: G) {\n  header(g, 'ALARM SUMMARY', 'amber')\n  ;[1, 2, 3].forEach((p, i) => {\n    const x = 12 + i * 170\n    const n = ALARMS.filter((a) => a[0] === p).length\n    rect(g, x, 54, 160, 56, C.panel, [C.p1, C.p2, C.p3][i], 3)\n    priTile(g, x + 10, 64, p)\n    text(g, String(n), x + 96, 74, { font: mono(26), color: C.value, align: 'right' })\n    text(g, ['CRITICAL', 'HIGH', 'LOW'][i], x + 102, 74, { font: sans(11, true), color: C.dim })\n  })\n  rect(g, 522, 54, 150, 56, C.panel, C.lineLight, 1.5)\n  text(g, 'SHELVED', 534, 70, { font: sans(13, true), color: C.dim })\n  text(g, '1', 660, 90, { font: mono(24), color: C.value, align: 'right' })\n  rect(g, 684, 54, 328, 56, C.panel, C.lineLight, 1.5)\n  text(g, 'ALARM RATE (10 min)', 696, 70, { font: sans(13, true), color: C.dim })\n  text(g, '6', 860, 90, { font: mono(26), color: C.value, align: 'right' })\n  text(g, 'target < 10', 1000, 90, { font: sans(14, true), color: C.dim, align: 'right' })\n  const cols = { pri: 14, time: 56, tag: 170, text: 310, value: 760, state: 1008 }\n  rect(g, 0, 120, W, 28, C.panelDark)\n  for (const [k, x] of Object.entries(cols)) text(g, k.toUpperCase(), x, 134, { font: sans(13, true), color: C.dim, align: k === 'state' ? 'right' : 'left' })\n  ALARMS.forEach(([p, t, tag, msg, val, st], i) => {\n    const y = 152 + i * 32\n    rect(g, 0, y, W, 30, i % 2 ? C.bg : '#bcc0c3')\n    priTile(g, cols.pri, y + 4, p)\n    const col = st === 'RTN' ? C.dim : C.text\n    text(g, t, cols.time, y + 15, { font: mono(16, false), color: col })\n    text(g, tag, cols.tag, y + 15, { font: sans(16, true), color: col, max: 134 })\n    text(g, msg, cols.text, y + 15, { font: sans(16), color: col, max: 440 })\n    if (val) text(g, val, cols.value, y + 15, { font: mono(15, false), color: C.value, max: 130 })\n    text(g, st, cols.state, y + 15, { font: sans(14, true), color: st.includes('UNACK') ? C.text : C.dim, align: 'right' })\n  })\n  footer(g)\n}\n\nfunction fg(g: G) {\n  header(g, 'FIRE & GAS: ZONE MAP, MAIN DECK')\n  const x0 = 24\n  const y0 = 60\n  const w = 690\n  const h = 470\n  rect(g, x0, y0, w, h, C.well, C.text, 3)\n  const zones: [string, number, number, number, number, number][] = [\n    ['P-SEP', 0, 0, 0.36, 0.5, 0],\n    ['P-COMP', 0.36, 0, 0.34, 0.5, 2],\n    ['P-EXP', 0.7, 0, 0.3, 0.5, 0],\n    ['P-PW', 0, 0.5, 0.3, 0.5, 0],\n    ['P-UTIL', 0.3, 0.5, 0.4, 0.5, 0],\n    ['P-PWR', 0.7, 0.5, 0.3, 0.5, 1],\n  ]\n  const r = rng(5)\n  for (const [id, zx, zy, zw, zh, sev] of zones) {\n    const x = x0 + 6 + zx * (w - 12)\n    const y = y0 + 6 + zy * (h - 12)\n    const ww = zw * (w - 12)\n    const hh = zh * (h - 12)\n    rect(g, x + 3, y + 3, ww - 6, hh - 6, [C.panel, C.p3, C.p2, C.p1][sev], C.line, 2)\n    text(g, id, x + 14, y + 22, { font: sans(18, true) })\n    text(g, ['HEALTHY', 'FAULT', '1ooN GAS 20 %LEL', 'FIRE'][sev], x + 14, y + 44, { font: sans(13, true), color: C.dim })\n    // detectors: point gas (circle), flame (triangle), MAC (square)\n    for (let i = 0; i < 6; i++) {\n      const dx = x + 24 + r() * (ww - 48)\n      const dy = y + 64 + r() * (hh - 88)\n      const t = i % 3\n      const alm = sev === 2 && i === 1\n      g.fillStyle = alm ? C.p2 : C.white\n      g.strokeStyle = C.text\n      g.lineWidth = 2\n      g.beginPath()\n      if (t === 0) g.arc(dx, dy, 8, 0, Math.PI * 2)\n      else if (t === 1) {\n        g.moveTo(dx, dy - 9)\n        g.lineTo(dx + 9, dy + 7)\n        g.lineTo(dx - 9, dy + 7)\n        g.closePath()\n      } else g.rect(dx - 7, dy - 7, 14, 14)\n      g.fill()\n      g.stroke()\n    }\n  }\n  // open-path beam across the compressor zone\n  line(g, [[x0 + 0.38 * w, y0 + 0.4 * h], [x0 + 0.68 * w, y0 + 0.4 * h]], C.info, 2)\n  box(g, 730, 60, 282, 210, 'LEGEND')\n  const leg: [string, number][] = [['Point gas detector', 0], ['IR3 flame detector', 1], ['Manual call point', 2]]\n  leg.forEach(([l, t], i) => {\n    const dx = 754\n    const dy = 104 + i * 34\n    g.fillStyle = C.white\n    g.beginPath()\n    if (t === 0) g.arc(dx, dy, 8, 0, Math.PI * 2)\n    else if (t === 1) {\n      g.moveTo(dx, dy - 9)\n      g.lineTo(dx + 9, dy + 7)\n      g.lineTo(dx - 9, dy + 7)\n      g.closePath()\n    } else g.rect(dx - 7, dy - 7, 14, 14)\n    g.fill()\n    g.strokeStyle = C.text\n    g.stroke()\n    text(g, l, 774, dy, { font: sans(15) })\n  })\n  line(g, [[744, 206], [766, 206]], C.info, 3)\n  text(g, 'Open-path gas beam', 774, 206, { font: sans(15) })\n  text(g, 'Voting: 2ooN confirmed', 744, 244, { font: sans(14, true), color: C.dim })\n  box(g, 730, 284, 282, 246, 'ZONE STATUS')\n  const st: [string, string, string][] = [['P-COMP', 'GAS 1ooN', C.p2], ['P-PWR', 'DETECTOR FAULT', C.p3], ['Deluge', 'READY', C.dim], ['Fire pumps', 'AUTO', C.dim], ['GA / PA', 'NORMAL', C.dim]]\n  st.forEach(([z, s, c], i) => {\n    text(g, z, 744, 322 + i * 38, { font: sans(16, true) })\n    text(g, s, 1000, 322 + i * 38, { font: sans(15, true), color: c, align: 'right' })\n  })\n  footer(g)\n}\n\nfunction esd(g: G) {\n  header(g, 'SHUTDOWN / ESD STATUS')\n  const lv = ['APS', 'ESD1', 'ESD2', 'PSD', 'USD']\n  const lbl = ['Abandon platform', 'Emergency SD 1', 'Emergency SD 2', 'Process SD', 'Unit SD']\n  lv.forEach((l, i) => {\n    const x = 12 + i * 122\n    const act = l === 'USD'\n    rect(g, x, 54, 114, 62, act ? C.p2 : C.panel, act ? C.p2 : C.lineLight, act ? 3 : 1.5)\n    text(g, l, x + 57, 76, { font: sans(22, true), align: 'center' })\n    text(g, lbl[i], x + 57, 102, { font: sans(11), color: C.dim, align: 'center', max: 106 })\n  })\n  box(g, 636, 54, 376, 62)\n  text(g, 'Process', 650, 74, { font: sans(15, true) })\n  text(g, 'NORMAL', 1000, 74, { font: sans(15, true), color: C.dim, align: 'right' })\n  text(g, 'General alarm', 650, 98, { font: sans(15, true) })\n  text(g, 'OFF', 1000, 98, { font: sans(15, true), color: C.dim, align: 'right' })\n  box(g, 12, 128, 690, 430, 'LATCHED SHUTDOWNS')\n  const rows: [string, string, string, string, string][] = [\n    ['USD', 'K-220 compressor', 'Anti-surge: surge margin < 10 %', '14:20:41', 'READY TO RESET'],\n    ['PSD', 'Process', 'Instrument air low-low (test)', '09:12:03', 'RESET'],\n    ['USD', 'P-150B', 'Seal pot level low', '06:45:18', 'RESET'],\n  ]\n  rows.forEach(([lvl, unit, cause, t, st], i) => {\n    const y = 152 + i * 52\n    const reset = st === 'RESET'\n    rect(g, 20, y, 674, 46, i % 2 ? C.bg : '#bcc0c3')\n    rect(g, 24, y + 6, 70, 34, reset ? C.white : C.p2, C.text, 1.5)\n    text(g, lvl, 59, y + 23, { font: sans(17, true), align: 'center' })\n    text(g, unit, 104, y + 14, { font: sans(14, true), color: C.dim })\n    text(g, cause, 104, y + 33, { font: sans(15), max: 420 })\n    text(g, t, 600, y + 14, { font: mono(14, false), color: C.dim, align: 'right' })\n    text(g, st, 686, y + 33, { font: sans(14, true), color: reset ? C.dim : C.text, align: 'right' })\n  })\n  box(g, 714, 128, 298, 430, 'ESD / BLOWDOWN VALVES')\n  const vs: [string, boolean, boolean][] = [['ESDV-101', true, false], ['ESDV-201', true, false], ['ESDV-230', true, false], ['ESDV-160', true, false], ['ESDV-530', true, false], ['BDV-220', false, false], ['ESDV-R101', true, false], ['ESDV-R201', true, false], ['XV-111', false, true]]\n  vs.forEach(([t, open, abn], i) => {\n    const y = 172 + i * 42\n    valve(g, 744, y, open, undefined, abn)\n    text(g, t, 772, y, { font: sans(15, true) })\n    text(g, open ? 'OPEN' : 'CLOSED', 1000, y, { font: sans(14, true), color: abn ? C.p1 : C.dim, align: 'right' })\n  })\n  footer(g)\n}\n\n/** Generic CCTV scene: sky, steelwork, piping and deck, a different arrangement per seed. */\nfunction camScene(g: G, x: number, y: number, w: number, h: number, seed: number, night = false) {\n  const r = rng(seed)\n  const sky = g.createLinearGradient(x, y, x, y + h)\n  sky.addColorStop(0, night ? '#20262c' : '#9fb3c2')\n  sky.addColorStop(0.55, night ? '#2e353b' : '#c7d0d6')\n  sky.addColorStop(1, night ? '#15181b' : '#6c757c')\n  g.fillStyle = sky\n  g.fillRect(x, y, w, h)\n  const horizon = y + h * (0.42 + r() * 0.12)\n  g.fillStyle = night ? '#1b2a33' : '#5d7684'\n  g.fillRect(x, horizon, w, h * 0.12)\n  // structures\n  for (let i = 0; i < 9; i++) {\n    const bx = x + r() * w * 0.9\n    const bw = w * (0.05 + r() * 0.16)\n    const bh = h * (0.15 + r() * 0.45)\n    g.fillStyle = r() < 0.5 ? (night ? '#3a4249' : '#8d969c') : night ? '#2d3439' : '#a4abb0'\n    g.fillRect(bx, y + h * 0.78 - bh, bw, bh)\n    g.fillStyle = 'rgba(0,0,0,0.18)'\n    g.fillRect(bx + bw * 0.75, y + h * 0.78 - bh, bw * 0.25, bh)\n  }\n  // a horizontal vessel or two and some pipe runs\n  g.fillStyle = night ? '#6d7277' : '#c9ccc8'\n  g.beginPath()\n  g.roundRect(x + w * (0.2 + r() * 0.3), y + h * 0.55, w * 0.32, h * 0.14, h * 0.07)\n  g.fill()\n  for (let i = 0; i < 3; i++) {\n    g.strokeStyle = ['#b7a24a', '#8a9399', '#a33a2c'][i]\n    g.lineWidth = Math.max(2, h * 0.018)\n    const py = y + h * (0.3 + r() * 0.35)\n    g.beginPath()\n    g.moveTo(x, py)\n    g.lineTo(x + w * (0.4 + r() * 0.6), py + (r() - 0.5) * h * 0.05)\n    g.stroke()\n  }\n  // deck with a yellow walkway line\n  g.fillStyle = night ? '#24292d' : '#555c61'\n  g.fillRect(x, y + h * 0.78, w, h * 0.22)\n  g.fillStyle = '#d4a514'\n  g.fillRect(x, y + h * 0.88, w, Math.max(2, h * 0.012))\n  if (night) {\n    for (let i = 0; i < 6; i++) {\n      const lx = x + r() * w\n      const ly = y + h * (0.3 + r() * 0.4)\n      const grd = g.createRadialGradient(lx, ly, 0, lx, ly, h * 0.08)\n      grd.addColorStop(0, 'rgba(255,230,170,0.9)')\n      grd.addColorStop(1, 'rgba(255,230,170,0)')\n      g.fillStyle = grd\n      g.fillRect(lx - h * 0.08, ly - h * 0.08, h * 0.16, h * 0.16)\n    }\n  }\n  // sensor noise\n  for (let i = 0; i < (w * h) / 220; i++) {\n    g.fillStyle = r() < 0.5 ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.06)'\n    g.fillRect(x + r() * w, y + r() * h, 2, 2)\n  }\n}\nconst CAMS = ['WELLBAY', 'HP SEPARATOR', 'COMPRESSOR', 'EXPORT PUMPS', 'FLARE BOOM', 'LIFEBOATS', 'NORTH BRIDGE', 'CRANE PEDESTAL']\nfunction cctv(g: G) {\n  g.fillStyle = '#0e1012'\n  g.fillRect(0, 0, W, H)\n  for (let q = 0; q < 4; q++) {\n    const x = (q % 2) * (W / 2)\n    const y = Math.floor(q / 2) * (H / 2)\n    camScene(g, x + 3, y + 3, W / 2 - 6, H / 2 - 6, 17 + q * 7, q === 3)\n    rect(g, x + 8, y + 10, 270, 26, 'rgba(10,12,14,0.55)')\n    text(g, `CAM ${String(q + 1).padStart(2, '0')}  ${CAMS[q]}`, x + 16, y + 23, { font: mono(16), color: '#e6eaee', max: 256 })\n    text(g, '2026-10-29 14:32:07', x + W / 2 - 14, y + H / 2 - 18, { font: mono(15), color: '#e6eaee', align: 'right' })\n    g.fillStyle = '#d42a1f'\n    g.beginPath()\n    g.arc(x + W / 2 - 24, y + 24, 6, 0, Math.PI * 2)\n    g.fill()\n  }\n  footer(g, true)\n}\n\nfunction helideck(g: G) {\n  // single wide CCTV feed of a helideck: green perimeter, H in a yellow circle, TD/PM circle\n  const sky = g.createLinearGradient(0, 0, 0, H)\n  sky.addColorStop(0, '#93a9ba')\n  sky.addColorStop(0.4, '#c3cdd4')\n  sky.addColorStop(0.42, '#5b7483')\n  sky.addColorStop(1, '#34495a')\n  g.fillStyle = sky\n  g.fillRect(0, 0, W, H)\n  g.save()\n  g.translate(W / 2, H * 0.68)\n  g.scale(1, 0.38)\n  g.fillStyle = '#2f3b33'\n  g.beginPath()\n  for (let i = 0; i < 8; i++) {\n    const a = (i / 8) * Math.PI * 2 + Math.PI / 8\n    g.lineTo(Math.cos(a) * 440, Math.sin(a) * 440)\n  }\n  g.closePath()\n  g.fill()\n  g.strokeStyle = '#f2f2ef'\n  g.lineWidth = 14\n  g.stroke()\n  g.strokeStyle = '#e8c21a'\n  g.lineWidth = 26\n  g.beginPath()\n  g.arc(0, 0, 230, 0, Math.PI * 2)\n  g.stroke()\n  g.restore()\n  g.save()\n  g.translate(W / 2, H * 0.68)\n  g.scale(1, 0.38)\n  g.fillStyle = '#f2f2ef'\n  g.fillRect(-70, -110, 34, 220)\n  g.fillRect(36, -110, 34, 220)\n  g.fillRect(-36, -17, 72, 34)\n  g.restore()\n  // safety net edge and lights\n  g.fillStyle = '#1e2a22'\n  g.fillRect(0, H * 0.86, W, H * 0.14)\n  for (let i = 0; i < 12; i++) {\n    g.fillStyle = '#7fd36a'\n    g.beginPath()\n    g.arc(60 + i * 82, H * 0.86, 4, 0, Math.PI * 2)\n    g.fill()\n  }\n  rect(g, 8, 10, 360, 26, 'rgba(10,12,14,0.55)')\n  text(g, 'CAM 09  HELIDECK', 16, 23, { font: mono(16), color: '#e6eaee' })\n  text(g, '2026-10-29 14:32:07', W - 14, 23, { font: mono(15), color: '#e6eaee', align: 'right' })\n  rect(g, 8, 44, 250, 70, 'rgba(10,12,14,0.55)')\n  text(g, 'WIND 315° 18 kn', 16, 62, { font: mono(15), color: '#e6eaee' })\n  text(g, 'PITCH 0.4° ROLL 0.3°', 16, 82, { font: mono(15), color: '#e6eaee' })\n  text(g, 'HEAVE 0.2 m/s', 16, 102, { font: mono(15), color: '#e6eaee' })\n  footer(g, true)\n}\n\nfunction weather(g: G) {\n  header(g, 'METOCEAN & HELIDECK MONITORING')\n  // wind rose\n  box(g, 16, 58, 380, 470, 'WIND')\n  const cx = 206\n  const cy = 300\n  for (const rr of [60, 120, 170]) {\n    g.beginPath()\n    g.arc(cx, cy, rr, 0, Math.PI * 2)\n    g.strokeStyle = C.lineLight\n    g.lineWidth = 1.5\n    g.stroke()\n  }\n  ;['N', 'E', 'S', 'W'].forEach((d, i) => {\n    const a = (i * Math.PI) / 2 - Math.PI / 2\n    text(g, d, cx + Math.cos(a) * 186, cy + Math.sin(a) * 186, { font: sans(18, true), align: 'center' })\n  })\n  const a = (315 * Math.PI) / 180 - Math.PI / 2\n  g.save()\n  g.translate(cx, cy)\n  g.rotate(a + Math.PI)\n  g.fillStyle = C.value\n  g.beginPath()\n  g.moveTo(170, 0)\n  g.lineTo(-20, -16)\n  g.lineTo(0, 0)\n  g.lineTo(-20, 16)\n  g.closePath()\n  g.fill()\n  g.restore()\n  valueBox(g, 60, 490, 140, 30, '315', '°')\n  valueBox(g, 214, 490, 160, 30, '18.4', 'kn')\n  // met values\n  box(g, 410, 58, 600, 300, 'METEOROLOGICAL')\n  const met: [string, string, string][] = [\n    ['Air temperature', '24.1', '°C'],\n    ['Relative humidity', '71', '%'],\n    ['Barometric pressure', '1012.6', 'hPa'],\n    ['Visibility', '9.8', 'km'],\n    ['Cloud base', '2 400', 'ft'],\n    ['Wind gust (10 min)', '23.0', 'kn'],\n  ]\n  met.forEach(([l, v, u], i) => {\n    text(g, l, 426, 100 + i * 42, { font: sans(16, true), color: C.dim })\n    valueBox(g, 760, 86 + i * 42, 230, 30, v, u)\n  })\n  box(g, 410, 372, 600, 156, 'SEA STATE · HELIDECK MOTION')\n  const sea: [string, string, string][] = [['Hs', '1.6', 'm'], ['Tp', '7.8', 's'], ['Pitch / roll', '0.4 / 0.3', '°'], ['Heave rate', '0.2', 'm/s']]\n  sea.forEach(([l, v, u], i) => {\n    const x = 426 + (i % 2) * 296\n    const y = 414 + Math.floor(i / 2) * 46\n    text(g, l, x, y + 14, { font: sans(16, true), color: C.dim })\n    valueBox(g, x + 120, y, 156, 30, v, u)\n  })\n  rect(g, 426, 500, 140, 22, '#3b8b4a')\n  text(g, 'HELIDECK GREEN', 496, 511, { font: sans(13, true), color: '#fff', align: 'center' })\n  footer(g)\n}\n\nfunction kpi(g: G) {\n  header(g, 'PRODUCTION KPIs: LAST 24 h')\n  const r = rng(3)\n  const tiles: [string, string, string, number][] = [\n    ['Oil', '49 830', 'bbl/d', 0.93],\n    ['Gas', '48.6', 'MMscfd', 0.86],\n    ['Water inj.', '40 100', 'bwpd', 0.95],\n    ['Uptime', '98.7', '%', 0.987],\n  ]\n  tiles.forEach(([l, v, u, f], i) => {\n    const x = 16 + i * 252\n    box(g, x, 58, 240, 150, l.toUpperCase())\n    text(g, v, x + 226, 112, { font: mono(40), color: C.value, align: 'right' })\n    text(g, u, x + 226, 146, { font: sans(15), color: C.dim, align: 'right' })\n    hbar(g, x + 14, 176, 212, 14, f, [0.9])\n  })\n  box(g, 16, 222, 992, 300, 'HOURLY OIL RATE vs PLAN')\n  const n = 24\n  for (let i = 0; i < n; i++) {\n    const f = 0.7 + r() * 0.22 - (i > 9 && i < 12 ? 0.3 : 0)\n    const x = 60 + i * 39\n    const h = 220 * f\n    rect(g, x, 500 - h, 28, h, i > 9 && i < 12 ? C.p2 : '#6d747a')\n    if (i % 3 === 0) text(g, `${String(i).padStart(2, '0')}:00`, x + 14, 512, { font: mono(11, false), color: C.dim, align: 'center' })\n  }\n  line(g, [[50, 500 - 220 * 0.85], [1000, 500 - 220 * 0.85]], C.info, 2)\n  text(g, 'PLAN', 994, 500 - 220 * 0.85 - 12, { font: sans(13, true), color: C.info, align: 'right' })\n  footer(g)\n}\n\nfunction off(g: G) {\n  g.fillStyle = '#07090b'\n  g.fillRect(0, 0, W, H)\n}\n\nconst DRAW: Record<ScreenKind, (g: G) => void> = { overview, mimic, trend, alarm, fg, esd, cctv, weather, helideck, kpi, off }\n\nconst texCache = new Map<ScreenKind, THREE.CanvasTexture>()\nconst matCache = new Map<ScreenKind, THREE.MeshBasicMaterial>()\n\n/** Cached 1024 × 576 procedural screen texture. */\nexport function screenTexture(kind: ScreenKind): THREE.CanvasTexture {\n  let t = texCache.get(kind)\n  if (t) return t\n  const c = document.createElement('canvas')\n  c.width = W\n  c.height = H\n  ;(DRAW[kind] ?? overview)(c.getContext('2d')!)\n  t = new THREE.CanvasTexture(c)\n  t.colorSpace = THREE.SRGBColorSpace\n  t.anisotropy = 8\n  texCache.set(kind, t)\n  return t\n}\n\n/** Self-lit screen material for a procedural kind. */\nexport function screenMaterial(kind: ScreenKind): THREE.MeshBasicMaterial {\n  let m = matCache.get(kind)\n  if (!m) matCache.set(kind, (m = new THREE.MeshBasicMaterial({ map: screenTexture(kind), color: '#e8e8e8' })))\n  return m\n}\n\nconst liveCache = new WeakMap<THREE.Texture, THREE.MeshBasicMaterial>()\nexport function liveMaterial(t: THREE.Texture) {\n  let m = liveCache.get(t)\n  if (!m) {\n    // canvas / video feeds arrive untagged; tag once so they are not washed out\n    const anyT = t as THREE.Texture & { isCanvasTexture?: boolean; isVideoTexture?: boolean }\n    if (t.colorSpace === THREE.NoColorSpace && (anyT.isCanvasTexture || anyT.isVideoTexture)) {\n      t.colorSpace = THREE.SRGBColorSpace\n      t.needsUpdate = true\n    }\n    liveCache.set(t, (m = new THREE.MeshBasicMaterial({ map: t, toneMapped: false })))\n  }\n  return m\n}\n\n/** Renders each placement as its own plane so live textures swap without rebuilding geometry. */\nexport function ScreenPanels({ placements, screens }: { placements: ScreenPlacement[]; screens?: (THREE.Texture | null | undefined)[] }) {\n  const geo = useMemo(() => new THREE.PlaneGeometry(1, 1), [])\n  useEffect(() => () => geo.dispose(), [geo])\n  return (\n    <>\n      {placements.map((p, i) => {\n        const live = screens?.[i]\n        return (\n          <mesh key={i} geometry={geo} material={live ? liveMaterial(live) : screenMaterial(p.kind)} position={p.pos} rotation={p.rot ?? [0, 0, 0]} scale={[p.w, p.h, 1]} />\n        )\n      })}\n    </>\n  )\n}\n"
    },
    {
      "path": "interiors/_textures.ts",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/_textures.ts",
      "content": "// Canvas materials for control-room interiors: wall lining, raised floor, ceiling tiles, LED panels, perforated doors, shutter slats, labels.\nimport * as THREE from 'three'\n\ntype G = CanvasRenderingContext2D\nconst cache = new Map<string, THREE.MeshStandardMaterial>()\n\n/** Cached canvas-texture material; `draw` runs once, on first use. */\nexport function canvasMaterial(key: string, w: number, h: number, draw: (g: G, w: number, h: number) => void, o: { repeat?: boolean; roughness?: number; metalness?: number; emissive?: number; alphaTest?: number; transparent?: boolean; side?: THREE.Side } = {}) {\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.05, roughness: o.roughness ?? 0.6, alphaTest: o.alphaTest ?? 0, transparent: o.transparent ?? false, side: o.side ?? THREE.FrontSide })\n  if (o.emissive) {\n    m.emissive = new THREE.Color('#ffffff')\n    m.emissiveMap = tex\n    m.emissiveIntensity = o.emissive\n  }\n  m.name = `int-${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}\n\n/** Steel-faced wall lining panel, 600 mm module with a shadow-gap joint. UV 1 = 0.6 m. */\nexport function liningMaterial(color = '#e3e4e0') {\n  return canvasMaterial(`lining-${color}`, 128, 128, (g, w, h) => {\n    speckle(g, w, h, color, 220, 'rgba(0,0,0,0.035)', 'rgba(255,255,255,0.05)', 3)\n    g.fillStyle = 'rgba(40,44,48,0.45)'\n    g.fillRect(0, 0, 2, h)\n    g.fillStyle = 'rgba(255,255,255,0.4)'\n    g.fillRect(2, 0, 1, h)\n  }, { repeat: true, roughness: 0.55, metalness: 0.15 })\n}\n\n/** 600 × 600 raised-access floor tile with an anti-static vinyl finish. UV 1 = 0.6 m. */\nexport function raisedFloorMaterial(color = '#8d9396') {\n  return canvasMaterial(`rf-${color}`, 256, 256, (g, w, h) => {\n    speckle(g, w, h, color, 2600, 'rgba(0,0,0,0.12)', 'rgba(255,255,255,0.1)', 7)\n    g.strokeStyle = 'rgba(30,32,34,0.75)'\n    g.lineWidth = 3\n    g.strokeRect(1.5, 1.5, w - 3, h - 3)\n    g.strokeStyle = 'rgba(255,255,255,0.18)'\n    g.lineWidth = 1\n    g.strokeRect(4, 4, w - 8, h - 8)\n  }, { repeat: true, roughness: 0.7 })\n}\n\n/** Carpet tile, 500 mm module. UV 1 = 0.5 m. */\nexport function carpetMaterial(color = '#4a5058') {\n  return canvasMaterial(`carpet-${color}`, 256, 256, (g, w, h) => {\n    speckle(g, w, h, color, 9000, 'rgba(0,0,0,0.16)', 'rgba(255,255,255,0.07)', 9)\n    g.strokeStyle = 'rgba(0,0,0,0.25)'\n    g.lineWidth = 2\n    g.strokeRect(1, 1, w - 2, h - 2)\n  }, { repeat: true, roughness: 0.95, metalness: 0 })\n}\n\n/** Mineral-fibre suspended ceiling tile on a 600 mm exposed grid. UV 1 = 0.6 m. */\nexport function ceilingMaterial() {\n  return canvasMaterial('ceiling', 256, 256, (g, w, h) => {\n    speckle(g, w, h, '#ecece8', 1800, 'rgba(0,0,0,0.08)', 'rgba(255,255,255,0.4)', 5)\n    g.fillStyle = '#f7f7f5'\n    g.fillRect(0, 0, w, 6)\n    g.fillRect(0, 0, 6, h)\n    g.fillStyle = 'rgba(0,0,0,0.12)'\n    g.fillRect(6, 6, w - 6, 2)\n    g.fillRect(6, 6, 2, h - 6)\n  }, { repeat: true, roughness: 0.9, metalness: 0, side: THREE.DoubleSide })\n}\n\n/** Flat 600 × 600 LED panel: opal diffuser with a slightly brighter edge-lit rim. */\nexport function ledPanelMaterial(intensity = 1.6) {\n  return canvasMaterial(`led-${intensity}`, 128, 128, (g, w, h) => {\n    const grd = g.createRadialGradient(w / 2, h / 2, 10, w / 2, h / 2, w * 0.7)\n    grd.addColorStop(0, '#fffdf6')\n    grd.addColorStop(1, '#fff6e6')\n    g.fillStyle = grd\n    g.fillRect(0, 0, w, h)\n  }, { emissive: intensity, roughness: 0.4, metalness: 0 })\n}\n\n/** Square-perforated steel (cabinet doors, ceiling diffusers). UV 1 = 0.1 m. */\nexport function perforatedMaterial(color = '#2f3337') {\n  return canvasMaterial(`perf-${color}`, 64, 64, (g, w, h) => {\n    g.clearRect(0, 0, w, h)\n    g.fillStyle = color\n    g.fillRect(0, 0, w, h)\n    for (let y = 0; y < 8; y++)\n      for (let x = 0; x < 8; x++) {\n        g.clearRect(x * 8 + 2 + (y % 2) * 4, y * 8 + 2, 4, 4)\n      }\n  }, { repeat: true, transparent: true, side: THREE.DoubleSide, metalness: 0.4, roughness: 0.5 })\n}\n\n/** Linear slot diffuser face. */\nexport function diffuserMaterial() {\n  return canvasMaterial('diffuser', 128, 32, (g, w, h) => {\n    g.fillStyle = '#f2f2f0'\n    g.fillRect(0, 0, w, h)\n    g.fillStyle = '#25282b'\n    for (let i = 0; i < 4; i++) g.fillRect(4, 4 + i * 7, w - 8, 3)\n  }, { roughness: 0.5 })\n}\n\n/** Interlocking roller-shutter slats, repeat along V. UV 1 = 0.075 m slat. */\nexport function slatMaterial(color = '#b9bec2') {\n  return canvasMaterial(`slat-${color}`, 32, 64, (g, w, h) => {\n    const grd = g.createLinearGradient(0, 0, 0, h)\n    grd.addColorStop(0, 'rgba(0,0,0,0.35)')\n    grd.addColorStop(0.15, color)\n    grd.addColorStop(0.55, '#e0e4e7')\n    grd.addColorStop(0.85, color)\n    grd.addColorStop(1, 'rgba(0,0,0,0.4)')\n    g.fillStyle = grd\n    g.fillRect(0, 0, w, h)\n  }, { repeat: true, metalness: 0.6, roughness: 0.4 })\n}\n\n/** Engraved traffolyte label (white on black, or black on white). */\nexport function labelMaterial(text: string, bg = '#1b1d1f', fg = '#f1f1ed', w = 512, h = 96) {\n  return canvasMaterial(`label-${text}-${bg}-${fg}-${w}x${h}`, w, h, (g) => {\n    g.fillStyle = bg\n    g.fillRect(0, 0, w, h)\n    g.fillStyle = fg\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    let size = Math.round(h * 0.6)\n    g.font = `bold ${size}px Arial, sans-serif`\n    while (g.measureText(text).width > w * 0.9 && size > 8) g.font = `bold ${(size -= 1)}px Arial, sans-serif`\n    g.fillText(text, w / 2, h / 2 + 1)\n  }, { roughness: 0.45 })\n}\n\n/** Door / window certification plate: rating, standard, serial. */\nexport function ratingLabelMaterial(rating: string) {\n  return canvasMaterial(`rating-${rating}`, 256, 128, (g, w, h) => {\n    const grd = g.createLinearGradient(0, 0, w, h)\n    grd.addColorStop(0, '#d8dcdf')\n    grd.addColorStop(1, '#b8bec2')\n    g.fillStyle = grd\n    g.fillRect(0, 0, w, h)\n    g.strokeStyle = '#555b60'\n    g.lineWidth = 4\n    g.strokeRect(3, 3, w - 6, h - 6)\n    g.fillStyle = '#b52a20'\n    g.fillRect(10, 10, 90, h - 20)\n    g.fillStyle = '#fff'\n    g.font = 'bold 34px Arial, sans-serif'\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    g.fillText(rating, 55, h / 2)\n    g.fillStyle = '#1b1e21'\n    g.textAlign = 'left'\n    g.font = 'bold 17px Arial, sans-serif'\n    g.fillText(rating.startsWith('H') ? 'H-CLASS DIVISION' : rating.startsWith('A') ? 'A-CLASS DIVISION' : 'FIRE DOOR', 110, 32)\n    g.font = '15px Arial, sans-serif'\n    g.fillText('IMO FTP CODE', 110, 58)\n    g.fillText('SN 000000', 110, 82)\n    g.fillText('TYPE APPROVED', 110, 104)\n  }, { metalness: 0.6, roughness: 0.35 })\n}\n\n/** ISO 7010 E001-style emergency exit sign (running man, door, arrow). */\nexport function exitSignMaterial(lit = true) {\n  return canvasMaterial(`exit-${lit}`, 512, 256, (g, w, h) => {\n    g.fillStyle = '#0f8a3d'\n    g.fillRect(0, 0, w, h)\n    g.fillStyle = '#fff'\n    // door frame\n    g.fillRect(300, 30, 12, 196)\n    g.fillRect(300, 30, 150, 12)\n    g.fillRect(438, 30, 12, 196)\n    // running figure\n    g.beginPath()\n    g.arc(240, 62, 22, 0, Math.PI * 2)\n    g.fill()\n    g.lineCap = 'round'\n    g.strokeStyle = '#fff'\n    g.lineWidth = 26\n    const ln = (a: [number, number][]) => {\n      g.beginPath()\n      a.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)))\n      g.stroke()\n    }\n    ln([[226, 100], [196, 160]])\n    ln([[196, 160], [238, 186], [228, 230]])\n    ln([[196, 160], [160, 200], [128, 196]])\n    ln([[222, 104], [262, 130], [290, 116]])\n    ln([[222, 104], [182, 112], [164, 140]])\n    // arrow\n    g.beginPath()\n    g.moveTo(24, 128)\n    g.lineTo(78, 78)\n    g.lineTo(78, 108)\n    g.lineTo(124, 108)\n    g.lineTo(124, 148)\n    g.lineTo(78, 148)\n    g.lineTo(78, 178)\n    g.closePath()\n    g.fill()\n  }, { emissive: lit ? 0.9 : 0, roughness: 0.4 })\n}\n\n/** Room name sign: white on blue (mandatory-style) or black on white. */\nexport function roomSignMaterial(text: string) {\n  return canvasMaterial(`room-${text}`, 512, 128, (g, w, h) => {\n    g.fillStyle = '#f4f4f0'\n    g.fillRect(0, 0, w, h)\n    g.fillStyle = '#1f4e9a'\n    g.fillRect(0, 0, 18, h)\n    g.fillStyle = '#16191c'\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    let size = 52\n    g.font = `bold ${size}px Arial, sans-serif`\n    while (g.measureText(text).width > w - 60 && size > 12) g.font = `bold ${(size -= 2)}px Arial, sans-serif`\n    g.fillText(text, w / 2 + 9, h / 2 + 2)\n  }, { roughness: 0.4 })\n}\n\n/** Keyboard key field. */\nexport function keyboardMaterial() {\n  return canvasMaterial('keyboard', 256, 96, (g, w, h) => {\n    g.fillStyle = '#1b1c1e'\n    g.fillRect(0, 0, w, h)\n    const kw = 14\n    for (let r = 0; r < 5; r++)\n      for (let c = 0; c < 15; c++) {\n        const x = 4 + c * (kw + 2) + (r % 2) * 4\n        if (x + kw > 200) continue\n        g.fillStyle = '#2d2f32'\n        g.fillRect(x, 6 + r * 17, kw, 14)\n        g.fillStyle = 'rgba(255,255,255,0.35)'\n        g.fillRect(x + 4, 10 + r * 17, 4, 3)\n      }\n    g.fillStyle = '#2d2f32'\n    g.fillRect(60, 6 + 5 * 17 - 2, 90, 10)\n    for (let r = 0; r < 4; r++) for (let c = 0; c < 3; c++) g.fillRect(206 + c * 16, 6 + r * 20, 14, 16)\n  }, { roughness: 0.6 })\n}\n"
    },
    {
      "path": "interiors/_wall.ts",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/_wall.ts",
      "content": "// Wall panel with real rectangular openings: built as boxes around each hole, lined both faces, for doors, windows and rooms.\nimport * as THREE from 'three'\nimport { mat4, uvScale, type Kit, type MatKey } from '../lib/kit'\n\n/** Hole in a wall: centre `u` along the wall, width `w`, sill `y0` and head `y1` above the floor. */\nexport interface Hole {\n  u: number\n  w: number\n  y0: number\n  y1: number\n}\n\n/**\n * Wall along X from -len/2 to len/2, thickness along Z centred on z = 0, floor y = 0 to `height`.\n * `face` is the finish on every face (UV 1 = 0.6 m, planar so the module lines up across pieces).\n */\nexport function wallWithHoles(k: Kit, len: number, height: number, t: number, holes: Hole[], face: MatKey, y0 = 0) {\n  const hs = holes\n    .map((h) => ({ a: Math.max(-len / 2, h.u - h.w / 2), b: Math.min(len / 2, h.u + h.w / 2), y0: Math.max(y0, h.y0), y1: Math.min(height, h.y1) }))\n    .filter((h) => h.b - h.a > 0.01 && h.y1 - h.y0 > 0.01)\n    .sort((p, q) => p.a - q.a)\n  const piece = (a: number, b: number, ya: number, yb: number) => {\n    if (b - a < 0.002 || yb - ya < 0.002) return\n    const g = new THREE.BoxGeometry(b - a, yb - ya, t)\n    const pos = g.getAttribute('position') as THREE.BufferAttribute\n    const nor = g.getAttribute('normal') as THREE.BufferAttribute\n    const uv = g.getAttribute('uv') as THREE.BufferAttribute\n    for (let i = 0; i < pos.count; i++) {\n      const x = pos.getX(i) + (a + b) / 2\n      const y = pos.getY(i) + (ya + yb) / 2\n      const z = pos.getZ(i)\n      const ax = Math.abs(nor.getX(i))\n      uv.setXY(i, (ax > 0.5 ? z : x) / 0.6, y / 0.6)\n    }\n    k.add(face, g, mat4([(a + b) / 2, (ya + yb) / 2, 0]))\n  }\n  let cur = -len / 2\n  for (const h of hs) {\n    if (h.a > cur) piece(cur, h.a, y0, height)\n    piece(h.a, h.b, y0, h.y0)\n    piece(h.a, h.b, h.y1, height)\n    cur = Math.max(cur, h.b)\n  }\n  piece(cur, len / 2, y0, height)\n}\n\n/** Plain box with UVs scaled so a 0.6 m module texture tiles at true size. */\nexport function tiledBox(k: Kit, mat: MatKey, size: [number, number, number], pos: [number, number, number], module = 0.6) {\n  const g = new THREE.BoxGeometry(...size)\n  uvScale(g, Math.max(size[0], size[2]) / module, Math.max(size[1], size[2]) / module)\n  k.add(mat, g, mat4(pos))\n}\n"
    }
  ],
  "categories": [
    "interiors"
  ],
  "meta": {
    "export": "Room",
    "anchor": "base",
    "standards": [
      "ISO 11064-3 (control room layout)",
      "EN 12825 (raised access floors)",
      "ISO 7010 E001",
      "IMO FTP Code"
    ]
  }
}