{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "video-wall",
  "type": "registry:component",
  "title": "Video wall",
  "description": "Rows × columns of narrow-bezel LCD tiles (0.88 mm bezel-to-bezel by default) or seamless fine-pitch LED tiles. Floor-standing frame with uprights, rails, pop-out mounts and a base cabinet with perforated doors, or wall-mounted on rails. The optional controller is a stack of 19\" units behind a glazed door in the base, or a rack beside a wall-mounted wall. Tiles show procedural sample pages (CCTV, overview, mimic, helideck camera, metocean, trends, F&G); pass `screens` per tile, or set `span` to stretch screens[0] across the whole wall.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "interiors/video-wall.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/video-wall.tsx",
      "content": "// Video wall: rows × columns of narrow-bezel LCD or fine-pitch LED tiles on a floor-standing frame with a base cabinet, or wall mounted; optional controller rack.\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport { Kit } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { frame, furnitureMats, INCH } from './_furniture'\nimport { ScreenPanels, liveMaterial, screenMaterial, type ScreenKind, type ScreenPlacement } from './_screens'\nimport { perforatedMaterial } from './_textures'\n\nexport interface VideoWallProps {\n  rows?: number\n  cols?: number\n  /** tile diagonal, inches */\n  diagonal?: number\n  /** LCD bezel-to-bezel, mm (LED tiles are seamless) */\n  bezel?: number\n  technology?: 'lcd' | 'led'\n  mount?: 'floor' | 'wall'\n  /** bottom edge of the lowest tile above the floor, m */\n  elevation?: number\n  /** 19\" controller rack beside the wall (wall mount) or behind a glazed door in the base (floor) */\n  controller?: boolean\n  /** one source stretched across the whole wall instead of one per tile */\n  span?: boolean\n  /** procedural content, cycled over the tiles (top row first, left to right) */\n  content?: ScreenKind[]\n  /** live textures per tile (top row first, left to right), or screens[0] across the wall when `span` */\n  screens?: (THREE.Texture | null | undefined)[]\n  tag?: string\n}\n\nexport const defaults = {\n  rows: 3,\n  cols: 4,\n  diagonal: 55,\n  bezel: 0.88,\n  technology: 'lcd' as 'lcd' | 'led',\n  mount: 'floor' as 'floor' | 'wall',\n  elevation: 0.75,\n  controller: true,\n  span: false,\n  content: ['cctv', 'overview', 'mimic', 'helideck', 'trend', 'fg', 'alarm', 'weather', 'cctv', 'kpi', 'esd', 'cctv'] as ScreenKind[],\n  screens: [] as (THREE.Texture | null | undefined)[],\n  tag: 'VW-001',\n}\n\ntype P = Omit<typeof defaults, 'screens'>\n\nexport function VideoWall({ screens, ...rest }: VideoWallProps & GroupProps) {\n  const { rows, cols, diagonal, bezel, technology, mount, elevation, controller, span, content, tag, ...group } = { ...defaults, ...rest }\n  const contentKey = content.join(',')\n  const b = useMemo(\n    () => build({ rows, cols, diagonal, bezel, technology, mount, elevation, controller, span, content, tag }),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [rows, cols, diagonal, bezel, technology, mount, elevation, controller, span, contentKey, tag],\n  )\n  useEffect(() => () => [b.built.geometry, ...b.spanGeos].forEach((g) => g.dispose()), [b])\n  const live = screens?.[0]\n  return (\n    <group {...group}>\n      <KitMesh data={b.built} />\n      {span ? (\n        b.spanGeos.map((g, i) => <mesh key={i} geometry={g} material={live ? liveMaterial(live) : screenMaterial(content[0] ?? 'overview')} />)\n      ) : (\n        <ScreenPanels placements={b.placements} screens={screens} />\n      )}\n    </group>\n  )\n}\n\nexport default VideoWall\n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nfunction build(p: P) {\n  const k = new Kit()\n  const M = furnitureMats()\n  const R = clamp(Math.round(p.rows), 1, 6)\n  const Cn = clamp(Math.round(p.cols), 1, 10)\n  const led = p.technology === 'led'\n  const d = clamp(p.diagonal, 20, 98)\n  const kk = (d * INCH) / Math.hypot(16, 9)\n  const aw = 16 * kk\n  const ah = 9 * kk\n  const gap = led ? 0.0003 : clamp(p.bezel, 0.4, 6) / 1000\n  const pw = aw + gap\n  const ph = ah + gap\n  const WW = Cn * pw\n  const HH = R * ph\n  const floor = p.mount === 'floor'\n  const D = floor ? 0.6 : 0.12 // overall depth: base cabinet / wall mount\n  const zF = D / 2 // screen face plane\n  const tileD = led ? 0.06 : 0.085\n  const y0 = clamp(p.elevation, floor ? 0.4 : 0.3, 2.5)\n  const frameC = paint('#2b2f33', { metalness: 0.35, roughness: 0.5, clearcoat: 0, weather: 0 })\n  const housing = paint('#121315', { metalness: 0.2, roughness: 0.45, clearcoat: 0.3, weather: 0 })\n\n  // tiles: thin black housing, seam shadow, screen faces\n  const placements: ScreenPlacement[] = []\n  const spanGeos: THREE.BufferGeometry[] = []\n  for (let r = 0; r < R; r++)\n    for (let c = 0; c < Cn; c++) {\n      const x = -WW / 2 + (c + 0.5) * pw\n      const y = y0 + HH - (r + 0.5) * ph\n      k.box(housing, [pw - 0.0002, ph - 0.0002, tileD], [x, y, zF - tileD / 2 - 0.0005])\n      if (!led) k.box(M.bezel, [aw * 0.86, ah * 0.86, 0.03], [x, y, zF - tileD - 0.015]) // rear electronics\n      const kind = p.content.length ? p.content[(r * Cn + c) % p.content.length] : 'overview'\n      placements.push({ pos: [x, y, zF + 0.0004], w: aw, h: ah, kind })\n      if (p.span) {\n        const g = new THREE.PlaneGeometry(aw, ah)\n        const uv = g.getAttribute('uv') as THREE.BufferAttribute\n        for (let i = 0; i < uv.count; i++) uv.setXY(i, (c * pw + gap / 2 + uv.getX(i) * aw) / WW, (HH - (r + 1) * ph + gap / 2 + uv.getY(i) * ah) / HH)\n        g.translate(x, y, zF + 0.0004)\n        spanGeos.push(g)\n      }\n    }\n  // trim around the wall\n  const tr = 0.025\n  const zt = zF - 0.03\n  k.box(frameC, [WW + 2 * tr, tr, 0.06], [0, y0 + HH + tr / 2, zt])\n  k.box(frameC, [WW + 2 * tr, tr, 0.06], [0, y0 - tr / 2, zt])\n  for (const s of [-1, 1]) k.box(frameC, [tr, HH, 0.06], [s * (WW / 2 + tr / 2), y0 + HH / 2, zt])\n\n  const zMount = zF - tileD - 0.04 // vertical rails behind the tiles\n  if (floor) {\n    // floor-standing frame: uprights at every column joint, horizontal rails per row, braces to the base, base cabinet\n    const yb = y0 - 0.02\n    for (let c = 0; c <= Cn; c++) {\n      const x = -WW / 2 + c * pw\n      k.box(frameC, [0.06, HH + (y0 - yb), 0.04], [x, yb + (HH + y0 - yb) / 2, zMount])\n      k.box(frameC, [0.06, 0.04, D - 0.1], [x, y0 + HH - 0.1, zMount - (D - 0.1) / 2 + 0.02])\n      k.rod(frameC, [x, y0 + HH - 0.12, zMount - D + 0.15], [x, yb + 0.02, zMount - 0.03], 0.015, 8)\n    }\n    for (let r = 0; r <= R; r++) k.box(M.alu, [WW, 0.04, 0.03], [0, y0 + r * ph - (r === 0 ? -0.06 : r === R ? 0.06 : 0), zMount - 0.035])\n    // pop-out mounts behind each tile\n    for (let r = 0; r < R; r++)\n      for (let c = 0; c < Cn; c++) {\n        const x = -WW / 2 + (c + 0.5) * pw\n        const y = y0 + HH - (r + 0.5) * ph\n        k.box(M.alu, [0.3, 0.03, zF - tileD - zMount + 0.02], [x, y + ah * 0.3, (zF - tileD + zMount) / 2])\n        k.box(M.alu, [0.3, 0.03, zF - tileD - zMount + 0.02], [x, y - ah * 0.3, (zF - tileD + zMount) / 2])\n      }\n    // base cabinet: carcass, perforated doors, plinth, top\n    const bw = WW + 2 * tr\n    const bh = yb\n    const bz = 0\n    k.box(frameC, [bw, bh - 0.08, D - 0.04], [0, 0.08 + (bh - 0.08) / 2, bz])\n    k.box('rubberBlack', [bw - 0.06, 0.08, D - 0.1], [0, 0.04, bz])\n    k.box(M.housing, [bw + 0.02, 0.025, D], [0, bh + 0.0125, bz])\n    const nd = Math.max(2, Math.round(bw / 0.6))\n    const dw = bw / nd\n    for (let i = 0; i < nd; i++) {\n      const x = -bw / 2 + (i + 0.5) * dw\n      const glazed = p.controller && i === nd - 1\n      const dh = bh - 0.12\n      const g = new THREE.PlaneGeometry(dw - 0.08, dh - 0.08)\n      const uv = g.getAttribute('uv') as THREE.BufferAttribute\n      for (let j = 0; j < uv.count; j++) uv.setXY(j, (uv.getX(j) * (dw - 0.08)) / 0.1, (uv.getY(j) * (dh - 0.08)) / 0.1)\n      k.box(frameC, [dw - 0.006, dh, 0.018], [x, 0.1 + dh / 2, bz + D / 2 - 0.02 + 0.009])\n      if (glazed) {\n        k.box('glass', [dw - 0.08, dh - 0.08, 0.004], [x, 0.1 + dh / 2, bz + D / 2 + 0.0005])\n        controllerUnits(k, x, 0.14, dh - 0.1, bz + D / 2 - 0.06, dw - 0.16)\n      } else k.add(perforatedMaterial('#25292d'), g, frame([x, 0.1 + dh / 2, bz + D / 2 + 0.0005]))\n      k.rbox(M.alu, [0.02, 0.12, 0.012], 0.004, [x + dw / 2 - 0.05, 0.1 + dh / 2, bz + D / 2 + 0.006])\n    }\n    tagPlate(k, p.tag, [-bw / 2 + 0.25, bh - 0.08, bz + D / 2 + 0.002], 'z', 0.24)\n  } else {\n    // wall mount: horizontal rails on the wall plane (z = -D/2) with pop-out arms per tile\n    const zw = -D / 2\n    for (let r = 0; r < R; r++) {\n      const y = y0 + HH - (r + 0.5) * ph\n      for (const dy of [ah * 0.3, -ah * 0.3]) k.box(M.alu, [WW - 0.1, 0.05, 0.012], [0, y + dy, zw + 0.006])\n    }\n    for (let r = 0; r < R; r++)\n      for (let c = 0; c < Cn; c++) {\n        const x = -WW / 2 + (c + 0.5) * pw\n        const y = y0 + HH - (r + 0.5) * ph\n        for (const dy of [ah * 0.3, -ah * 0.3]) k.box(M.alu, [0.25, 0.03, zF - tileD - zw - 0.012], [x, y + dy, (zF - tileD + zw + 0.012) / 2])\n      }\n    tagPlate(k, p.tag, [-WW / 2 + 0.15, y0 - 0.06, zF - 0.02], 'z', 0.2)\n    if (p.controller) {\n      // 19\" rack beside the wall\n      const x = WW / 2 + 0.5\n      const rw = 0.6\n      const rd = 0.8\n      const rh = 1.2\n      k.box(frameC, [rw, rh, rd], [x, rh / 2 + 0.05, zw + rd / 2])\n      k.box('rubberBlack', [rw - 0.04, 0.05, rd - 0.04], [x, 0.025, zw + rd / 2])\n      k.box('glass', [rw - 0.08, rh - 0.1, 0.004], [x, rh / 2 + 0.05, zw + rd + 0.002])\n      controllerUnits(k, x, 0.15, rh - 0.2, zw + rd - 0.04, rw - 0.12)\n    }\n  }\n  const built = k.build()\n  return { built, placements, spanGeos }\n}\n\n/** Stack of 1U / 2U controller and KVM units with status LEDs, fronts on the plane z. */\nfunction controllerUnits(k: Kit, x: number, y0: number, h: number, z: number, w: number) {\n  const M = furnitureMats()\n  const U = 0.04445\n  let y = y0\n  let i = 0\n  while (y + U < y0 + h) {\n    const u = i % 3 === 0 ? 2 : 1\n    if (y + u * U > y0 + h) break\n    k.box(i % 2 ? M.housing : M.bezel, [w, u * U - 0.002, 0.4], [x, y + (u * U) / 2, z - 0.2])\n    for (let l = 0; l < 4; l++) k.box(l === 3 && i % 4 === 1 ? M.ledAmber : M.ledGreen, [0.006, 0.004, 0.002], [x - w / 2 + 0.04 + l * 0.015, y + (u * U) / 2, z + 0.001])\n    if (u === 2) k.box('plasticGrey', [w * 0.5, 0.05, 0.002], [x + w * 0.1, y + U, z + 0.001])\n    y += u * U\n    i++\n  }\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"
    }
  ],
  "categories": [
    "interiors"
  ],
  "meta": {
    "export": "VideoWall",
    "anchor": "base",
    "standards": [
      "ISO 11064",
      "ISA-101 (screen style)"
    ]
  }
}