{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "beam-smoke-detector",
  "type": "registry:component",
  "title": "Reflective beam smoke detector",
  "description": "Reflective optical beam smoke detector for large open volumes such as warehouses, turbine halls and module roofs. Transceiver head (lens, alignment LEDs, pan/tilt yoke with adjuster knobs) at one end, cube-corner prism reflector at the other (one prism up to 50 m, a 2 × 2 array beyond), facing each other along X across separation. The head is cabled to a low-level controller with signal display and status LEDs. On walls or stanchions; beam draws the IR path.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "safety/beam-smoke-detector.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/safety/beam-smoke-detector.tsx",
      "content": "// Reflective optical beam smoke detector: transceiver head with lens, alignment LEDs and pan/tilt bracket, low-level controller, and a cube-corner prism reflector across the protected space; walls or stanchions facing along X. EN 54-12, UL 268.\nimport * as THREE from 'three'\nimport { Kit, mat4, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { LED_GREEN, LENS, cable, canvasMat, faceZ, mounted, polePlate, wallPlate, type Mount } from './_fg'\nimport { LED_AMBER } from './_detectors'\n\nexport interface BeamSmokeDetectorProps {\n  mount?: Mount\n  /** head to reflector, m (one prism to 50 m, four beyond) */\n  separation?: number\n  /** beam height above the floor, m */\n  height?: number\n  /** draw the IR beam for coverage studies */\n  beam?: boolean\n  tag?: string\n}\n\nexport const defaults = { mount: 'wall' as Mount, separation: 30, height: 4, beam: false, tag: 'SDB-9201' }\n\nconst CASE = paint('#ecebe6', { metalness: 0.05, roughness: 0.45 })\nconst BEAM = paint('#ff4a6a', { emissive: '#ff2a4a', emissiveIntensity: 0.8, transparent: true, opacity: 0.3, roughness: 0.5, weather: 0 })\nconst ZH = 0.11 // bracket face to head centre, m\nconst HB = 0.134 // head body cube, m (typical)\nconst CY = 1.5 // controller height, m\n\nexport function BeamSmokeDetector(props: BeamSmokeDetectorProps & GroupProps) {\n  const { mount, separation, height, beam, tag, ...group } = { ...defaults, ...props }\n  const sep = Math.max(5, Math.min(100, separation))\n  const h = Math.max(2.2, height)\n  const data = useKit((k) => build(k, mount, sep, h, tag), [mount, sep, h, tag])\n  const ray = useKit((k) => {\n    const L = sep - 2 * faceZ(mount) - ZH - HB / 2 - 0.03\n    if (beam) k.cyl(BEAM, 0.01, L, [-sep / 2 + faceZ(mount) + ZH + HB / 2 + L / 2, h, 0], 'x', 10)\n  }, [mount, sep, h, beam])\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n      <KitMesh data={ray} shadows={false} />\n    </group>\n  )\n}\n\nexport default BeamSmokeDetector\n\nfunction prismMat() {\n  return canvasMat('beam-prism', 256, 256, (g) => {\n    g.fillStyle = '#c9ced3'\n    g.fillRect(0, 0, 256, 256)\n    // cube-corner facets\n    const r = 16\n    for (let row = 0; row < 10; row++)\n      for (let col = 0; col < 10; col++) {\n        const cx = col * r * 1.732 + (row % 2) * r * 0.866\n        const cy = row * r * 1.5\n        for (let i = 0; i < 3; i++) {\n          g.fillStyle = ['#f2f4f6', '#a7aeb5', '#cfd4d9'][i]\n          g.beginPath()\n          g.moveTo(cx, cy)\n          for (let j = 0; j < 3; j++) {\n            const a = ((i * 2 + j) * Math.PI) / 3 - Math.PI / 2\n            g.lineTo(cx + Math.cos(a) * r, cy + Math.sin(a) * r)\n          }\n          g.closePath()\n          g.fill()\n        }\n      }\n  }, { roughness: 0.15 })\n}\n\nfunction controllerMat() {\n  return canvasMat('beam-ctrl', 256, 256, (g) => {\n    g.fillStyle = '#ecebe6'\n    g.fillRect(0, 0, 256, 256)\n    g.fillStyle = '#2b2f33'\n    g.fillRect(28, 28, 200, 80)\n    g.fillStyle = '#7dff9a'\n    g.font = 'bold 30px \"Courier New\", monospace'\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    g.fillText('SIG 100%', 128, 68)\n    g.fillStyle = '#2b2f33'\n    g.font = 'bold 22px Arial, sans-serif'\n    g.fillText('BEAM DETECTOR', 128, 140)\n    for (const [i, c] of ['#ff3b30', '#ffae1a', '#2bff6e'].entries()) {\n      g.fillStyle = c\n      g.beginPath()\n      g.arc(70 + i * 58, 190, 12, 0, Math.PI * 2)\n      g.fill()\n    }\n    g.fillStyle = '#2b2f33'\n    g.font = '16px Arial, sans-serif'\n    ;['FIRE', 'FAULT', 'OK'].forEach((t, i) => g.fillText(t, 70 + i * 58, 222))\n  }, { emissive: 0.15 })\n}\n\n/** Transceiver head on a pan/tilt bracket, +Z towards the reflector, axis at y = 0; origin on the bracket face. */\nfunction head(k: Kit) {\n  k.box('stainless', [0.07, 0.1, 0.006], [0, -0.01, 0.003])\n  for (const sy of [-1, 1]) k.hex('nut', 0.014, 0.006, [0, -0.01 + sy * 0.038, 0.009], 'z')\n  k.box('stainless', [0.04, 0.012, ZH - HB / 2], [0, -HB / 2 - 0.006, (ZH - HB / 2) / 2 + 0.006])\n  k.box('stainless', [0.04, HB / 2 - 0.02, 0.008], [0, -HB / 2 - 0.006 + (HB / 2 - 0.02) / 2, 0.01])\n  k.cyl('stainless', 0.022, 0.012, [0, -HB / 2 - 0.006, ZH], 'y', 20)\n  // pan/tilt yoke with adjuster knobs\n  for (const s of [-1, 1]) {\n    k.box('stainless', [0.006, 0.06, 0.05], [s * (HB / 2 + 0.006), -0.035, ZH])\n    k.cyl('plasticGrey', 0.012, 0.016, [s * (HB / 2 + 0.017), 0, ZH], 'x', 16)\n  }\n  k.box('stainless', [HB + 0.018, 0.006, 0.05], [0, -HB / 2 - 0.003, ZH])\n  // body, front bezel, lens, alignment LEDs, rear gland\n  k.rbox(CASE, [HB, HB * 0.98, HB], 0.018, [0, 0, ZH])\n  const zF = ZH + HB / 2\n  k.rbox('plasticGrey', [HB - 0.01, HB - 0.012, 0.012], 0.008, [0, 0, zF + 0.002])\n  k.disc(LENS, 0.042, [0, 0.008, zF + 0.0085], 'z', 32)\n  k.torus('plasticGrey', 0.043, 0.003, [0, 0.008, zF + 0.0085], 'z', 6, 32)\n  k.disc(LENS, 0.012, [0.042, -0.042, zF + 0.0085], 'z', 16)\n  k.sphere(LED_GREEN, 0.004, [-0.045, -0.048, zF + 0.008], undefined, 8)\n  k.sphere(LED_AMBER, 0.004, [-0.032, -0.048, zF + 0.008], undefined, 8)\n  k.hex('brass', 0.026, 0.014, [0.03, -HB / 2 - 0.004, ZH - 0.03], 'y')\n}\n\n/** Prism reflector plate (1 or 2 × 2 prisms) facing +Z on the bracket face. */\nfunction reflector(k: Kit, n: number) {\n  const s = 0.1 // 100 × 100 mm prism, typical\n  const W = n * s + 0.02\n  k.box('stainless', [W, W, 0.006], [0, 0, 0.003])\n  for (const sx of [-1, 1]) for (const sy of [-1, 1]) k.hex('nut', 0.012, 0.005, [sx * (W / 2 - 0.008), sy * (W / 2 - 0.008), 0.0085], 'z')\n  const mat = prismMat()\n  for (let i = 0; i < n; i++)\n    for (let j = 0; j < n; j++) {\n      const x = (i - (n - 1) / 2) * s\n      const y = (j - (n - 1) / 2) * s\n      k.box('plasticGrey', [s - 0.004, s - 0.004, 0.01], [x, y, 0.011])\n      k.add(mat, new THREE.PlaneGeometry(s - 0.012, s - 0.012), mat4([x, y, 0.0162]))\n    }\n}\n\nfunction build(k: Kit, mount: Mount, sep: number, h: number, tag: string) {\n  const n = sep > 50 ? 2 : 1\n  for (const side of [-1, 1] as const) {\n    const m = new THREE.Matrix4().makeTranslation(side * (sep / 2), 0, 0).multiply(new THREE.Matrix4().makeRotationY(-side * (Math.PI / 2)))\n    k.with(m, () => {\n      if (side < 0) {\n        const face = mounted(k, mount, h, [0.12, 0.32], () => head(k))\n        tagPlate(k, tag, [0, h + 0.1, face + 0.001], 'z', 0.1)\n        // low-level controller with display and test key, cabled to the head\n        const cy = Math.min(CY, h - 0.6)\n        const cz = mount === 'pole' ? polePlate(k, cy, 0.2, 0.24) : wallPlate(k, cy, 0.2, 0.24)\n        k.rbox(CASE, [0.18, 0.18, 0.065], 0.01, [0, cy, cz + 0.0325])\n        k.add(controllerMat(), new THREE.PlaneGeometry(0.16, 0.16), mat4([0, cy, cz + 0.0655]))\n        k.cyl('stainless', 0.006, 0.008, [0.06, cy - 0.07, cz + 0.069], 'z', 10)\n        tagPlate(k, `${tag} CTRL`, [0, cy - 0.11, cz + 0.001], 'z', 0.09)\n        const xc = mount === 'pole' ? -0.045 : 0.03\n        const gl: V3 = [0.03, h - HB / 2 - 0.02, face + ZH - 0.03]\n        cable(k, [gl, [0.03, h - HB / 2 - 0.06, face + ZH - 0.03], [xc, h - 0.25, mount === 'pole' ? 0 : 0.016], [xc, cy + 0.2, mount === 'pole' ? 0 : 0.016], [0.05, cy + 0.09, cz + 0.03]], 0.005)\n        cable(k, [[-0.05, cy - 0.09, cz + 0.03], [-0.05, cy - 0.2, cz + 0.02], [xc, cy - 0.35, mount === 'pole' ? 0 : 0.016], [xc, 0.03, mount === 'pole' ? 0 : 0.016]], 0.005)\n        for (const s of [-1, 1]) k.hex('brass', 0.022, 0.012, [s * 0.05, cy - 0.096, cz + 0.03], 'y')\n      } else {\n        const W = n * 0.1 + 0.02\n        const face = mounted(k, mount, h, [W + 0.04, W + 0.16], () => reflector(k, n))\n        tagPlate(k, `${tag} REF`, [0, h - W / 2 - 0.025, face + 0.001], 'z', 0.08)\n      }\n    })\n  }\n}\n"
    },
    {
      "path": "safety/_fg.ts",
      "type": "registry:file",
      "target": "components/deck-twin/safety/_fg.ts",
      "content": "// Shared fire & gas hardware: galvanised pole stanchion, pole and wall brackets, field cable, LEDs, lenses and canvas faces.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { paint, weather } from '../lib/materials'\nimport { pipeOD } from '../lib/standards'\n\nexport type Mount = 'pole' | 'wall'\n\nexport const LENS: MatKey = paint('#15202b', { metalness: 0.3, roughness: 0.06 })\nexport const WHITE: MatKey = paint('#eceae4', { metalness: 0.05, roughness: 0.55 })\nexport const LED_GREEN: MatKey = paint('#2bff6e', { emissive: '#2bff6e', emissiveIntensity: 0.9, roughness: 0.3 })\nexport const LED_RED: MatKey = paint('#ff3b30', { emissive: '#ff3b30', emissiveIntensity: 0.9, roughness: 0.3 })\n\n/** 2\" galvanised stanchion pipe */\nexport const POLE_R = pipeOD(2) / 2\nconst PLATE_T = 0.006\n\nconst deg = (d: number) => (d * Math.PI) / 180\nexport { deg }\n\n/** Free-standing stanchion: base plate with four anchor bolts and gussets, 2\" pole to `top`, welded cap. Pole on the origin. */\nexport function poleStanchion(k: Kit, top: number, mat: MatKey = 'galvanised') {\n  const B = 0.26\n  k.box(mat, [B, 0.016, B], [0, 0.008, 0])\n  for (const sx of [-1, 1])\n    for (const sz of [-1, 1]) {\n      const p: V3 = [sx * (B / 2 - 0.035), 0, sz * (B / 2 - 0.035)]\n      k.cyl('galvanised', 0.008, 0.05, [p[0], 0.035, p[2]], 'y', 8)\n      k.hex('nut', 0.03, 0.014, [p[0], 0.023, p[2]], 'y')\n      k.cyl('galvanised', 0.017, 0.003, [p[0], 0.0175, p[2]], 'y', 12)\n    }\n  k.cyl(mat, POLE_R, top - 0.016, [0, 0.016 + (top - 0.016) / 2, 0], 'y', 20)\n  k.cyl(mat, POLE_R + 0.004, 0.008, [0, top + 0.004, 0], 'y', 20)\n  const g = new THREE.Shape()\n  g.moveTo(0, 0).lineTo(0.09, 0).lineTo(0, 0.16).closePath()\n  for (let i = 0; i < 4; i++) {\n    const a = (i * Math.PI) / 2 + Math.PI / 4\n    const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), a)\n    const m = new THREE.Matrix4().compose(new THREE.Vector3(0, 0.016, 0).add(new THREE.Vector3(POLE_R, 0, -0.004).applyQuaternion(q)), q, new THREE.Vector3(1, 1, 1))\n    k.extrude(mat, g, 0.008, m)\n  }\n}\n\n/** Plate clamped to the front (+Z) of the pole by two U-bolts. Returns the z of the plate front face. */\nexport function polePlate(k: Kit, y: number, w: number, h: number, mat: MatKey = 'stainless') {\n  const z0 = POLE_R + 0.004\n  k.box(mat, [w, h, PLATE_T], [0, y, z0 + PLATE_T / 2])\n  for (const s of [-1, 1]) {\n    const yy = y + s * (h / 2 - 0.025)\n    k.torus('galvanised', POLE_R + 0.006, 0.005, [0, yy, 0], 'y', 6, 20, Math.PI)\n    k.rod('galvanised', [-(POLE_R + 0.006), yy, 0], [-(POLE_R + 0.006), yy, z0 + PLATE_T + 0.012], 0.005, 6)\n    k.rod('galvanised', [POLE_R + 0.006, yy, 0], [POLE_R + 0.006, yy, z0 + PLATE_T + 0.012], 0.005, 6)\n    for (const sx of [-1, 1]) k.hex('nut', 0.017, 0.008, [sx * (POLE_R + 0.006), yy, z0 + PLATE_T + 0.005], 'z')\n  }\n  // the U-bolt arcs sit behind the pole (torus half facing -Z)\n  return z0 + PLATE_T\n}\n\n/** Stainless wall plate in the XY plane (back face on z = 0), four fixings. Returns the front face z. */\nexport function wallPlate(k: Kit, y: number, w: number, h: number) {\n  k.box('stainless', [w, h, 0.008], [0, y, 0.004])\n  for (const sx of [-1, 1]) for (const sy of [-1, 1]) k.hex('nut', 0.016, 0.008, [sx * (w / 2 - 0.014), y + sy * (h / 2 - 0.014), 0.012], 'z')\n  return 0.008\n}\n\n/** z of the bracket face that mounted() builds on. */\nexport const faceZ = (mount: Mount) => (mount === 'pole' ? POLE_R + 0.004 + PLATE_T : 0.008)\n\n/** Builds the stanchion (pole) or wall plate, then runs `fn` with the origin on the bracket face at `y`, +Z out of the bracket. */\nexport function mounted(k: Kit, mount: Mount, y: number, plate: [number, number], fn: (zFace: number) => void, poleTop = y + plate[1] / 2 + 0.05) {\n  let zf: number\n  if (mount === 'pole') {\n    poleStanchion(k, poleTop)\n    zf = polePlate(k, y, plate[0], plate[1])\n  } else zf = wallPlate(k, y, plate[0], plate[1])\n  k.at([0, y, zf], undefined, () => fn(zf))\n  return zf\n}\n\n/** Armoured field cable (SWA, black sheath). */\nexport function cable(k: Kit, pts: V3[], r = 0.0065) {\n  k.spline('cable', pts, r, 6, Math.max(16, pts.length * 8))\n}\n\n/** Cable from `from` down the left side of the pole (or the wall) to the deck, cleated every ~0.5 m. */\nexport function cableToDeck(k: Kit, mount: Mount, from: V3, r = 0.0065) {\n  const x = mount === 'pole' ? -(POLE_R + r + 0.001) : from[0]\n  const z = mount === 'pole' ? 0 : r + 0.009\n  const yTop = Math.min(from[1] - 0.08, from[1])\n  cable(k, [from, [from[0], from[1] - 0.05, from[2]], [(from[0] + x) / 2, yTop - 0.05, (from[2] + z) / 2], [x, yTop - 0.14, z], [x, 0.2, z]], r)\n  k.tube('cable', [[x, 0.2, z], [x, 0.03, z], mount === 'pole' ? [x - 0.2, 0.03, z] : [x, 0.03, z + 0.2]], r, 6, 0.06)\n  for (let y = 0.4; y < yTop - 0.2; y += 0.5) {\n    if (mount === 'pole') k.torus('stainless', POLE_R + 0.002, 0.003, [0, y, 0], 'y', 4, 18)\n    else k.box('stainless', [0.03, 0.012, 0.004], [x, y, 2 * r + 0.011])\n  }\n}\n\n// ------------------------------------------------------------------ canvas faces (created lazily, browser only)\n\nconst texCache = new Map<string, THREE.MeshStandardMaterial>()\n\nexport function canvasMat(key: string, w: number, h: number, draw: (g: CanvasRenderingContext2D) => void, opts: { emissive?: number; roughness?: number } = {}) {\n  let m = texCache.get(key)\n  if (m) return m\n  const c = document.createElement('canvas')\n  c.width = w\n  c.height = h\n  draw(c.getContext('2d')!)\n  const tex = new THREE.CanvasTexture(c)\n  tex.colorSpace = THREE.SRGBColorSpace\n  tex.anisotropy = 4\n  m = new THREE.MeshStandardMaterial({ map: tex, metalness: 0.1, roughness: opts.roughness ?? 0.45 })\n  if (opts.emissive) {\n    m.emissive = new THREE.Color('#ffffff')\n    m.emissiveMap = tex\n    m.emissiveIntensity = opts.emissive\n  }\n  m.name = key\n  weather(m, 0.3)\n  texCache.set(key, m)\n  return m\n}\n\nfunction fit(g: CanvasRenderingContext2D, text: string, max: number, size: number, weight = 'bold') {\n  g.font = `${weight} ${size}px Arial, sans-serif`\n  while (g.measureText(text).width > max && size > 10) g.font = `${weight} ${(size -= 2)}px Arial, sans-serif`\n}\n\n/** ISO 7010-style sign: coloured field, white border, optional pictogram square on the left, one or two text lines. */\nexport function signMat(lines: string[], bg: string, fg = '#ffffff', icon?: (g: CanvasRenderingContext2D, s: number) => void) {\n  return canvasMat(`fgsign-${lines.join('|')}-${bg}-${fg}-${icon ? 1 : 0}`, 512, 256, (g) => {\n    g.fillStyle = bg\n    g.fillRect(0, 0, 512, 256)\n    g.strokeStyle = fg\n    g.lineWidth = 8\n    g.strokeRect(10, 10, 492, 236)\n    let x0 = 256\n    let w = 460\n    if (icon) {\n      g.save()\n      g.translate(28, 28)\n      icon(g, 200)\n      g.restore()\n      x0 = 370\n      w = 250\n    }\n    g.fillStyle = fg\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    const n = lines.length\n    lines.forEach((t, i) => {\n      fit(g, t, w, n > 2 ? 46 : 60)\n      g.fillText(t, x0, 128 + (i - (n - 1) / 2) * (n > 2 ? 58 : 72))\n    })\n  })\n}\n\n/** Black/yellow hazard-bordered warning plate. */\nexport function warnMat(lines: string[]) {\n  return canvasMat(`fgwarn-${lines.join('|')}`, 512, 256, (g) => {\n    g.fillStyle = '#f2c318'\n    g.fillRect(0, 0, 512, 256)\n    g.save()\n    g.beginPath()\n    g.rect(0, 0, 512, 256)\n    g.rect(22, 22, 468, 212)\n    g.clip('evenodd')\n    g.fillStyle = '#141414'\n    for (let x = -256; x < 768; x += 44) {\n      g.beginPath()\n      g.moveTo(x, 0)\n      g.lineTo(x + 22, 0)\n      g.lineTo(x - 234, 256)\n      g.lineTo(x - 256, 256)\n      g.closePath()\n      g.fill()\n    }\n    g.restore()\n    g.fillStyle = '#141414'\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    lines.forEach((t, i) => {\n      fit(g, t, 440, i === 0 ? 70 : 44)\n      g.fillText(t, 256, lines.length === 1 ? 128 : i === 0 ? 100 : 176)\n    })\n  })\n}\n\n/** White pictogram helpers drawn into an s × s square. */\nexport const ICON = {\n  flame(g: CanvasRenderingContext2D, s: number) {\n    g.fillStyle = '#ffffff'\n    g.beginPath()\n    g.moveTo(s * 0.5, s * 0.08)\n    g.bezierCurveTo(s * 0.85, s * 0.4, s * 0.82, s * 0.7, s * 0.5, s * 0.9)\n    g.bezierCurveTo(s * 0.18, s * 0.7, s * 0.2, s * 0.45, s * 0.38, s * 0.3)\n    g.bezierCurveTo(s * 0.4, s * 0.45, s * 0.48, s * 0.5, s * 0.52, s * 0.42)\n    g.closePath()\n    g.fill()\n  },\n  shower(g: CanvasRenderingContext2D, s: number) {\n    g.fillStyle = '#ffffff'\n    g.strokeStyle = '#ffffff'\n    g.lineWidth = s * 0.04\n    g.beginPath()\n    g.arc(s * 0.62, s * 0.18, s * 0.12, Math.PI, 0)\n    g.fill()\n    for (let i = 0; i < 5; i++) {\n      g.beginPath()\n      g.moveTo(s * (0.52 + i * 0.05), s * 0.24)\n      g.lineTo(s * (0.48 + i * 0.05), s * 0.42)\n      g.stroke()\n    }\n    g.beginPath()\n    g.arc(s * 0.4, s * 0.38, s * 0.07, 0, Math.PI * 2)\n    g.fill()\n    g.fillRect(s * 0.34, s * 0.47, s * 0.12, s * 0.28)\n    g.fillRect(s * 0.34, s * 0.75, s * 0.05, s * 0.18)\n    g.fillRect(s * 0.41, s * 0.75, s * 0.05, s * 0.18)\n  },\n  extinguisher(g: CanvasRenderingContext2D, s: number) {\n    g.fillStyle = '#ffffff'\n    g.fillRect(s * 0.36, s * 0.3, s * 0.28, s * 0.6)\n    g.beginPath()\n    g.arc(s * 0.5, s * 0.3, s * 0.14, Math.PI, 0)\n    g.fill()\n    g.fillRect(s * 0.45, s * 0.1, s * 0.1, s * 0.08)\n    g.lineWidth = s * 0.04\n    g.strokeStyle = '#ffffff'\n    g.beginPath()\n    g.moveTo(s * 0.55, s * 0.14)\n    g.quadraticCurveTo(s * 0.85, s * 0.18, s * 0.8, s * 0.5)\n    g.stroke()\n  },\n  hose(g: CanvasRenderingContext2D, s: number) {\n    g.strokeStyle = '#ffffff'\n    g.lineWidth = s * 0.06\n    for (const r of [0.14, 0.24, 0.34]) {\n      g.beginPath()\n      g.arc(s * 0.45, s * 0.5, s * r, 0, Math.PI * 2)\n      g.stroke()\n    }\n    g.beginPath()\n    g.moveTo(s * 0.79, s * 0.5)\n    g.lineTo(s * 0.92, s * 0.85)\n    g.stroke()\n  },\n}\n\n/** Ex d stopping (blanking) plug in an unused cable entry, axis out of the entry. */\nexport function stopPlug(k: Kit, pos: V3, axis: 'x' | '-x' | 'y' | '-y' | 'z' | '-z', d = 0.026) {\n  k.hex('castIron', d, 0.006, pos, axis)\n  const v = { x: [1, 0, 0], '-x': [-1, 0, 0], y: [0, 1, 0], '-y': [0, -1, 0], z: [0, 0, 1], '-z': [0, 0, -1] }[axis]\n  k.cyl('castIron', d * 0.36, 0.006, [pos[0] + v[0] * 0.006, pos[1] + v[1] * 0.006, pos[2] + v[2] * 0.006], axis, 12)\n}\n\n/**\n * Flameproof (Ex d) cast junction box: body with a wide flamepath flange, bolted lid, earth stud and bottom entries.\n * Back face at `pos`, lid facing +Z. `used` entries get brass glands, the rest stopping plugs. Returns the entry centres (bottom face).\n */\nexport function exdBox(k: Kit, pos: V3, size: V3 = [0.18, 0.18, 0.12], entries = 2, used = 1, mat: MatKey = 'castIron'): V3[] {\n  const [w, h, d] = size\n  const [x, y, z] = pos\n  k.rbox(mat, [w, h, d * 0.62], 0.01, [x, y, z + d * 0.31])\n  k.rbox(mat, [w + 0.024, h + 0.024, d * 0.12], 0.006, [x, y, z + d * 0.68])\n  k.rbox(mat, [w, h, d * 0.26], 0.012, [x, y, z + d * 0.87])\n  // cover bolts round the flamepath flange\n  const nx = Math.max(2, Math.round(w / 0.05))\n  const ny = Math.max(2, Math.round(h / 0.05))\n  for (let i = 0; i <= nx; i++)\n    for (const s of [-1, 1]) k.cyl('stainless', 0.006, 0.006, [x - w / 2 + (w * i) / nx, y + s * (h / 2 + 0.006), z + d * 0.74 + 0.003], 'z', 8)\n  for (let i = 1; i < ny; i++)\n    for (const s of [-1, 1]) k.cyl('stainless', 0.006, 0.006, [x + s * (w / 2 + 0.006), y - h / 2 + (h * i) / ny, z + d * 0.74 + 0.003], 'z', 8)\n  // mounting lugs, earth stud\n  for (const s of [-1, 1]) {\n    k.box(mat, [0.03, 0.03, 0.012], [x + s * (w / 2 + 0.012), y + s * (h / 2 - 0.02), z + 0.006])\n    k.hex('nut', 0.014, 0.007, [x + s * (w / 2 + 0.014), y + s * (h / 2 - 0.02), z + 0.0155], 'z')\n  }\n  k.cyl('brass', 0.004, 0.025, [x + w / 2 + 0.012, y - h * 0.25, z + d * 0.35], 'x', 8)\n  k.hex('brass', 0.012, 0.006, [x + w / 2 + 0.018, y - h * 0.25, z + d * 0.35], 'x')\n  const out: V3[] = []\n  for (let i = 0; i < entries; i++) {\n    const ex = x + (entries === 1 ? 0 : -w * 0.3 + (w * 0.6 * i) / (entries - 1))\n    const p: V3 = [ex, y - h / 2, z + d * 0.31]\n    k.cyl(mat, 0.016, 0.008, [p[0], p[1] - 0.004, p[2]], 'y', 14)\n    if (i < used) {\n      k.hex('brass', 0.03, 0.014, [p[0], p[1] - 0.015, p[2]], 'y')\n      k.cyl('brass', 0.012, 0.02, [p[0], p[1] - 0.032, p[2]], 'y', 12)\n      k.cyl('rubberBlack', 0.0085, 0.012, [p[0], p[1] - 0.046, p[2]], 'y', 10)\n    } else stopPlug(k, [p[0], p[1] - 0.011, p[2]], '-y')\n    out.push([p[0], p[1] - 0.05, p[2]])\n  }\n  return out\n}\n"
    },
    {
      "path": "safety/_detectors.ts",
      "type": "registry:file",
      "target": "components/deck-twin/safety/_detectors.ts",
      "content": "// Shared detector and emergency-equipment hardware: ceiling drop mount, open-front GRP shells, hollow horns, ISO 7010-style pictograms and sign faces.\nimport * as THREE from 'three'\nimport { Kit, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { SAFETY } from '../lib/standards'\nimport { tagPlate } from '../lib/parts'\nimport { canvasMat, polePlate, wallPlate, type Mount } from './_fg'\n\nexport const GRP_RED: MatKey = paint('#b3221b', { metalness: 0.05, roughness: 0.42, clearcoat: 0.5 })\nexport const GRP_GREEN: MatKey = paint('#1f7a4c', { metalness: 0.05, roughness: 0.45, clearcoat: 0.4 })\nexport const GRP_ORANGE: MatKey = paint('#e8641c', { metalness: 0.05, roughness: 0.45, clearcoat: 0.4 })\nexport const GRP_INNER: MatKey = paint('#d8d6cf', { metalness: 0, roughness: 0.8, weather: 0 })\nexport const SEAL: MatKey = paint('#202224', { metalness: 0, roughness: 0.9, weather: 0 })\nexport const LAMP_WHITE: MatKey = paint('#fffaf0', { emissive: '#fff4dc', emissiveIntensity: 1.6, roughness: 0.25, metalness: 0, weather: 0 })\nexport const LAMP_OFF: MatKey = paint('#e9e6dc', { roughness: 0.3, metalness: 0, weather: 0 })\nexport const LED_AMBER: MatKey = paint('#ffae1a', { emissive: '#ffae1a', emissiveIntensity: 0.9, roughness: 0.3, weather: 0 })\n\n/** Beam clamp, drop rod and fixing plate under steelwork; origin on the steel underside, returns the plate underside y (≤ 0). */\nexport function ceilingMount(k: Kit, drop: number, plate: [number, number] = [0.16, 0.16]) {\n  if (drop <= 0.02) {\n    k.box('stainless', [plate[0], 0.008, plate[1]], [0, -0.004, 0])\n    for (const sx of [-1, 1]) for (const sz of [-1, 1]) k.hex('nut', 0.016, 0.007, [sx * (plate[0] / 2 - 0.014), -0.0115, sz * (plate[1] / 2 - 0.014)], 'y')\n    return -0.008\n  }\n  k.box('galvanised', [0.12, 0.012, 0.08], [0, -0.006, 0])\n  for (const s of [-1, 1]) k.hex('nut', 0.022, 0.012, [s * 0.04, -0.018, 0], 'y')\n  k.cyl('galvanised', 0.008, drop, [0, -drop / 2, 0], 'y', 10)\n  k.hex('nut', 0.022, 0.012, [0, -drop + 0.014, 0], 'y')\n  k.box('galvanised', [plate[0], 0.008, plate[1]], [0, -drop - 0.004, 0])\n  k.hex('nut', 0.022, 0.012, [0, -drop - 0.014, 0], 'y')\n  return -drop - 0.008\n}\n\n/** Small stainless tag bracket on the stanchion (U-bolted) or wall at height y, offset x along the wall. */\nexport function tagBracket(k: Kit, mount: Mount, y: number, tag: string, x = 0) {\n  if (mount === 'pole') {\n    const z = polePlate(k, y, 0.13, 0.11)\n    tagPlate(k, tag, [0, y, z + 0.001], 'z', 0.1)\n  } else\n    k.at([x, 0, 0], undefined, () => {\n      const z = wallPlate(k, y, 0.13, 0.11)\n      tagPlate(k, tag, [0, y, z + 0.001], 'z', 0.1)\n    })\n}\n\n/** Open-front box shell (five walls, wall thickness t): back on z = 0, opening at z = d, x ∈ ±w/2, y from y0 to y0 + h. */\nexport function shell(k: Kit, mat: MatKey, w: number, h: number, d: number, t: number, y0 = 0, inner: MatKey = GRP_INNER) {\n  const yc = y0 + h / 2\n  k.box(mat, [w, h, t], [0, yc, t / 2])\n  for (const s of [-1, 1]) k.box(mat, [t, h, d - t], [s * (w / 2 - t / 2), yc, t + (d - t) / 2])\n  for (const y of [y0 + t / 2, y0 + h - t / 2]) k.box(mat, [w - 2 * t, t, d - t], [0, y, t + (d - t) / 2])\n  // light gel-coat lining on the inside faces\n  k.plane(inner, w - 2 * t, h - 2 * t, [0, yc, t + 0.0006])\n  for (const s of [-1, 1]) k.plane(inner, d - t, h - 2 * t, [s * (w / 2 - t - 0.0006), yc, t + (d - t) / 2], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), (-s * Math.PI) / 2))\n  k.plane(inner, w - 2 * t, d - t, [0, y0 + t + 0.0006, t + (d - t) / 2], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2))\n  k.plane(inner, w - 2 * t, d - t, [0, y0 + h - t - 0.0006, t + (d - t) / 2], new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2))\n}\n\n/** Hollow flared horn of wall t along +axis from pos: throat radius r0, mouth radius r1, length L, exponential-ish flare, rolled lip. */\nexport function horn(k: Kit, mat: MatKey, pos: V3, axis: 'x' | 'y' | 'z' | '-x' | '-y' | '-z' | V3, r0: number, r1: number, L: number, t = 0.003, seg = 40) {\n  const n = 10\n  const outer: [number, number][] = []\n  const inner: [number, number][] = []\n  for (let i = 0; i <= n; i++) {\n    const u = i / n\n    const r = r0 + (r1 - r0) * Math.pow(u, 2.2)\n    outer.push([r + t, u * L])\n    inner.push([r, u * L])\n  }\n  // outside up to the mouth, round the rolled lip, back down the inside to the throat\n  k.lathe(mat, [...outer, [r1 + t * 1.8, L + t * 0.5], [r1 + t * 0.6, L + t * 1.4], [r1 - t * 0.2, L], ...inner.reverse()], pos, axis, seg)\n}\n\n// ------------------------------------------------------------------ pictograms (white on the sign colour, drawn into an s × s square)\n\ntype Draw = (g: CanvasRenderingContext2D, s: number) => void\n\nfunction limb(g: CanvasRenderingContext2D, pts: [number, number][], s: number, w: number, flip: boolean) {\n  g.beginPath()\n  pts.forEach(([x, y], i) => (i ? g.lineTo((flip ? 1 - x : x) * s, y * s) : g.moveTo((flip ? 1 - x : x) * s, y * s)))\n  g.lineWidth = w * s\n  g.lineCap = 'round'\n  g.lineJoin = 'round'\n  g.stroke()\n}\n\n/** Running figure (escape pictogram) heading +x, or -x when flip. */\nexport function runningMan(g: CanvasRenderingContext2D, s: number, flip = false) {\n  g.fillStyle = '#ffffff'\n  g.strokeStyle = '#ffffff'\n  g.beginPath()\n  g.arc((flip ? 0.38 : 0.62) * s, 0.13 * s, 0.085 * s, 0, Math.PI * 2)\n  g.fill()\n  limb(g, [[0.56, 0.28], [0.43, 0.54]], s, 0.15, flip)\n  limb(g, [[0.55, 0.31], [0.7, 0.42], [0.84, 0.33]], s, 0.085, flip)\n  limb(g, [[0.53, 0.31], [0.37, 0.37], [0.25, 0.48]], s, 0.085, flip)\n  limb(g, [[0.44, 0.55], [0.63, 0.68], [0.6, 0.9], [0.72, 0.9]], s, 0.1, flip)\n  limb(g, [[0.42, 0.56], [0.32, 0.72], [0.12, 0.8]], s, 0.1, flip)\n}\n\n/** Emergency exit pictogram: door frame on the running side with the figure heading into it (ISO 7010 E001 left / E002 right). */\nexport function exitPictogram(g: CanvasRenderingContext2D, s: number, toLeft: boolean) {\n  g.save()\n  g.fillStyle = '#ffffff'\n  const dx = toLeft ? 0.04 * s : 0.6 * s\n  g.fillRect(dx, 0.04 * s, 0.36 * s, 0.92 * s)\n  g.fillStyle = SAFETY.green\n  g.fillRect(dx + 0.05 * s, 0.09 * s, 0.26 * s, 0.87 * s)\n  g.restore()\n  g.save()\n  g.translate(toLeft ? 0.28 * s : 0.04 * s, 0.16 * s)\n  runningMan(g, s * 0.68, toLeft)\n  g.restore()\n}\n\n/** Direction arrow pointing to `dir` (supplementary sign). */\nexport function arrow(g: CanvasRenderingContext2D, s: number, dir: 'left' | 'right' | 'up' | 'down') {\n  g.save()\n  g.translate(s / 2, s / 2)\n  g.rotate({ right: 0, down: Math.PI / 2, left: Math.PI, up: -Math.PI / 2 }[dir])\n  g.fillStyle = '#ffffff'\n  g.beginPath()\n  g.moveTo(0.42 * s, 0)\n  g.lineTo(0.02 * s, -0.36 * s)\n  g.lineTo(0.02 * s, -0.14 * s)\n  g.lineTo(-0.4 * s, -0.14 * s)\n  g.lineTo(-0.4 * s, 0.14 * s)\n  g.lineTo(0.02 * s, 0.14 * s)\n  g.lineTo(0.02 * s, 0.36 * s)\n  g.closePath()\n  g.fill()\n  g.restore()\n}\n\nfunction person(g: CanvasRenderingContext2D, x: number, y: number, h: number) {\n  g.beginPath()\n  g.arc(x, y + h * 0.12, h * 0.12, 0, Math.PI * 2)\n  g.fill()\n  g.beginPath()\n  g.roundRect(x - h * 0.16, y + h * 0.28, h * 0.32, h * 0.42, h * 0.08)\n  g.fill()\n  g.fillRect(x - h * 0.13, y + h * 0.6, h * 0.11, h * 0.4)\n  g.fillRect(x + h * 0.02, y + h * 0.6, h * 0.11, h * 0.4)\n}\n\n/** Assembly point pictogram: group of four with arrows converging from the corners (ISO 7010 E007). */\nexport const assemblyPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  person(g, s * 0.43, s * 0.29, s * 0.3)\n  person(g, s * 0.57, s * 0.29, s * 0.3)\n  person(g, s * 0.33, s * 0.42, s * 0.3)\n  person(g, s * 0.67, s * 0.42, s * 0.3)\n  for (let i = 0; i < 4; i++) {\n    g.save()\n    g.translate(s / 2, s / 2)\n    g.rotate(Math.PI / 4 + (i * Math.PI) / 2)\n    g.beginPath()\n    g.moveTo(s * 0.3, 0)\n    g.lineTo(s * 0.4, -s * 0.07)\n    g.lineTo(s * 0.4, -s * 0.03)\n    g.lineTo(s * 0.5, -s * 0.03)\n    g.lineTo(s * 0.5, s * 0.03)\n    g.lineTo(s * 0.4, s * 0.03)\n    g.lineTo(s * 0.4, s * 0.07)\n    g.closePath()\n    g.fill()\n    g.restore()\n  }\n}\n\n/** First-aid cross (ISO 7010 E003). */\nexport const crossPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.fillRect(s * 0.36, s * 0.1, s * 0.28, s * 0.8)\n  g.fillRect(s * 0.1, s * 0.36, s * 0.8, s * 0.28)\n}\n\n/** Telephone handset with a small cross (ISO 7010 E004 style). */\nexport const phonePictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.strokeStyle = '#ffffff'\n  g.lineCap = 'round'\n  g.lineWidth = s * 0.13\n  g.beginPath()\n  g.arc(s * 0.42, s * 0.6, s * 0.3, Math.PI * 0.95, Math.PI * 1.85)\n  g.stroke()\n  for (const a of [Math.PI * 0.95, Math.PI * 1.85]) {\n    g.beginPath()\n    g.ellipse(s * 0.42 + Math.cos(a) * s * 0.3, s * 0.6 + Math.sin(a) * s * 0.3, s * 0.12, s * 0.07, a + Math.PI / 2, 0, Math.PI * 2)\n    g.fill()\n  }\n  g.fillRect(s * 0.66, s * 0.62, s * 0.08, s * 0.26)\n  g.fillRect(s * 0.57, s * 0.71, s * 0.26, s * 0.08)\n}\n\n/** Stretcher pictogram (side view). */\nexport const stretcherPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.fillRect(s * 0.08, s * 0.5, s * 0.84, s * 0.08)\n  for (const x of [0.2, 0.8]) g.fillRect(s * (x - 0.03), s * 0.58, s * 0.06, s * 0.16)\n  g.beginPath()\n  g.arc(s * 0.22, s * 0.4, s * 0.08, 0, Math.PI * 2)\n  g.fill()\n  g.beginPath()\n  g.roundRect(s * 0.32, s * 0.36, s * 0.52, s * 0.12, s * 0.05)\n  g.fill()\n}\n\n/** Lifejacket pictogram (front view of an inflatable/foam vest). */\nexport const lifejacketPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.beginPath()\n  g.moveTo(s * 0.3, s * 0.12)\n  g.quadraticCurveTo(s * 0.5, s * 0.34, s * 0.7, s * 0.12)\n  g.lineTo(s * 0.84, s * 0.2)\n  g.lineTo(s * 0.8, s * 0.88)\n  g.lineTo(s * 0.55, s * 0.88)\n  g.lineTo(s * 0.55, s * 0.42)\n  g.lineTo(s * 0.45, s * 0.42)\n  g.lineTo(s * 0.45, s * 0.88)\n  g.lineTo(s * 0.2, s * 0.88)\n  g.lineTo(s * 0.16, s * 0.2)\n  g.closePath()\n  g.fill()\n  g.fillStyle = '#1f7a4c'\n  for (const y of [0.56, 0.7]) g.fillRect(s * 0.2, s * y, s * 0.6, s * 0.03)\n}\n\n/** Fire blanket pictogram: blanket hanging from its pouch over a flame. */\nexport const blanketPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.fillRect(s * 0.2, s * 0.08, s * 0.6, s * 0.16)\n  g.beginPath()\n  g.moveTo(s * 0.26, s * 0.24)\n  g.lineTo(s * 0.74, s * 0.24)\n  g.lineTo(s * 0.8, s * 0.62)\n  g.lineTo(s * 0.2, s * 0.62)\n  g.closePath()\n  g.fill()\n  g.beginPath()\n  g.moveTo(s * 0.5, s * 0.66)\n  g.bezierCurveTo(s * 0.7, s * 0.76, s * 0.66, s * 0.92, s * 0.5, s * 0.94)\n  g.bezierCurveTo(s * 0.34, s * 0.92, s * 0.32, s * 0.78, s * 0.44, s * 0.7)\n  g.closePath()\n  g.fill()\n}\n\n/** Respirator pictogram (mandatory sign: head with a twin-filter respirator over nose and mouth). */\nexport const respiratorPictogram: Draw = (g, s) => {\n  g.fillStyle = '#ffffff'\n  g.beginPath()\n  g.ellipse(s * 0.5, s * 0.4, s * 0.22, s * 0.28, 0, 0, Math.PI * 2)\n  g.fill()\n  g.fillRect(s * 0.4, s * 0.62, s * 0.2, s * 0.2)\n  g.beginPath()\n  g.moveTo(s * 0.18, s * 0.98)\n  g.quadraticCurveTo(s * 0.5, s * 0.72, s * 0.82, s * 0.98)\n  g.fill()\n  g.fillStyle = SAFETY.blue\n  for (const x of [0.42, 0.58]) {\n    g.beginPath()\n    g.ellipse(s * x, s * 0.33, s * 0.035, s * 0.02, 0, 0, Math.PI * 2)\n    g.fill()\n  }\n  // half mask with two filter cartridges and head strap\n  g.beginPath()\n  g.moveTo(s * 0.5, s * 0.36)\n  g.quadraticCurveTo(s * 0.68, s * 0.42, s * 0.62, s * 0.6)\n  g.quadraticCurveTo(s * 0.5, s * 0.66, s * 0.38, s * 0.6)\n  g.quadraticCurveTo(s * 0.32, s * 0.42, s * 0.5, s * 0.36)\n  g.fill()\n  g.fillRect(s * 0.27, s * 0.44, s * 0.46, s * 0.025)\n  for (const x of [0.32, 0.68]) {\n    g.beginPath()\n    g.arc(s * x, s * 0.56, s * 0.085, 0, Math.PI * 2)\n    g.fill()\n    g.fillStyle = '#ffffff'\n    g.beginPath()\n    g.arc(s * x, s * 0.56, s * 0.04, 0, Math.PI * 2)\n    g.fill()\n    g.fillStyle = SAFETY.blue\n  }\n}\n\n/** Safety sign face: field colour, white border, pictogram square on the left (round for mandatory blue), one to three text lines. */\nexport function safetySign(key: string, bg: string, lines: string[], icon?: Draw, round = false, w = 512, h = 256, emissive = 0) {\n  return canvasMat(`det-sign-${key}-${bg}-${lines.join('|')}-${w}x${h}-${emissive}`, w, h, (g) => {\n    g.fillStyle = round ? '#ffffff' : bg\n    g.fillRect(0, 0, w, h)\n    const ic = icon ? h - 40 : 0\n    if (icon) {\n      g.save()\n      g.translate(20, 20)\n      if (round) {\n        g.fillStyle = bg\n        g.beginPath()\n        g.arc(ic / 2, ic / 2, ic / 2, 0, Math.PI * 2)\n        g.fill()\n        g.translate(ic * 0.12, ic * 0.12)\n        icon(g, ic * 0.76)\n      } else icon(g, ic)\n      g.restore()\n    }\n    g.strokeStyle = round ? bg : '#ffffff'\n    g.lineWidth = 8\n    g.strokeRect(6, 6, w - 12, h - 12)\n    if (!lines.length) return\n    const x0 = icon ? ic + 40 : 0\n    const tw = w - x0 - 30\n    g.fillStyle = round ? '#111111' : '#ffffff'\n    g.textAlign = 'center'\n    g.textBaseline = 'middle'\n    const n = lines.length\n    const fs = Math.min(64, (h - 50) / n / 1.15)\n    lines.forEach((t, i) => {\n      let size = fs\n      g.font = `bold ${size}px Arial, sans-serif`\n      while (g.measureText(t).width > tw && size > 10) g.font = `bold ${(size -= 2)}px Arial, sans-serif`\n      g.fillText(t, x0 + (w - x0) / 2, h / 2 + (i - (n - 1) / 2) * fs * 1.15)\n    })\n  }, emissive ? { emissive } : {})\n}\n\nexport interface CabinetOpts {\n  w: number\n  h: number\n  d: number\n  mat: MatKey\n  /** door swing 0 closed … 1 fully open (110°) */\n  open: number\n  /** cabinet bottom above the origin, m */\n  y0: number\n  /** draws on the door's outer face: origin at the door centre, +Z out of the face */\n  face?: (k: Kit, w: number, h: number) => void\n  /** draws the contents: origin at the inside back-bottom-centre */\n  inside?: (k: Kit, w: number, h: number, d: number) => void\n}\n\n/** Hollow GRP cabinet with gel-coat lining, hinged door (left-hand piano hinge, toggle latches, seal), back on z = 0. */\nexport function cabinet(k: Kit, o: CabinetOpts) {\n  const t = 0.006\n  const { w, h, d, mat, y0 } = o\n  shell(k, mat, w, h, d, t, y0)\n  // moulded front flange, door seal\n  for (const s of [-1, 1]) {\n    k.box(mat, [0.03, h, 0.012], [s * (w / 2 - 0.015), y0 + h / 2, d - 0.006])\n    k.box(mat, [w, 0.03, 0.012], [0, y0 + (s > 0 ? h - 0.015 : 0.015), d - 0.006])\n  }\n  k.box(SEAL, [w - 0.05, 0.008, 0.004], [0, y0 + h - 0.034, d + 0.002])\n  k.box(SEAL, [w - 0.05, 0.008, 0.004], [0, y0 + 0.034, d + 0.002])\n  o.inside?.(k, w, h, d)\n  // door: hinge on the left edge, swings out\n  const a = (Math.max(0, Math.min(1, o.open)) * 110 * Math.PI) / 180\n  const m = new THREE.Matrix4().makeTranslation(-w / 2, y0 + h / 2, d + 0.004).multiply(new THREE.Matrix4().makeRotationY(-a))\n  k.with(m, () => {\n    const dt = 0.022\n    k.rbox(mat, [w, h, dt], 0.008, [w / 2, 0, dt / 2])\n    k.box(GRP_INNER, [w - 0.06, h - 0.06, 0.002], [w / 2, 0, -0.001])\n    // piano hinge, toggle latches with keeper, pull handle\n    k.cyl('stainless', 0.006, h - 0.04, [-0.004, 0, dt / 2], 'y', 10)\n    for (const s of [-1, 1]) {\n      const y = s * h * 0.3\n      k.box('stainless', [0.03, 0.05, 0.012], [w - 0.03, y, dt + 0.006])\n      k.box('stainless', [0.012, 0.03, 0.02], [w - 0.012, y, dt + 0.01])\n    }\n    k.tube('stainless', [[w - 0.07, -0.08, dt], [w - 0.07, -0.08, dt + 0.035], [w - 0.07, 0.08, dt + 0.035], [w - 0.07, 0.08, dt]], 0.007, 8, 0.02)\n    k.at([w / 2, 0, dt], undefined, () => o.face?.(k, w, h))\n  })\n}\n"
    }
  ],
  "categories": [
    "safety"
  ],
  "meta": {
    "export": "BeamSmokeDetector",
    "anchor": "base",
    "standards": [
      "EN 54-12",
      "UL 268",
      "BS 5839-1"
    ]
  }
}