{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "emergency-telephone",
  "type": "registry:component",
  "title": "Emergency telephone",
  "description": "Hazardous-area emergency telephone: Ex d cast body with bolted cover, handset on its hook with perforated ear and mouth caps, armoured stainless cord, keypad, red emergency call button, ringer grille, gland and stopping plug. enclosure hood puts it in a hollow GRP acoustic hood (tapered sides, foam lining, open front and bottom; about 790 × 740 × 560 mm) on a stanchion or wall; booth puts it in a floor-standing open-front acoustic booth with roof light; none mounts the telephone alone. Red call beacon flashes when ringing; ISO 7010 E004 sign.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "registryDependencies": [
    "https://deck-twin-library.demoareeb.workers.dev/r/core.json"
  ],
  "files": [
    {
      "path": "safety/emergency-telephone.tsx",
      "type": "registry:file",
      "target": "components/deck-twin/safety/emergency-telephone.tsx",
      "content": "// Emergency telephone: Ex d industrial telephone (handset on hook, armoured cord, keypad, emergency button) in a GRP acoustic hood on a stanchion or wall, or a floor-standing acoustic booth, with a call beacon and ISO 7010 E004 sign. IEC 60079-1, ISO 7010, EN 54-23.\nimport { useFrame } from '@react-three/fiber'\nimport { useEffect, useMemo } from 'react'\nimport * as THREE from 'three'\nimport { Kit, mat4, type MatKey, type V3 } from '../lib/kit'\nimport { paint } from '../lib/materials'\nimport { KitMesh, useKit, type GroupProps } from '../lib/mesh'\nimport { tagPlate } from '../lib/parts'\nimport { POLE_R, cable, canvasMat, poleStanchion, polePlate, stopPlug, wallPlate } from './_fg'\nimport { GRP_INNER, phonePictogram, safetySign, shell } from './_detectors'\n\nexport type HoodColour = 'yellow' | 'red' | 'orange'\n\nexport interface EmergencyTelephoneProps {\n  /** hood: acoustic hood on a stanchion or wall; booth: walk-in acoustic booth; none: telephone only */\n  enclosure?: 'hood' | 'booth' | 'none'\n  /** hood and telephone-only: stanchion or wall */\n  mount?: 'pole' | 'wall'\n  colour?: HoodColour\n  /** handset centre above the deck, m */\n  height?: number\n  beacon?: boolean\n  /** beacon flashing (incoming call) */\n  ringing?: boolean\n  sign?: boolean\n  tag?: string\n}\n\nexport const defaults = { enclosure: 'hood' as 'hood' | 'booth' | 'none', mount: 'pole' as 'pole' | 'wall', colour: 'yellow' as HoodColour, height: 1.5, beacon: true, ringing: true, sign: true, tag: 'TEL-0201' }\n\nconst HOOD: Record<HoodColour, string> = { yellow: '#e5b114', red: '#b3221b', orange: '#e8641c' }\nconst BODY: MatKey = paint('#5d6670', { metalness: 0.35, roughness: 0.45, clearcoat: 0.3 })\nconst FOAM: MatKey = paint('#3a3d40', { metalness: 0, roughness: 1, weather: 0 })\nconst KEY: MatKey = paint('#d8d8d2', { metalness: 0.1, roughness: 0.5 })\n// hood overall (typical GRP acoustic hood): 0.79 W × 0.74 H × 0.56 D\nconst HW = 0.79\nconst HH = 0.74\nconst HD = 0.56\n\nexport function EmergencyTelephone(props: EmergencyTelephoneProps & GroupProps) {\n  const { enclosure, mount, colour, height, beacon, ringing, sign, tag, ...group } = { ...defaults, ...props }\n  const h = Math.max(1, height)\n  const data = useKit((k) => build(k, enclosure, mount, colour, h, beacon, sign, tag), [enclosure, mount, colour, h, beacon, sign, tag])\n  const lensMat = useMemo(() => new THREE.MeshStandardMaterial({ color: '#ff3020', emissive: '#ff2a1a', emissiveIntensity: 0, roughness: 0.15, transparent: true, opacity: 0.88 }), [])\n  useEffect(() => () => lensMat.dispose(), [lensMat])\n  const lens = useKit((k) => {\n    if (!beacon) return\n    const p = beaconAt(enclosure, mount, h)\n    k.cyl(lensMat, 0.04, 0.08, [p[0], p[1] + 0.04, p[2]], 'y', 24)\n    k.dome(lensMat, 0.04, [p[0], p[1] + 0.08, p[2]], 'y', Math.PI / 2, 0.5, 20)\n  }, [lensMat, enclosure, mount, h, beacon])\n  useFrame(({ clock }) => {\n    lensMat.emissiveIntensity = ringing ? ((clock.elapsedTime * 1.5) % 1 < 0.25 ? 2.2 : 0.1) : 0.05\n  })\n  return (\n    <group {...group}>\n      <KitMesh data={data} />\n      <KitMesh data={lens} shadows={false} />\n    </group>\n  )\n}\n\nexport default EmergencyTelephone\n\n/** Lens base position for each enclosure. */\nfunction beaconAt(enclosure: 'hood' | 'booth' | 'none', mount: 'pole' | 'wall', h: number): V3 {\n  const zf = mount === 'pole' ? POLE_R + 0.01 : 0.008\n  if (enclosure === 'booth') return [0, 2.32 + 0.07, 0.45]\n  if (enclosure === 'hood') return [0.26, h + 0.32 + 0.07 + 0.01, zf + 0.3]\n  return [0, h + 0.27 + 0.07, zf + 0.06]\n}\n\nfunction keyMat() {\n  return canvasMat('tel-keys', 192, 256, (g) => {\n    g.fillStyle = '#2a2f35'\n    g.fillRect(0, 0, 192, 256)\n    const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']\n    keys.forEach((t, i) => {\n      const x = 22 + (i % 3) * 56\n      const y = 18 + Math.floor(i / 3) * 60\n      g.fillStyle = '#d8d8d2'\n      g.beginPath()\n      g.roundRect(x, y, 44, 46, 8)\n      g.fill()\n      g.fillStyle = '#1a1d20'\n      g.font = 'bold 28px Arial, sans-serif'\n      g.textAlign = 'center'\n      g.textBaseline = 'middle'\n      g.fillText(t, x + 22, y + 24)\n    })\n  })\n}\n\n/** Ex d telephone, back on z = 0, handset centre at the origin. Returns the gland tail. */\nfunction telephone(k: Kit): V3 {\n  const W = 0.28\n  const H = 0.36\n  const D = 0.12\n  const yc = -0.05\n  k.rbox(BODY, [W, H, D * 0.75], 0.014, [0, yc, D * 0.375])\n  k.rbox(BODY, [W + 0.016, H + 0.016, 0.014], 0.006, [0, yc, D * 0.75 + 0.007])\n  k.rbox(BODY, [W - 0.01, H - 0.01, D * 0.2], 0.012, [0, yc, D * 0.86])\n  for (const sx of [-1, 1]) for (const sy of [-1, 0, 1]) k.cyl('stainless', 0.006, 0.006, [sx * (W / 2 + 0.002), yc + sy * (H / 2 - 0.02), D * 0.76 + 0.009], 'z', 8)\n  const zF = D * 0.96\n  // handset cradle (hook) on the upper face, handset with ear and mouth cups\n  k.box(BODY, [0.07, 0.24, 0.03], [-0.07, 0.04, zF + 0.015])\n  k.box('stainless', [0.012, 0.02, 0.02], [-0.07, 0.165, zF + 0.025])\n  const zh = zF + 0.05\n  k.rbox('rubberBlack', [0.05, 0.2, 0.032], 0.012, [-0.07, 0.04, zh])\n  for (const s of [-1, 1]) {\n    k.cyl('rubberBlack', 0.033, 0.03, [-0.07, 0.04 + s * 0.1, zh - 0.004], 'z', 24)\n    k.disc('castIron', 0.024, [-0.07, 0.04 + s * 0.1, zh + 0.0115], 'z', 20)\n    for (let i = 0; i < 6; i++) {\n      const a = (i / 6) * Math.PI * 2\n      k.disc('pipeBore', 0.003, [-0.07 + Math.cos(a) * 0.012, 0.04 + s * 0.1 + Math.sin(a) * 0.012, zh + 0.0118], 'z', 6)\n    }\n  }\n  // armoured stainless cord looping down to its gland\n  const pts: V3[] = []\n  for (let i = 0; i <= 40; i++) {\n    const t = i / 40\n    pts.push([-0.12 - Math.sin(t * Math.PI) * 0.03 + Math.cos(t * 60) * 0.006, -0.06 - t * 0.12, zh - 0.01 + Math.sin(t * 60) * 0.006 - t * 0.02])\n  }\n  k.spline('tubing', [[-0.07, -0.06, zh - 0.01], ...pts, [-0.11, -0.19, zF - 0.01]], 0.0035, 6, 160)\n  k.hex('stainless', 0.02, 0.012, [-0.11, -0.2, zF - 0.01], 'z')\n  // keypad, emergency button, volume, ringer grille\n  k.add(keyMat(), new THREE.PlaneGeometry(0.09, 0.12), mat4([0.06, 0.03, zF + 0.0005]))\n  k.cyl('actuatorRed', 0.02, 0.012, [0.06, -0.08, zF + 0.006], 'z', 24)\n  k.torus(KEY, 0.022, 0.003, [0.06, -0.08, zF + 0.002], 'z', 6, 24)\n  for (let i = 0; i < 5; i++) k.box('pipeBore', [0.08, 0.004, 0.002], [0.06, 0.13 - i * 0.009 + 0.0, zF + 0.0005])\n  k.box('nameplate', [0.08, 0.025, 0.002], [0.06, -0.16, zF + 0.001])\n  // cable entries under\n  k.hex('brass', 0.03, 0.016, [0.07, yc - H / 2 - 0.008, D * 0.4], 'y')\n  stopPlug(k, [-0.07, yc - H / 2 - 0.004, D * 0.4], '-y')\n  return [0.07, yc - H / 2 - 0.03, D * 0.4]\n}\n\n/** Beacon base, guard and cap at p (lens is a separate mesh). */\nfunction beaconBase(k: Kit, p: V3) {\n  const [x, y, z] = p\n  k.cyl('actuatorRed', 0.05, 0.06, [x, y + 0.03 - 0.07, z], 'y', 24)\n  k.cyl('actuatorRed', 0.054, 0.012, [x, y + 0.066 - 0.07, z], 'y', 24)\n  for (let i = 0; i < 4; i++) {\n    const a = Math.PI / 4 + (i * Math.PI) / 2\n    k.rod('stainless', [x + Math.cos(a) * 0.046, y, z + Math.sin(a) * 0.046], [x + Math.cos(a) * 0.046, y + 0.11, z + Math.sin(a) * 0.046], 0.003, 6)\n  }\n  k.torus('stainless', 0.046, 0.003, [x, y + 0.11, z], 'y', 6, 24)\n  k.cyl('stainless', 0.01, 0.01, [x, y + 0.115, z], 'y', 10)\n}\n\n/** GRP acoustic hood, back on z = 0, bottom edge at y = 0: back, tapered sides, top, front lip, foam lining; open front and bottom. */\nfunction hood(k: Kit, mat: MatKey) {\n  const t = 0.006\n  const d0 = 0.32\n  k.box(mat, [HW, HH, t], [0, HH / 2, t / 2])\n  k.box(mat, [HW, t, HD], [0, HH - t / 2, HD / 2])\n  k.box(mat, [HW, 0.06, t], [0, HH - 0.03, HD - t / 2])\n  const sh = new THREE.Shape().moveTo(0, 0).lineTo(d0, 0).lineTo(HD, HH * 0.62).lineTo(HD, HH).lineTo(0, HH).closePath()\n  for (const s of [-1, 1]) {\n    const m = new THREE.Matrix4().makeBasis(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0), new THREE.Vector3(-1, 0, 0)).setPosition(s * (HW / 2) + (s < 0 ? t : 0), 0, 0)\n    k.extrude(mat, sh, t, m)\n    // rolled front edge on the side panels\n    k.rod(mat, [s * (HW / 2 - t / 2), 0, d0], [s * (HW / 2 - t / 2), HH * 0.62, HD], 0.008, 8)\n  }\n  // foam lining on the back and top\n  k.box(FOAM, [HW - 0.04, HH - 0.06, 0.03], [0, HH / 2 - 0.01, t + 0.015])\n  k.box(FOAM, [HW - 0.04, 0.03, HD - 0.08], [0, HH - t - 0.015, (HD - 0.08) / 2 + t + 0.03])\n}\n\nfunction build(k: Kit, enclosure: 'hood' | 'booth' | 'none', mount: 'pole' | 'wall', colour: HoodColour, h: number, beacon: boolean, sign: boolean, tag: string) {\n  const grp = paint(HOOD[colour] ?? HOOD.yellow, { metalness: 0.05, roughness: 0.42, clearcoat: 0.45 })\n  const signMat = () => safetySign('E004', '#237f52', ['EMERGENCY', 'TELEPHONE'], phonePictogram)\n  if (enclosure === 'booth') {\n    const W = 1.0\n    const H = 2.3\n    const D = 0.9\n    // plinth, booth shell open at the front, roof overhang, inner light, telephone on the back wall\n    k.box('galvanised', [W + 0.04, 0.08, D + 0.04], [0, 0.04, D / 2])\n    k.at([0, 0.08, 0], undefined, () => shell(k, grp, W, H - 0.08, D, 0.012, 0, GRP_INNER))\n    k.box(grp, [W + 0.1, 0.05, D + 0.12], [0, H + 0.025, D / 2 + 0.02])\n    k.box(FOAM, [W - 0.06, 1.0, 0.03], [0, 1.6, 0.027])\n    k.box('lampLens', [0.3, 0.02, 0.08], [0, H - 0.03, 0.4])\n    let g: V3 = [0, 0, 0]\n    k.at([0, h, 0.042], undefined, () => (g = telephone(k)))\n    cable(k, [[g[0], g[1] + h, g[2] + 0.042], [g[0], 0.4, 0.06], [g[0], 0.1, 0.03]], 0.0065)\n    if (beacon) beaconBase(k, beaconAt('booth', mount, h))\n    // fascia over the opening carrying the sign and tag\n    k.box(grp, [W - 0.024, 0.36, 0.012], [0, H - 0.18, D - 0.006])\n    if (sign) {\n      k.box('stainless', [0.56, 0.28, 0.004], [-0.08, H - 0.18, D + 0.002])\n      k.plane(signMat(), 0.55, 0.27, [-0.08, H - 0.18, D + 0.0045])\n    }\n    tagPlate(k, tag, [0.36, H - 0.18, D + 0.001], 'z', 0.1)\n    return\n  }\n  // stanchion or wall plate; hood bottom 0.42 m below the handset\n  const yb = h - 0.42\n  const hooded = enclosure === 'hood'\n  let zf: number\n  if (mount === 'pole') {\n    poleStanchion(k, hooded ? yb + HH - 0.02 : h + 0.32)\n    zf = hooded ? polePlate(k, yb + HH / 2, 0.3, HH - 0.06) : polePlate(k, h + 0.02, 0.3, 0.6)\n  } else zf = hooded ? wallPlate(k, yb + HH / 2, 0.36, HH - 0.06) : wallPlate(k, h + 0.02, 0.36, 0.6)\n  const zt = enclosure === 'hood' ? zf + 0.04 : zf\n  if (enclosure === 'hood') {\n    k.at([0, yb, zf], undefined, () => hood(k, grp))\n    // hood fixings through the back, telephone on a spacer frame in the hood\n    for (const sx of [-1, 1]) for (const yy of [0.2, HH - 0.12]) k.hex('nut', 0.018, 0.008, [sx * 0.11, yb + yy, zf + 0.04], 'z')\n    k.box('stainless', [0.3, 0.42, 0.034], [0, h - 0.05, zf + 0.023])\n    if (beacon) {\n      const p = beaconAt('hood', mount, h)\n      k.box('stainless', [0.12, 0.01, 0.12], [p[0], p[1] - 0.075, p[2]])\n      beaconBase(k, p)\n    }\n    if (sign) {\n      // sign on two angle feet bolted to the hood roof\n      const ys = yb + HH + 0.16\n      const zs = zf + 0.08\n      k.box('stainless', [0.5, 0.25, 0.004], [-0.08, ys, zs])\n      k.plane(signMat(), 0.49, 0.245, [-0.08, ys, zs + 0.0025])\n      for (const sx of [-0.25, 0.09]) {\n        k.box('stainless', [0.03, 0.06, 0.004], [sx, yb + HH + 0.03, zs - 0.004])\n        k.box('stainless', [0.03, 0.004, 0.05], [sx, yb + HH + 0.002, zs - 0.027])\n        k.hex('nut', 0.012, 0.005, [sx, yb + HH + 0.006, zs - 0.035], 'y')\n      }\n    }\n  } else if (beacon) {\n    const p = beaconAt('none', mount, h)\n    k.box('stainless', [0.12, 0.01, 0.12], [0, p[1] - 0.075, p[2]])\n    k.box('stainless', [0.12, 0.08, 0.008], [0, p[1] - 0.11, zf + 0.004])\n    beaconBase(k, p)\n  }\n  let g: V3 = [0, 0, 0]\n  k.at([0, h, zt], undefined, () => (g = telephone(k)))\n  const gp: V3 = [g[0], g[1] + h, g[2] + zt]\n  const xc = mount === 'pole' ? -(POLE_R + 0.007) : 0.2\n  const zc = mount === 'pole' ? 0 : 0.015\n  cable(k, [gp, [gp[0], gp[1] - 0.06, gp[2]], [xc * 0.5 + 0.03, gp[1] - 0.25, (gp[2] + zc) / 2], [xc, gp[1] - 0.45, zc], [xc, 0.2, zc], [xc + (mount === 'pole' ? -0.2 : 0), 0.03, zc + (mount === 'pole' ? 0 : 0.2)]])\n  const ty = enclosure === 'hood' ? Math.max(0.3, yb - 0.25) : Math.max(0.3, h - 0.55)\n  if (mount === 'pole') tagPlate(k, tag, [0, ty, polePlate(k, ty, 0.13, 0.11) + 0.001], 'z', 0.1)\n  else tagPlate(k, tag, [-0.2, ty, 0.009], 'z', 0.1)\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": "EmergencyTelephone",
    "anchor": "base",
    "standards": [
      "IEC 60079-1",
      "IEC 60079-0",
      "ISO 7010",
      "EN 54-23"
    ]
  }
}