{
  "name": "bookshelf",
  "title": "Bookshelf",
  "type": "registry:component",
  "description": "Procedurally generated bookshelf with seeded books and one cuboid collider.",
  "dependencies": [
    "@react-three/drei@^10.7.7",
    "@react-three/fiber@^9.6.1",
    "@react-three/rapier@^2.2.0",
    "@runek/core@^0.12.0",
    "three@^0.184.0"
  ],
  "registryDependencies": [
    "sign"
  ],
  "files": [
    {
      "path": "Bookshelf.tsx",
      "content": "import { Html } from '@react-three/drei'\nimport type { ThreeEvent } from '@react-three/fiber'\nimport { CuboidCollider, RigidBody } from '@react-three/rapier'\nimport { rng, useWorld, type Vec3 } from '@runek/core'\nimport { useLayoutEffect, useMemo, useRef, useState } from 'react'\nimport * as THREE from 'three'\nimport { Sign } from './Sign'\n\n/**\n * An addressable book on the shelf. JSON-serializable so a whole shelf of\n * clickable books survives a worlds-as-data round-trip; behavior (the reader,\n * the overlay) stays in the host via `onBookSelect`.\n */\nexport interface BookSpec {\n  /** Stable identity, passed back to `onBookSelect`. */\n  id: string\n  /** Shown as a hover label. */\n  title?: string\n  /** Spine color. Defaults to a seeded color. */\n  color?: string\n  /** Navigated to on click when no `onBookSelect` is given. */\n  href?: string\n  /**\n   * Row to place the book on, counted from the top (0 = top row). Clamped to\n   * the shelf's row count. Books without a row auto-pack bottom-up around the\n   * placed ones.\n   */\n  shelf?: number\n}\n\nexport interface BookshelfProps {\n  position?: Vec3\n  rotation?: Vec3\n  /** Outer dimensions in units. */\n  width?: number\n  height?: number\n  depth?: number\n  /** Number of rows. Defaults to 3; set 1 or 2 for a shorter case. */\n  shelves?: number\n  /**\n   * Fraction of shelf space filled with procedural decoration, 0–1. Defaults to\n   * `0` (an empty shelf); set it for a decorative, non-interactive shelf. Ignored\n   * when `books` is set.\n   */\n  fill?: number\n  /** Frame color. Defaults to the world palette's `wood` slot. */\n  color?: string\n  /** Back-panel color. Defaults to the world palette's `woodDark` slot. */\n  backColor?: string\n  seed?: number\n  /**\n   * Explicit, addressable books. When provided, these replace the procedural\n   * `fill` and are the only books rendered. Pass `[]` for an empty shelf and\n   * append to make books appear. A book is clickable when `onBookSelect` is set\n   * or it carries an `href`.\n   */\n  books?: BookSpec[]\n  /** Called with the clicked book. When omitted, a book's `href` is navigated to. */\n  onBookSelect?: (book: BookSpec) => void\n  /**\n   * Section label rendered above the shelf, e.g. `\"Guides\"`. Drawn as a `Sign`\n   * in the world's `display` face.\n   */\n  label?: string\n  /** Label color. Defaults to the world palette's `accent` slot. */\n  labelColor?: string\n  /** Label cap height in units. */\n  labelSize?: number\n}\n\ninterface Book {\n  x: number\n  y: number\n  width: number\n  height: number\n  depth: number\n  /** Spine tilt around z, radians — an occasional leaning book. */\n  lean: number\n  color: THREE.Color\n  /** Set in configured mode: the book's identity and click behavior. */\n  spec?: BookSpec\n}\n\nconst HIGHLIGHT = new THREE.Color('#ffffff')\n\nexport function Bookshelf({\n  position = [0, 0, 0],\n  rotation = [0, 0, 0],\n  width = 1.2,\n  height = 2,\n  depth = 0.3,\n  shelves = 3,\n  fill = 0,\n  color,\n  backColor,\n  seed = 1,\n  books: bookSpecs,\n  onBookSelect,\n  label,\n  labelColor,\n  labelSize = 0.08,\n}: BookshelfProps) {\n  const { unit, palette } = useWorld()\n  const frameColor = color ?? palette.wood\n  const panelColor = backColor ?? palette.woodDark\n  const w = width * unit\n  const h = height * unit\n  const d = depth * unit\n  const plank = 0.03 * unit\n  const inner = w - plank * 2\n  const gap = (h - plank) / shelves\n  const booksRef = useRef<THREE.InstancedMesh>(null)\n  const [hovered, setHovered] = useState<number | null>(null)\n\n  const books = useMemo<Book[]>(() => {\n    const next = rng(seed)\n    const out: Book[] = []\n\n    // Configured mode: render exactly the given books. Seeded geometry, but\n    // identity/color come from the spec. A book with a `shelf` goes on that row\n    // (0 = top); the rest pack into rows (bottom shelf first). Each row is\n    // centered, so a partly-filled shelf still reads as intentional rather than\n    // left-clustered.\n    if (bookSpecs) {\n      const sized = bookSpecs.map((spec) => {\n        const bw = (0.075 + next() * 0.05) * unit\n        const bh = gap * (0.6 + next() * 0.32)\n        const hue = next() * 360\n        const sat = 30 + next() * 25\n        const light = 42 + next() * 20\n        const bd = d * (0.55 + next() * 0.2)\n        return {\n          spec,\n          bw,\n          bh,\n          bd,\n          color: spec.color\n            ? new THREE.Color(spec.color)\n            : new THREE.Color(`hsl(${hue}, ${sat}%, ${light}%)`),\n        }\n      })\n\n      const slack = 0.006 * unit\n      // Row arrays are indexed bottom-up (row 0 renders lowest).\n      const rows: (typeof sized)[] = Array.from({ length: shelves }, () => [])\n      const used = new Array<number>(shelves).fill(0)\n      const place = (row: number, book: (typeof sized)[number]) => {\n        used[row] += (rows[row].length ? slack : 0) + book.bw\n        rows[row].push(book)\n      }\n\n      for (const book of sized) {\n        if (book.spec.shelf == null) continue\n        const fromTop = Math.min(Math.max(book.spec.shelf, 0), shelves - 1)\n        place(shelves - 1 - fromTop, book)\n      }\n      let cursor = 0\n      for (const book of sized) {\n        if (book.spec.shelf != null) continue\n        while (cursor < shelves && rows[cursor].length && used[cursor] + slack + book.bw > inner) {\n          cursor++\n        }\n        if (cursor >= shelves) break // out of shelf space\n        place(cursor, book)\n      }\n\n      rows.forEach((row, shelf) => {\n        const span = row.reduce((sum, b) => sum + b.bw, 0) + slack * Math.max(0, row.length - 1)\n        let x = -span / 2\n        for (const book of row) {\n          out.push({\n            x: x + book.bw / 2,\n            y: -h / 2 + plank + gap * shelf + book.bh / 2,\n            width: book.bw,\n            height: book.bh,\n            depth: book.bd,\n            lean: 0, // upright: tidy, and a clean click target\n            color: book.color,\n            spec: book.spec,\n          })\n          x += book.bw + slack\n        }\n      })\n      return out\n    }\n\n    // Procedural mode: seeded decorative fill (unchanged).\n    for (let shelf = 0; shelf < shelves; shelf++) {\n      let x = -inner / 2\n      while (x < inner / 2) {\n        const bw = (0.025 + next() * 0.03) * unit\n        if (x + bw > inner / 2) break\n        const bh = gap * (0.55 + next() * 0.35)\n        const keep = next() < fill\n        const lean = next() < 0.12 ? (next() - 0.5) * 0.22 : 0\n        const hue = next() * 360\n        const sat = 30 + next() * 25\n        const light = 42 + next() * 20\n        if (keep) {\n          out.push({\n            x: x + bw / 2,\n            y: -h / 2 + plank + gap * shelf + bh / 2,\n            width: bw,\n            height: bh,\n            depth: d * (0.5 + next() * 0.2),\n            lean,\n            color: new THREE.Color(`hsl(${hue}, ${sat}%, ${light}%)`),\n          })\n        }\n        x += bw + 0.004 * unit\n      }\n    }\n    return out\n  }, [h, d, inner, gap, plank, shelves, fill, seed, unit, bookSpecs])\n\n  useLayoutEffect(() => {\n    const mesh = booksRef.current\n    if (!mesh) return\n    const dummy = new THREE.Object3D()\n    const tint = new THREE.Color()\n    books.forEach((book, i) => {\n      const hot = i === hovered\n      const grow = hot ? 1.12 : 1\n      dummy.position.set(book.x, book.y, hot ? book.depth * 0.5 : 0)\n      dummy.rotation.set(0, 0, book.lean)\n      dummy.scale.set(book.width * grow, book.height * grow, book.depth * (hot ? 1.5 : 1))\n      dummy.updateMatrix()\n      mesh.setMatrixAt(i, dummy.matrix)\n      mesh.setColorAt(i, hot ? tint.copy(book.color).lerp(HIGHLIGHT, 0.3) : book.color)\n    })\n    mesh.count = books.length\n    mesh.instanceMatrix.needsUpdate = true\n    if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true\n  }, [books, hovered])\n\n  const plankYs = Array.from({ length: shelves + 1 }, (_, i) => -h / 2 + plank / 2 + gap * i)\n\n  // Interactive only when there's something a click can do. Decorative shelves\n  // (no `books`) stay pure and event-free.\n  const interactive = onBookSelect != null || books.some((b) => b.spec?.href != null)\n\n  const handleClick = (e: ThreeEvent<MouseEvent>) => {\n    const id = e.instanceId\n    if (id == null) return\n    const spec = books[id]?.spec\n    if (!spec) return\n    e.stopPropagation()\n    if (onBookSelect) onBookSelect(spec)\n    else if (spec.href) window.location.href = spec.href\n  }\n\n  const handleMove = (e: ThreeEvent<PointerEvent>) => {\n    const spec = e.instanceId != null ? books[e.instanceId]?.spec : undefined\n    if (!spec) {\n      if (hovered != null) {\n        setHovered(null)\n        document.body.style.cursor = 'auto'\n      }\n      return\n    }\n    e.stopPropagation()\n    if (e.instanceId !== hovered) setHovered(e.instanceId ?? null)\n    document.body.style.cursor = 'pointer'\n  }\n\n  const handleOut = () => {\n    setHovered(null)\n    document.body.style.cursor = 'auto'\n  }\n\n  const hoveredBook = hovered != null ? books[hovered] : undefined\n\n  return (\n    <RigidBody type=\"fixed\" colliders={false} position={position} rotation={rotation}>\n      {/* one collider for the whole footprint — books stay visual-only */}\n      <CuboidCollider args={[w / 2, h / 2, d / 2]} />\n\n      <mesh castShadow receiveShadow position={[-w / 2 + plank / 2, 0, 0]}>\n        <boxGeometry args={[plank, h, d]} />\n        <meshStandardMaterial color={frameColor} />\n      </mesh>\n      <mesh castShadow receiveShadow position={[w / 2 - plank / 2, 0, 0]}>\n        <boxGeometry args={[plank, h, d]} />\n        <meshStandardMaterial color={frameColor} />\n      </mesh>\n      <mesh castShadow receiveShadow position={[0, 0, -d / 2 + plank / 2]}>\n        <boxGeometry args={[w, h, plank]} />\n        <meshStandardMaterial color={panelColor} />\n      </mesh>\n\n      {plankYs.map((y) => (\n        <mesh key={`plank-${y.toFixed(4)}`} castShadow receiveShadow position={[0, y, 0]}>\n          <boxGeometry args={[inner, plank, d]} />\n          <meshStandardMaterial color={frameColor} />\n        </mesh>\n      ))}\n\n      {/* Section label floating just above the shelf, on its front plane. */}\n      {label && (\n        <Sign\n          position={[0, h / 2 + 0.05 * unit, d / 2]}\n          size={labelSize}\n          color={labelColor}\n          anchorY=\"bottom\"\n        >\n          {label}\n        </Sign>\n      )}\n\n      {/* all books in one draw call */}\n      {books.length > 0 && (\n        <instancedMesh\n          key={books.length}\n          ref={booksRef}\n          args={[undefined, undefined, books.length]}\n          castShadow\n          onClick={interactive ? handleClick : undefined}\n          onPointerMove={interactive ? handleMove : undefined}\n          onPointerOut={interactive ? handleOut : undefined}\n        >\n          <boxGeometry args={[1, 1, 1]} />\n          <meshStandardMaterial roughness={0.85} />\n        </instancedMesh>\n      )}\n\n      {hoveredBook?.spec?.title && (\n        <Html\n          position={[\n            hoveredBook.x,\n            hoveredBook.y + hoveredBook.height / 2 + 0.12 * unit,\n            hoveredBook.depth,\n          ]}\n          center\n          distanceFactor={6}\n          zIndexRange={[30, 0]}\n        >\n          <div\n            style={{\n              padding: '2px 8px',\n              borderRadius: 6,\n              background: 'rgba(10, 14, 20, 0.85)',\n              color: '#f4efe6',\n              font: '500 12px/1.4 system-ui, sans-serif',\n              whiteSpace: 'nowrap',\n              pointerEvents: 'none',\n            }}\n          >\n            {hoveredBook.spec.title}\n          </div>\n        </Html>\n      )}\n    </RigidBody>\n  )\n}\n",
      "type": "registry:component"
    }
  ]
}
