{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "door",
  "type": "registry:component",
  "title": "Door set (fire, blast, sliding, roller shutter)",
  "description": "Offshore door set in a short lined wall stub with a real hole, so you can see through it when open. `kind` picks a single or double hinged steel door (pressed-steel rebated frame with seals, 3–4 hinges, stainless threshold and kick plates, overhead closer, lever or panic bar, vision panel, astragal and flush bolts on pairs), a top-hung sliding door, a blast door (100 mm leaf, heavy hinges, multi-point locking with dog lever, bolted laminated vision panel) or a roller shutter (slat curtain in side guides, coil box with drive, push-button station). `fireRating` sets leaf thickness and the A60 / H120 rating plates. `open` (0–1) swings, slides or rolls the leaf; leaves swing towards +Z. ISO 7010 exit sign over the door and a room-name sign beside it. Set `wall: none` to drop it into a room wall; `doorOpening()` gives the hole size.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "interiors/door.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/door.tsx",
      "content": "// Offshore door set in a wall stub: single / double hinged, sliding, blast or roller shutter; A60 / H120 fire rating, vision panel, panic bar, closer, ISO 7010 signage.\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport { Kit, eul, type Built, type MatKey } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { furnitureMats } from './_furniture'\nimport { exitSignMaterial, labelMaterial, liningMaterial, ratingLabelMaterial, roomSignMaterial, slatMaterial } from './_textures'\nimport { wallWithHoles } from './_wall'\n\nexport type DoorKind = 'single' | 'double' | 'sliding' | 'blast' | 'roller-shutter'\nexport type FireRating = 'A60' | 'H120' | 'none'\n\nexport interface DoorProps {\n  kind?: DoorKind\n  fireRating?: FireRating\n  /** clear opening width, m (0 = typical for the kind) */\n  width?: number\n  /** clear opening height, m (0 = typical for the kind) */\n  height?: number\n  /** 0 closed … 1 fully open (swing, slide or roll up) */\n  open?: number\n  /** hinge side (or slide direction) seen from the +Z face */\n  hand?: 'left' | 'right'\n  hardware?: 'lever' | 'panic'\n  visionPanel?: boolean\n  /** short wall around the frame so the door reads on its own; 'none' to set it in a room wall */\n  wall?: 'stub' | 'none'\n  wallThickness?: number\n  sign?: 'both' | 'exit' | 'room' | 'none'\n  /** room name on the sign beside the door */\n  label?: string\n  tag?: string\n}\n\nexport const defaults = {\n  kind: 'single' as DoorKind,\n  fireRating: 'A60' as FireRating,\n  width: 0,\n  height: 0,\n  open: 0.35,\n  hand: 'left' as 'left' | 'right',\n  hardware: 'lever' as 'lever' | 'panic',\n  visionPanel: true,\n  wall: 'stub' as 'stub' | 'none',\n  wallThickness: 0.15,\n  sign: 'both' as 'both' | 'exit' | 'room' | 'none',\n  label: 'CENTRAL CONTROL ROOM',\n  tag: 'D-101',\n}\n\nconst TYPICAL: Record<DoorKind, [number, number]> = { single: [0.9, 2.1], double: [1.8, 2.1], sliding: [1.2, 2.1], blast: [1.0, 2.1], 'roller-shutter': [2.4, 2.6] }\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\n/** Clear opening, frame and the rough hole the door needs in a wall (hole centred on x = 0, from the floor). */\nexport function doorOpening(p: Pick<DoorProps, 'kind' | 'width' | 'height'> = {}) {\n  const kind = p.kind ?? defaults.kind\n  const W = clamp(p.width || TYPICAL[kind][0], 0.6, kind === 'roller-shutter' ? 6 : 3)\n  const H = clamp(p.height || TYPICAL[kind][1], 1.8, kind === 'roller-shutter' ? 5 : 3)\n  const blast = kind === 'blast'\n  const FR = kind === 'roller-shutter' ? 0 : blast ? 0.1 : 0.06 // frame face\n  const R = kind === 'roller-shutter' ? 0 : blast ? 0.02 : 0.012 // rebate\n  return { W, H, FR, R, holeW: W + 2 * (R + FR), holeH: H + R + FR }\n}\n\nexport function Door(props: DoorProps & GroupProps) {\n  const { kind, fireRating, width, height, open, hand, hardware, visionPanel, wall, wallThickness, sign, label, tag, ...group } = { ...defaults, ...props }\n  const p = { kind, fireRating, width, height, hand, hardware, visionPanel, wall, wallThickness, sign, label, tag }\n  const b = useMemo(() => build(p), [kind, fireRating, width, height, hand, hardware, visionPanel, wall, wallThickness, sign, label, tag]) // eslint-disable-line react-hooks/exhaustive-deps\n  const o = clamp(open, 0, 1)\n  const curtain = useMemo(() => (kind === 'roller-shutter' ? curtainBuilt(b.dims.W, b.dims.H, o, b.zFace) : null), [kind, b, o])\n  useEffect(() => () => [b.built, ...b.leaves.map((l) => l.built)].forEach((x) => x.geometry.dispose()), [b])\n  useEffect(() => () => curtain?.geometry.dispose(), [curtain])\n  return (\n    <group {...group}>\n      <KitMesh data={b.built} />\n      {b.leaves.map((l, i) => (\n        <group key={i} position={[l.x + (l.slide ?? 0) * o, 0, l.z]} rotation={[0, l.slide !== undefined ? 0 : -l.dir * o * l.max, 0]}>\n          <KitMesh data={l.built} />\n        </group>\n      ))}\n      {curtain && <KitMesh data={curtain} />}\n    </group>\n  )\n}\n\nexport default Door\n\ninterface Leaf {\n  x: number\n  z: number\n  dir: 1 | -1\n  max: number\n  slide?: number\n  built: Built\n}\n\ntype P = Omit<typeof defaults, 'open'>\n\nfunction build(p: P) {\n  const k = new Kit()\n  const M = furnitureMats()\n  const d = doorOpening(p)\n  const { W, H, FR, R } = d\n  const kind = p.kind\n  const blast = kind === 'blast'\n  const T = clamp(p.wallThickness, 0.06, 0.4)\n  const FD = Math.max(T + 0.03, blast ? 0.2 : 0.12) // frame depth\n  const rating: FireRating = blast && p.fireRating === 'none' ? 'A60' : p.fireRating\n  const t = blast ? 0.1 : rating === 'H120' ? 0.07 : rating === 'A60' ? 0.06 : 0.045 // leaf thickness\n  const frameMat = paint(blast ? '#5d646a' : '#c9cdc9', { metalness: 0.3, roughness: 0.45, clearcoat: 0.2, weather: 0 })\n  const leafMat = paint(blast ? '#6b7379' : '#d4d8d4', { metalness: 0.25, roughness: 0.42, clearcoat: 0.3, weather: 0 })\n  const zo = FD / 2 - 0.003 // outer leaf face (swing doors close flush with the +Z frame face)\n  const latch = p.hand === 'left' ? 1 : -1 // latch side x sign\n\n  // wall stub with a real hole\n  const slideRun = kind === 'sliding' ? W + 0.1 : 0\n  const wallLen = d.holeW + slideRun + 1.5\n  const wallH = Math.max(2.9, d.holeH + (kind === 'roller-shutter' ? 0.9 : 0.6))\n  const wallX = kind === 'sliding' ? (-latch * slideRun) / 2 : 0\n  if (p.wall === 'stub') {\n    k.at([wallX, 0, 0], undefined, () => wallWithHoles(k, wallLen, wallH, T, [{ u: -wallX, w: d.holeW, y0: 0, y1: d.holeH }], liningMaterial()))\n    // vinyl skirting both faces\n    for (const s of [-1, 1])\n      for (const [a, b] of [[-wallLen / 2 + wallX, -d.holeW / 2], [d.holeW / 2, wallLen / 2 + wallX]] as const) if (b - a > 0.01) k.box('rubberBlack', [b - a, 0.1, 0.008], [(a + b) / 2, 0.05, s * (T / 2 + 0.004)])\n  }\n\n  const leaves: Leaf[] = []\n  if (kind === 'roller-shutter') {\n    // reveal lining, side guides, coil box with drive, bottom seal, push-button station\n    const zf = T / 2\n    for (const s of [-1, 1]) {\n      k.box(frameMat, [0.012, H, T], [s * (W / 2 + 0.006), H / 2, 0])\n      k.box('galvanised', [0.09, H + 0.05, 0.11], [s * (W / 2 + 0.03), (H + 0.05) / 2, zf + 0.055])\n      k.box('rubberBlack', [0.03, H, 0.02], [s * (W / 2 - 0.005), H / 2, zf + 0.05])\n    }\n    k.box(frameMat, [W, 0.012, T], [0, H + 0.006, 0])\n    const bx = W + 0.3\n    k.box('galvanised', [bx, 0.48, 0.46], [0, H + 0.05 + 0.24, zf + 0.23])\n    k.box('galvanised', [bx + 0.01, 0.02, 0.47], [0, H + 0.05, zf + 0.235])\n    k.cyl(M.housing, 0.09, 0.32, [latch * (bx / 2 + 0.16), H + 0.29, zf + 0.23], 'x', 20)\n    k.box(M.housing, [0.14, 0.16, 0.14], [latch * (bx / 2 + 0.07), H + 0.29, zf + 0.23])\n    const ps = latch * (W / 2 + 0.45)\n    k.rbox('actuatorYellow', [0.12, 0.22, 0.08], 0.01, [ps, 1.3, zf + 0.04])\n    ;(['#2e8b57', '#d42a1f', '#2e8b57'] as const).forEach((c, i) => k.cyl(paint(c, { weather: 0 }), 0.018, 0.02, [ps, 1.37 - i * 0.07, zf + 0.088], 'z', 14))\n    k.plane(ratingLabelMaterial(rating === 'none' ? 'FIRE' : rating), 0.12, 0.06, [-latch * (W / 2 + 0.03), 1.5, zf + 0.1105])\n    tagPlate(k, p.tag, [0, H + 0.36, zf + 0.461], 'z', 0.2)\n    k.plane(labelMaterial('KEEP CLEAR', '#e5b114', '#111'), 0.6, 0.1, [0, H + 0.18, zf + 0.461])\n  } else {\n    // pressed-steel frame: jambs, head, rebate stops with seals, threshold\n    const zStop = (zo - t - 0.002 - FD / 2) / 2 // centre of the stop behind the leaf\n    const dStop = zo - t - 0.002 + FD / 2\n    for (const s of [-1, 1]) {\n      k.box(frameMat, [FR, H + R + FR, FD], [s * (W / 2 + R + FR / 2), (H + R + FR) / 2, 0])\n      k.box(frameMat, [R, H + R, dStop], [s * (W / 2 + R / 2), (H + R) / 2, zStop])\n      k.box('rubberBlack', [0.008, H, 0.006], [s * (W / 2 + R - 0.004), H / 2, zo - t - 0.005])\n      if (blast) k.box(frameMat, [FR + 0.06, H + R + FR + 0.03, 0.012], [s * (W / 2 + R + FR / 2 + 0.03), (H + R + FR + 0.03) / 2, FD / 2 + 0.006])\n    }\n    k.box(frameMat, [W + 2 * (R + FR), FR, FD], [0, H + R + FR / 2, 0])\n    k.box(frameMat, [W + 2 * R, R, dStop], [0, H + R / 2, zStop])\n    k.box('rubberBlack', [W + 2 * R - 0.01, 0.006, 0.006], [0, H + R - 0.004, zo - t - 0.005])\n    if (blast) k.box(frameMat, [W + 2 * (R + FR) + 0.12, 0.08, 0.012], [0, H + R + FR + 0.04, FD / 2 + 0.006])\n    k.box('stainless', [W + 2 * R, 0.012, FD + 0.04], [0, 0.006, 0])\n    tagPlate(k, p.tag, [0, H + R + FR / 2, FD / 2 + 0.001], 'z', Math.min(0.14, FR * 2.2))\n    if (rating !== 'none') k.plane(ratingLabelMaterial(blast ? `${rating}` : rating), 0.09, 0.045, [-latch * (W / 2 + R + FR / 2), 1.6, FD / 2 + 0.0015])\n\n    const nh = blast ? 4 : rating === 'H120' || H > 2.3 ? 4 : 3\n    const hingeR = blast ? 0.03 : 0.012\n    const hinge = (x: number) => {\n      for (let i = 0; i < nh; i++) {\n        const y = 0.25 + (i * (H - 0.5)) / (nh - 1)\n        k.cyl('stainless', hingeR, blast ? 0.16 : 0.1, [x, y, zo], 'y', 14)\n        k.cyl('stainless', hingeR * 0.45, 0.015, [x, y + (blast ? 0.085 : 0.055), zo], 'y', 8)\n        if (blast) k.box(frameMat, [0.05, 0.14, 0.02], [x - Math.sign(x) * 0.03, y, zo - 0.005])\n      }\n    }\n    const lw = (n: number) => (W + 2 * R) / n - (n === 2 ? 0.005 : 0.006)\n    if (kind === 'double') {\n      for (const dir of [1, -1] as const) {\n        const hx = -dir * (W / 2 + R)\n        hinge(hx)\n        leaves.push({ x: hx, z: zo, dir, max: 1.66, built: leafBuilt({ ...p, rating, w: lw(2), h: H + R - 0.011, t, dir, active: dir === -1, mat: leafMat }) })\n      }\n    } else if (kind === 'sliding') {\n      // top-hung on a track on the +Z face; the jamb stops are the closing faces\n      const sw = W + 0.1\n      const zl = FD / 2 + 0.015\n      const hx = -sw / 2\n      k.box('aluminium', [2 * sw + 0.1, 0.08, 0.06], [(-latch * slideRun) / 2, H + 0.12, FD / 2 + 0.03])\n      k.box(frameMat, [2 * sw + 0.14, 0.16, 0.012], [(-latch * slideRun) / 2, H + 0.12, FD / 2 + t + 0.05])\n      k.box('rubberBlack', [0.05, 0.03, 0.05], [latch * (sw / 2 + 0.03), 0.015, zl + t / 2])\n      leaves.push({ x: hx * latch, z: zl + t, dir: latch as 1 | -1, max: 0, slide: -latch * slideRun, built: leafBuilt({ ...p, rating, w: sw, h: H + 0.06, t, dir: latch as 1 | -1, active: true, mat: leafMat, sliding: true }) })\n    } else {\n      const dir = latch as 1 | -1\n      const hx = -dir * (W / 2 + R)\n      hinge(hx)\n      leaves.push({ x: hx, z: zo, dir, max: blast ? 1.57 : 1.66, built: leafBuilt({ ...p, rating, w: lw(1), h: H + R - 0.011, t, dir, active: true, mat: leafMat }) })\n    }\n    // overhead closer slide rail on the head (push side)\n    if (kind !== 'sliding') k.box(M.housing, [kind === 'double' ? W * 0.8 : W * 0.45, 0.025, 0.03], [kind === 'double' ? 0 : latch * W * 0.05, H + R - 0.02, -FD / 2 - 0.015])\n  }\n\n  // signage: ISO 7010 E001 exit sign over the door, room name beside it on the latch side\n  const zs = T / 2 + 0.002\n  const top = kind === 'roller-shutter' ? H + 0.6 : d.holeH\n  if (p.sign === 'both' || p.sign === 'exit') {\n    if (kind === 'roller-shutter') k.plane(exitSignMaterial(), 0.4, 0.2, [-latch * (W / 2 + 0.5), 2.2, zs])\n    else {\n      const ye = top + (kind === 'sliding' ? 0.4 : 0.2)\n      k.rbox(M.housing, [0.42, 0.22, 0.03], 0.01, [0, ye, zs + 0.013])\n      k.plane(exitSignMaterial(), 0.4, 0.2, [0, ye, zs + 0.0285])\n    }\n  }\n  if ((p.sign === 'both' || p.sign === 'room') && p.label) {\n    const sx = latch * (d.holeW / 2 + 0.38)\n    k.plane(roomSignMaterial(p.label), 0.5, 0.125, [sx, 1.6, zs])\n  }\n  return { built: k.build(), leaves, dims: d, zFace: T / 2 }\n}\n\n/** Door leaf from its hinge line along dir·X, outer face at z = 0, inner face at z = -t. */\nfunction leafBuilt(o: P & { rating: FireRating; w: number; h: number; t: number; dir: 1 | -1; active: boolean; mat: MatKey; sliding?: boolean }): Built {\n  const k = new Kit()\n  const M = furnitureMats()\n  const { w: lw, h, t, dir } = o\n  const blast = o.kind === 'blast'\n  const X = (x: number) => dir * x\n  const x0 = 0.003\n  const y0 = 0.008\n  const zc = -t / 2\n  // vision panel: A60 type-approved glazing, laminated for blast\n  const vis = o.visionPanel\n  const vw = Math.min(blast ? 0.2 : 0.3, lw * 0.4)\n  const vy0 = blast ? 1.35 : 1.25\n  const vy1 = Math.min(h - 0.25, vy0 + (blast ? 0.3 : 0.55))\n  const vc = x0 + lw / 2\n  const piece = (a: number, b: number, ya: number, yb: number) => k.box(o.mat, [b - a, yb - ya, t], [X((a + b) / 2), (ya + yb) / 2, zc])\n  if (vis) {\n    piece(x0, x0 + lw, y0, vy0)\n    piece(x0, x0 + lw, vy1, h)\n    piece(x0, vc - vw / 2, vy0, vy1)\n    piece(vc + vw / 2, x0 + lw, vy0, vy1)\n    k.box('glass', [vw, vy1 - vy0, Math.min(0.03, t * 0.5)], [X(vc), (vy0 + vy1) / 2, zc])\n    for (const zz of [0.004, -t - 0.004]) {\n      const bw = blast ? 0.04 : 0.02\n      for (const yy of [vy0, vy1]) k.box('stainless', [vw + 2 * bw, bw, 0.008], [X(vc), yy, zz])\n      for (const s of [-1, 1]) k.box('stainless', [bw, vy1 - vy0 + 2 * bw, 0.008], [X(vc + s * (vw / 2 + bw / 2 - bw / 2)), (vy0 + vy1) / 2, zz])\n      if (blast) for (let i = 0; i < 8; i++) {\n        const a = (i / 8) * Math.PI * 2\n        k.cyl('nut', 0.007, 0.012, [X(vc + Math.cos(a) * (vw / 2 + 0.02)), (vy0 + vy1) / 2 + Math.sin(a) * ((vy1 - vy0) / 2 + 0.02), zz + Math.sign(zz) * 0.006], 'z', 6)\n      }\n    }\n  } else piece(x0, x0 + lw, y0, h)\n  // kick plates\n  for (const zz of [0.0025, -t - 0.0025]) k.box('stainless', [lw - 0.04, 0.25, 0.003], [X(vc), y0 + 0.135, zz])\n  // fire door signage, rating plate on the outer face\n  if (o.rating !== 'none' && !o.sliding) k.plane(labelMaterial(blast ? 'BLAST DOOR · KEEP SHUT' : 'FIRE DOOR · KEEP SHUT', '#1f4e9a', '#ffffff', 512, 96), Math.min(0.3, lw * 0.5), Math.min(0.3, lw * 0.5) * 0.1875, [X(vc), vis ? vy0 - 0.12 : 1.55, 0.0012])\n\n  const zi = -t // push (inner) face\n  const hx = lw - 0.075 // handle position from the hinge line\n  if (o.sliding) {\n    // flush pull on both faces, hangers to the track\n    k.rbox('stainless', [0.03, 0.4, 0.04], 0.01, [X(hx), 1.05, 0.025])\n    k.box(M.bezel, [0.03, 0.2, 0.004], [X(hx), 1.05, zi - 0.0015])\n    for (const f of [0.15, lw - 0.15]) {\n      k.box('aluminium', [0.08, 0.1, 0.012], [X(f), h + 0.03, zc])\n      k.cyl('rubberBlack', 0.025, 0.02, [X(f), h + 0.08, zc + 0.01], 'z', 14)\n    }\n    return k.build()\n  }\n  if (o.active) {\n    // lever furniture on the pull side\n    const lever = (zz: number, s: number) => {\n      k.cyl('stainless', 0.026, 0.012, [X(hx), 1.05, zz + s * 0.006], 'z', 20)\n      k.rod('stainless', [X(hx), 1.05, zz + s * 0.012], [X(hx), 1.05, zz + s * 0.06], 0.009, 10)\n      k.rod('stainless', [X(hx), 1.05, zz + s * 0.06], [X(hx - 0.13), 1.05, zz + s * 0.065], 0.01, 10)\n      k.rbox('stainless', [0.04, 0.09, 0.008], 0.004, [X(hx), 0.95, zz + s * 0.004])\n      k.cyl('brass', 0.011, 0.012, [X(hx), 0.95, zz + s * 0.01], 'z', 12)\n    }\n    lever(0, 1)\n    if (blast) {\n      // multi-point locking: centre gearbox with dog lever, rods to top and bottom keepers\n      k.rbox(M.housing, [0.14, 0.24, 0.05], 0.01, [X(hx - 0.04), 1.05, zi - 0.025])\n      k.rod('actuatorRed', [X(hx - 0.04), 1.05, zi - 0.06], [X(hx - 0.3), 1.12, zi - 0.07], 0.016, 10)\n      for (const [ya, yb] of [[1.17, h - 0.05], [0.93, 0.05]] as const) {\n        k.rod('galvanised', [X(hx - 0.04), ya, zi - 0.02], [X(hx - 0.04), yb, zi - 0.02], 0.012, 8)\n        k.box(M.housing, [0.06, 0.04, 0.04], [X(hx - 0.04), yb + (yb > 1 ? -0.02 : 0.02), zi - 0.02])\n      }\n      for (const y of [0.4, 1.7]) {\n        k.rod('galvanised', [X(hx - 0.04), y, zi - 0.02], [X(lw - 0.01), y, zi - 0.02], 0.01, 8)\n        k.box(M.housing, [0.05, 0.05, 0.03], [X(hx + 0.02), y, zi - 0.02])\n      }\n    } else if (o.hardware === 'panic') {\n      // push bar across the push face with end housings\n      const a = 0.09\n      const b = lw - 0.05\n      k.rbox(M.alu, [b - a, 0.07, 0.05], 0.015, [X((a + b) / 2), 0.98, zi - 0.045])\n      k.rbox('stainless', [b - a - 0.1, 0.05, 0.03], 0.012, [X((a + b) / 2 - 0.02), 0.98, zi - 0.085])\n      k.rbox(M.housing, [0.1, 0.12, 0.07], 0.01, [X(b - 0.04), 0.98, zi - 0.035])\n      k.rbox(M.housing, [0.06, 0.1, 0.06], 0.01, [X(a + 0.02), 0.98, zi - 0.03])\n    } else lever(zi, -1)\n  } else {\n    // passive leaf: flush bolts on the meeting edge\n    for (const y of [0.15, h - 0.15]) k.box('stainless', [0.02, 0.18, 0.004], [X(lw - 0.02), y, zi - 0.002])\n  }\n  // overhead closer body on the push face near the hinge\n  k.rbox(M.housing, [0.28, 0.065, 0.055], 0.012, [X(0.25), h - 0.08, zi - 0.0275])\n  k.box(M.alu, [0.012, 0.012, 0.08], [X(0.38), h - 0.05, zi - 0.04], eul(0, dir * 0.4, 0))\n  // astragal on the active leaf of a pair\n  if (o.kind === 'double' && o.active) k.box(o.mat, [0.04, h - 0.02, 0.006], [X(lw + 0.012), h / 2, 0.003])\n  return k.build()\n}\n\n/** Roller-shutter curtain of interlocking slats between the guides, rolled up by `open`. */\nfunction curtainBuilt(W: number, H: number, open: number, zf: number): Built {\n  const k = new Kit()\n  const yb = Math.min(H - 0.02, open * H)\n  const h = H + 0.04 - yb\n  const g = new THREE.PlaneGeometry(W + 0.06, h)\n  const uv = g.getAttribute('uv') as THREE.BufferAttribute\n  for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i), uv.getY(i) * (h / 0.075))\n  k.add(slatMaterial(), g, new THREE.Matrix4().makeTranslation(0, yb + h / 2, zf + 0.055))\n  k.add(slatMaterial(), g.clone(), new THREE.Matrix4().makeRotationY(Math.PI).setPosition(0, yb + h / 2, zf + 0.045))\n  k.box('aluminium', [W + 0.06, 0.06, 0.04], [0, yb + 0.03, zf + 0.05])\n  k.box('rubberBlack', [W + 0.04, 0.012, 0.025], [0, yb + 0.006, zf + 0.05])\n  return k.build()\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": "Door",
    "anchor": "base",
    "standards": [
      "IMO FTP Code (A60 / H120)",
      "ISO 7010 E001",
      "EN 1125 (panic exit)",
      "EN 1154 (closers)"
    ]
  }
}