{
  "name": "whiteboard",
  "title": "Whiteboard",
  "type": "registry:component",
  "description": "A whiteboard on a rolling stand or a wall, with seeded marker diagrams (boxes, arrows, lines of writing) and optional text across the top.",
  "dependencies": [
    "@react-three/rapier@^2.2.0",
    "@runek/core@^0.13.0",
    "three@^0.184.0"
  ],
  "registryDependencies": [
    "sign"
  ],
  "files": [
    {
      "path": "Whiteboard.tsx",
      "content": "import { CuboidCollider, RigidBody } from '@react-three/rapier'\nimport { pick, range, rng, sub, useWorld, type Vec3, type WorldComponentProps } from '@runek/core'\nimport { useLayoutEffect, useMemo, useRef } from 'react'\nimport { Color, type InstancedMesh, Object3D } from 'three'\nimport { Sign } from './Sign'\n\n/** On a rolling `stand`, or flat on a `wall` (origin at the board's middle, back to local -Z). */\nexport type WhiteboardMount = 'stand' | 'wall'\n\nexport interface WhiteboardProps extends WorldComponentProps {\n  /** Board width, in units. */\n  width?: number\n  /** Board height, in units. */\n  height?: number\n  mount?: WhiteboardMount\n  /** Written across the top of the board in marker. */\n  text?: string\n  /** Seeded marker diagrams (boxes, arrows, lines). Set false for a clean board. */\n  doodles?: boolean\n  /** Frame and stand; defaults to the palette's `metal`. */\n  color?: string\n}\n\ninterface Stroke {\n  pos: Vec3\n  size: [number, number]\n  angle: number\n  color: string\n}\n\nconst MARKERS = ['#2f5fb3', '#c0392b', '#2e8b57', '#2b2a2e']\nconst BOARD = '#f7f7f4'\n\n/**\n * A whiteboard on a rolling stand or a wall, with a seeded scrawl of diagrams on it (boxes joined\n * by arrows, underlined lines of \"writing\"), all thin geometry, and optional `text` in the world's\n * font across the top. One cuboid collider.\n */\nexport function Whiteboard({\n  position = [0, 0, 0],\n  rotation = [0, 0, 0],\n  seed = 1,\n  width = 1.6,\n  height = 1,\n  mount = 'stand',\n  text,\n  doodles = true,\n  color,\n}: WhiteboardProps) {\n  const { unit, palette } = useWorld()\n  const frame = color ?? palette.metal\n  const W = width * unit\n  const H = height * unit\n  const t = 0.02 * unit\n  const wall = mount === 'wall'\n  const cy = wall ? 0 : 0.75 * unit + H / 2\n  const cz = wall ? t / 2 + 0.01 * unit : 0\n\n  const strokes = useMemo<Stroke[]>(() => {\n    if (!doodles) return []\n    const r = rng(sub(seed, 0))\n    const out: Stroke[] = []\n    const line = 0.006 * unit\n    const add = (x0: number, y0: number, x1: number, y1: number, c: string) =>\n      out.push({\n        pos: [(x0 + x1) / 2, (y0 + y1) / 2, 0],\n        size: [Math.hypot(x1 - x0, y1 - y0), line],\n        angle: Math.atan2(y1 - y0, x1 - x0),\n        color: c,\n      })\n    const box = (x: number, y: number, w: number, h: number, c: string) => {\n      add(x - w / 2, y - h / 2, x + w / 2, y - h / 2, c)\n      add(x - w / 2, y + h / 2, x + w / 2, y + h / 2, c)\n      add(x - w / 2, y - h / 2, x - w / 2, y + h / 2, c)\n      add(x + w / 2, y - h / 2, x + w / 2, y + h / 2, c)\n    }\n    const top = text ? 0.2 : 0.35\n    // A row of boxes joined by arrows.\n    const n = 2 + Math.floor(r() * 2)\n    const bw = (0.7 / n) * W * 0.6\n    const bh = 0.16 * H\n    const by = range(r, -0.05, top - 0.15) * H\n    const ink = pick(r, MARKERS)\n    for (let i = 0; i < n; i++) {\n      const x = (-0.35 + (0.7 * (i + 0.5)) / n) * W\n      box(x, by, bw, bh, ink)\n      if (i < n - 1) {\n        const x0 = x + bw / 2 + 0.01 * W\n        const x1 = x + (0.7 / n) * W - bw / 2 - 0.01 * W\n        add(x0, by, x1, by, ink)\n        add(x1 - 0.02 * W, by + 0.02 * H, x1, by, ink)\n        add(x1 - 0.02 * W, by - 0.02 * H, x1, by, ink)\n      }\n    }\n    // Lines of \"writing\" below, one underlined.\n    const lines = 2 + Math.floor(r() * 3)\n    const second = pick(r, MARKERS)\n    for (let i = 0; i < lines; i++) {\n      const y = (-0.22 - i * 0.07) * H\n      const w = range(r, 0.25, 0.6) * W\n      const x0 = -0.4 * W\n      // Words: short dashes along the line.\n      let x = x0\n      while (x < x0 + w) {\n        const word = range(r, 0.03, 0.08) * W\n        add(x, y, Math.min(x + word, x0 + w), y + range(r, -0.004, 0.004) * H, second)\n        x += word + 0.015 * W\n      }\n      if (i === 0) add(x0, y - 0.025 * H, x0 + w * 0.5, y - 0.025 * H, second)\n    }\n    return out\n  }, [doodles, seed, W, H, unit, text])\n\n  // Every stroke is one instance of a unit square, scaled and turned into place.\n  const ink = useRef<InstancedMesh>(null)\n  useLayoutEffect(() => {\n    const mesh = ink.current\n    if (!mesh) return\n    const o = new Object3D()\n    const c = new Color()\n    strokes.forEach((st, i) => {\n      o.position.set(st.pos[0], st.pos[1], 0.0005 * unit)\n      o.rotation.set(0, 0, st.angle)\n      o.scale.set(st.size[0], st.size[1], 1)\n      o.updateMatrix()\n      mesh.setMatrixAt(i, o.matrix)\n      mesh.setColorAt(i, c.set(st.color))\n    })\n    mesh.count = strokes.length\n    mesh.instanceMatrix.needsUpdate = true\n    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true\n  }, [strokes, unit])\n\n  return (\n    <RigidBody type=\"fixed\" colliders={false} position={position} rotation={rotation}>\n      {wall ? (\n        <CuboidCollider args={[W / 2, H / 2, t / 2]} position={[0, 0, cz]} />\n      ) : (\n        <CuboidCollider\n          args={[W / 2 + 0.05 * unit, (cy + H / 2) / 2, 0.25 * unit]}\n          position={[0, (cy + H / 2) / 2, 0]}\n        />\n      )}\n      <mesh position={[0, cy, cz]} castShadow>\n        <boxGeometry args={[W + 0.04 * unit, H + 0.04 * unit, t]} />\n        <meshStandardMaterial color={frame} roughness={0.4} metalness={0.4} />\n      </mesh>\n      <group position={[0, cy, cz + t / 2 + 0.0008 * unit]}>\n        <mesh receiveShadow>\n          <planeGeometry args={[W, H]} />\n          <meshStandardMaterial color={BOARD} roughness={0.25} />\n        </mesh>\n        {strokes.length > 0 && (\n          <instancedMesh\n            key={strokes.length}\n            ref={ink}\n            args={[undefined, undefined, strokes.length]}\n          >\n            <planeGeometry args={[1, 1]} />\n            <meshBasicMaterial />\n          </instancedMesh>\n        )}\n        {text && (\n          <Sign\n            position={[0, H * 0.36, 0.001 * unit]}\n            variant=\"body\"\n            size={Math.min(0.09, height * 0.09)}\n            color=\"#2b2a2e\"\n            maxWidth={width * 0.9}\n          >\n            {text}\n          </Sign>\n        )}\n        {/* marker tray */}\n        <mesh position={[0, -H / 2 - 0.03 * unit, 0.02 * unit]} castShadow>\n          <boxGeometry args={[W * 0.5, 0.015 * unit, 0.05 * unit]} />\n          <meshStandardMaterial color={frame} roughness={0.4} metalness={0.4} />\n        </mesh>\n      </group>\n      {!wall &&\n        [-1, 1].map((s) => (\n          <group key={s} position={[s * (W / 2 + 0.03 * unit), 0, 0]}>\n            <mesh position={[0, (cy + H / 2) / 2, 0]} castShadow>\n              <boxGeometry args={[0.03 * unit, cy + H / 2, 0.03 * unit]} />\n              <meshStandardMaterial color={frame} roughness={0.4} metalness={0.4} />\n            </mesh>\n            <mesh position={[0, 0.05 * unit, 0]} castShadow>\n              <boxGeometry args={[0.04 * unit, 0.03 * unit, 0.5 * unit]} />\n              <meshStandardMaterial color={frame} roughness={0.4} metalness={0.4} />\n            </mesh>\n            {[-1, 1].map((z) => (\n              <mesh key={z} position={[0, 0.025 * unit, z * 0.23 * unit]}>\n                <sphereGeometry args={[0.025 * unit, 8, 6]} />\n                <meshStandardMaterial color=\"#1c1c1e\" roughness={0.6} />\n              </mesh>\n            ))}\n          </group>\n        ))}\n    </RigidBody>\n  )\n}\n",
      "type": "registry:component"
    }
  ]
}
