{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cctv-camera",
  "type": "registry:component",
  "title": "Ex d CCTV camera (fixed / PTZ)",
  "description": "Hazardous-area CCTV camera in electropolished 316L: flameproof barrel with bolted window and rear flanges, glazed window, sunshield on standoffs, wiper, rear gland and armoured cable, Ex marking plate and tag. `kind: ptz` adds the pan drive, U-yoke and tilt drive; `pan` and `tilt` (degrees) turn the head, and also aim the fixed camera on its knuckle bracket. Mounted on a galvanised pole with a gusseted base plate and anchor bolts, cable cleats and an Ex e junction box, or on a wall bracket (wall plane z = 0).",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "interiors/cctv-camera.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/interiors/cctv-camera.tsx",
      "content": "// Ex d hazardous-area CCTV camera, fixed or PTZ, 316L stainless with sunshield and wiper, on a pole or wall bracket with an Ex e junction box. IEC 60079-1, ATEX / IECEx.\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport { Kit, type Built } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, type GroupProps } from '../lib/mesh'\nimport { junctionBox, tagPlate } from '../lib/parts'\nimport { labelMaterial } from './_textures'\n\nexport interface CctvCameraProps {\n  kind?: 'fixed' | 'ptz'\n  mount?: 'pole' | 'wall'\n  /** camera base above the deck, m (pole height, or bracket height on the wall plane z = 0) */\n  height?: number\n  /** pan about the vertical, degrees (0 looks along +Z) */\n  pan?: number\n  /** tilt, degrees (negative looks down) */\n  tilt?: number\n  wiper?: boolean\n  junctionBox?: boolean\n  tag?: string\n}\n\nexport const defaults = {\n  kind: 'ptz' as 'fixed' | 'ptz',\n  mount: 'pole' as 'pole' | 'wall',\n  height: 3.5,\n  pan: 25,\n  tilt: -12,\n  wiper: true,\n  junctionBox: true,\n  tag: 'CCTV-01',\n}\n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n\nexport function CctvCamera(props: CctvCameraProps & GroupProps) {\n  const { kind, mount, height, pan, tilt, wiper, junctionBox: jb, tag, ...group } = { ...defaults, ...props }\n  const b = useMemo(() => build({ kind, mount, height, wiper, junctionBox: jb, tag }), [kind, mount, height, wiper, jb, tag])\n  useEffect(() => () => [b.base, b.yoke, b.head].forEach((x) => x.geometry.dispose()), [b])\n  return (\n    <group {...group}>\n      <KitMesh data={b.base} />\n      <group position={b.panAt} rotation={[0, (pan * Math.PI) / 180, 0]}>\n        <KitMesh data={b.yoke} />\n        <group position={b.tiltAt} rotation={[(-clamp(tilt, -90, 40) * Math.PI) / 180, 0, 0]}>\n          <KitMesh data={b.head} />\n        </group>\n      </group>\n    </group>\n  )\n}\n\nexport default CctvCamera\n\nconst steel = () => paint('#c6cacd', { metalness: 0.88, roughness: 0.22, clearcoat: 0, weather: 0.3 }) // 316L electropolished\nconst shield = () => paint('#d8dbdd', { metalness: 0.8, roughness: 0.3, clearcoat: 0, weather: 0.3, side: THREE.DoubleSide })\n\n/** Camera head along +Z, optical axis on the origin: flameproof barrel, bolted window flange, sunshield, wiper, rear gland. */\nfunction head(R: number, L: number, wiper: boolean, tag: string): Built {\n  const k = new Kit()\n  const S = steel()\n  const z0 = -L * 0.45\n  const z1 = L * 0.55\n  k.cyl(S, R, L - 0.06, [0, 0, (z0 + z1) / 2], 'z', 32)\n  // front and rear flameproof flanges with socket-head bolts\n  for (const [z, s] of [[z1 - 0.02, 1], [z0 + 0.015, -1]] as const) {\n    k.cyl(S, R + 0.014, 0.03, [0, 0, z], 'z', 32)\n    k.around(8, R + 0.006, [0, 0, z + s * 0.016], 'z', (p) => k.cyl('nut', 0.005, 0.006, p, 'z', 8), Math.PI / 8)\n  }\n  k.disc('rubberBlack', R * 0.8, [0, 0, z1 - 0.004], 'z', 28)\n  k.disc('glass', R * 0.78, [0, 0, z1 - 0.003], 'z', 28)\n  k.disc(paint('#0d1012', { metalness: 0.2, roughness: 0.1, weather: 0 }), R * 0.35, [0, 0, z1 - 0.012], 'z', 24)\n  // rear cable gland and armoured cable\n  k.cyl('brass', 0.016, 0.05, [0, -R * 0.4, z0 - 0.02], 'z', 12)\n  k.spline('cable', [[0, -R * 0.4, z0 - 0.045], [0, -R * 0.6, z0 - 0.11], [0, -R - 0.08, z0 - 0.12]], 0.008, 6, 16)\n  // sunshield on standoffs\n  const sh = new THREE.CylinderGeometry(R + 0.04, R + 0.04, L + 0.08, 24, 1, true, -Math.PI * 0.42, Math.PI * 0.84)\n  k.add(shield(), sh, new THREE.Matrix4().makeRotationX(-Math.PI / 2).premultiply(new THREE.Matrix4().makeTranslation(0, 0, (z0 + z1) / 2 + 0.03)))\n  for (const z of [z0 + 0.08, z1 - 0.08]) k.box(S, [0.02, 0.04, 0.02], [0, R + 0.02, z])\n  // wiper motor bump and blade\n  if (wiper) {\n    k.rbox(S, [0.05, 0.035, 0.05], 0.01, [0, R + 0.012, z1 - 0.03])\n    k.box('rubberBlack', [0.008, R * 1.5, 0.008], [R * 0.15, R * 0.15, z1 + 0.006], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), 0.5))\n  }\n  // Ex marking and tag\n  k.plane(labelMaterial('Ex db IIC T6 Gb', '#c8ccd0', '#111111', 512, 96), 0.1, 0.019, [R + 0.0005, 0, (z0 + z1) / 2 - 0.04], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2))\n  tagPlate(k, tag, [-R - 0.001, 0, (z0 + z1) / 2 - 0.03], '-x', 0.08)\n  return k.build()\n}\n\nfunction build(p: { kind: 'fixed' | 'ptz'; mount: 'pole' | 'wall'; height: number; wiper: boolean; junctionBox: boolean; tag: string }) {\n  const base = new Kit()\n  const yoke = new Kit()\n  const S = steel()\n  const galv = 'galvanised' as const\n  const H = clamp(p.height, 1.5, 12)\n  const ptz = p.kind === 'ptz'\n  const R = ptz ? 0.07 : 0.062\n  const L = ptz ? 0.36 : 0.42\n  // mounting: pole with base plate and anchors, or a wall plate with a projecting arm\n  let top: [number, number, number]\n  if (p.mount === 'pole') {\n    base.box(galv, [0.3, 0.02, 0.3], [0, 0.01, 0])\n    for (const sx of [-1, 1]) for (const sz of [-1, 1]) {\n      base.cyl(galv, 0.01, 0.06, [sx * 0.11, 0.03, sz * 0.11], 'y', 8)\n      base.hex('nut', 0.03, 0.015, [sx * 0.11, 0.028, sz * 0.11])\n    }\n    for (let i = 0; i < 4; i++) {\n      const a = (i * Math.PI) / 2 + Math.PI / 4\n      base.box(galv, [0.008, 0.12, 0.06], [Math.cos(a) * 0.08, 0.08, Math.sin(a) * 0.08], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -a))\n    }\n    base.cyl(galv, 0.057, H - 0.03, [0, 0.02 + (H - 0.03) / 2, 0], 'y', 24)\n    base.cyl(galv, 0.1, 0.012, [0, H - 0.004, 0], 'y', 24)\n    top = [0, H + 0.002, 0]\n    if (p.junctionBox) {\n      junctionBox(base, [0, 1.25, 0.06], 'z', [0.2, 0.25, 0.12], S)\n      for (const y of [1.0, 1.15]) base.box(S, [0.14, 0.03, 0.01], [0, y + 0.2, 0.06])\n      base.spline('cable', [[0.0, 1.25 + 0.145, 0.13], [0.0, 1.5, 0.08], [0.0, 2.0, 0.065], [0.0, H - 0.1, 0.065]], 0.008, 6, 30)\n      for (let y = 1.7; y < H - 0.1; y += 0.5) base.torus(S, 0.062, 0.004, [0, y, 0], 'y', 6, 24)\n    }\n  } else {\n    // wall plane at z = 0\n    base.box(S, [0.2, 0.26, 0.012], [0, H - 0.05, 0.006])\n    base.around(4, 0.1, [0, H - 0.05, 0.014], 'z', (q) => base.hex('nut', 0.016, 0.008, q, 'z'), Math.PI / 4)\n    base.box(S, [0.06, 0.06, 0.36], [0, H + 0.02, 0.19])\n    base.add(S, new THREE.ExtrudeGeometry(new THREE.Shape([new THREE.Vector2(0, 0), new THREE.Vector2(0.3, 0), new THREE.Vector2(0, -0.2)]), { depth: 0.008, bevelEnabled: false }), new THREE.Matrix4().makeRotationY(-Math.PI / 2).setPosition(-0.004, H - 0.01, 0.012))\n    base.box(S, [0.14, 0.012, 0.14], [0, H + 0.056, 0.32])\n    top = [0, H + 0.062, 0.32]\n    if (p.junctionBox) {\n      junctionBox(base, [0, H - 0.6, 0], 'z', [0.2, 0.25, 0.12], S)\n      base.spline('cable', [[0, H - 0.6 + 0.145, 0.06], [0, H - 0.3, 0.05], [0, H - 0.05, 0.08], [0, H + 0.0, 0.2], [0, H + 0.06, 0.3]], 0.008, 6, 24)\n    }\n  }\n  base.add(S, new THREE.CylinderGeometry(0.06, 0.07, 0.03, 24), new THREE.Matrix4().makeTranslation(top[0], top[1] + 0.015, top[2]))\n  const panAt: [number, number, number] = [top[0], top[1] + 0.03, top[2]]\n  let tiltAt: [number, number, number]\n  if (ptz) {\n    // pan drive housing, U-yoke, tilt drive on the right arm\n    yoke.cyl(S, 0.085, 0.12, [0, 0.06, 0], 'y', 32)\n    yoke.cyl(S, 0.09, 0.012, [0, 0.126, 0], 'y', 32)\n    const ay = 0.13 + R + 0.06\n    for (const s of [-1, 1]) yoke.rbox(S, [0.035, ay + 0.03, 0.09], 0.012, [s * (R + 0.04), 0.13 + (ay + 0.03) / 2 - 0.015, 0])\n    yoke.box(S, [2 * (R + 0.04) + 0.035, 0.02, 0.09], [0, 0.14, 0])\n    yoke.cyl(S, 0.045, 0.06, [R + 0.09, ay, 0], 'x', 24)\n    yoke.around(6, 0.035, [R + 0.121, ay, 0], 'x', (q) => yoke.cyl('nut', 0.004, 0.004, q, 'x', 6))\n    tiltAt = [0, ay, 0]\n  } else {\n    // fixed: adjustable knuckle bracket\n    yoke.cyl(S, 0.03, 0.08, [0, 0.04, 0], 'y', 16)\n    yoke.box(S, [0.06, 0.03, 0.12], [0, 0.09, 0])\n    tiltAt = [0, 0.1 + R + 0.01, 0]\n    yoke.box(S, [0.02, R + 0.03, 0.08], [0.04, 0.09 + (R + 0.03) / 2, 0])\n  }\n  const h = head(R, L, p.wiper, p.tag)\n  return { base: base.build(), yoke: yoke.build(), head: h, panAt, tiltAt }\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": "CctvCamera",
    "anchor": "base",
    "standards": [
      "IEC 60079-0 / -1 (Ex d)",
      "ATEX 2014/34/EU",
      "IECEx",
      "IEC 60529 (IP66/67)"
    ]
  }
}