{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "organ-viewer",
  "title": "Organ viewer",
  "description": "Rotate, frame and select the structures of a GLB anatomy model, with ghosting and named views.",
  "dependencies": [
    "three@^0.186.0",
    "@base-ui/react@^1.8.0",
    "class-variance-authority@^0.7.1",
    "cn@^0.5.0"
  ],
  "devDependencies": [
    "@types/three@^0.186.0"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "progress",
    "select",
    "tooltip"
  ],
  "files": [
    {
      "path": "registry/base-nova/organ-viewer/organ-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"cn\"\nimport {\n  AmbientLight,\n  Box3,\n  Color,\n  DirectionalLight,\n  HemisphereLight,\n  Mesh,\n  MeshStandardMaterial,\n  PerspectiveCamera,\n  Raycaster,\n  Scene,\n  Spherical,\n  Vector2,\n  Vector3,\n  WebGLRenderer,\n  type Group,\n  type Intersection,\n  type Material,\n  type Object3D,\n} from \"three\"\nimport { GLTFLoader } from \"three/addons/loaders/GLTFLoader.js\"\nimport { OrbitControls } from \"three/addons/controls/OrbitControls.js\"\n\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\"\nimport { Button } from \"@/components/ui/button\"\nimport { Progress } from \"@/components/ui/progress\"\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nexport type OrganStructure = {\n  /** Mesh name, or `userData.structureId`, of the structure in the GLB. */\n  id: string\n  label: string\n  /** Interface grouping, as shown in the explorer's anatomy panel. */\n  group: string\n  color: string\n  description?: string\n}\n\nexport type OrganView = {\n  name: string\n  /**\n   * Camera direction from the framing centre in model space: +x is the\n   * patient's left, +y superior and +z anterior.\n   */\n  direction: readonly [number, number, number]\n  /** Frame only these structures. Defaults to the whole model. */\n  structureIds?: readonly string[]\n}\n\nexport type OrganModel = {\n  name: string\n  /** GLB URL: a commit-pinned file in the organ's explorer repository, or one served by organui.com. */\n  src: string\n  /** Expected size in bytes, for progress when the server sends no length. */\n  bytes?: number\n  structures: readonly OrganStructure[]\n  views: readonly OrganView[]\n  defaultView: string\n  /** Structures the explorer hides on first load. */\n  hiddenIds?: readonly string[]\n  attribution: {\n    credit: string\n    license: string\n    licenseUrl: string\n    source: string\n  }\n}\n\nexport type OrganViewerHandle = {\n  setView: (name: string) => void\n  zoom: (factor: number) => void\n  rotate: (azimuth: number, polar?: number) => void\n  reset: () => void\n}\n\nexport type OrganViewerProps = {\n  model: OrganModel\n  /** Overrides `model.src`, for example to serve the GLB yourself. */\n  src?: string\n  selectedId?: string | null\n  onSelectionChange?: (id: string) => void\n  /** Missing IDs are visible, matching the anatomy tree's contract. */\n  visibility?: Readonly<Record<string, boolean>>\n  defaultView?: string\n  /** Called with the view name, or null once the camera is rotated freely. */\n  onViewChange?: (view: string | null) => void\n  /** Shows the view menu, zoom and reset buttons. Defaults to true. */\n  controls?: boolean\n  /** Shows the model credit and licence link. Keep it unless you credit the model elsewhere. */\n  attribution?: boolean\n  label?: string\n  className?: string\n  ref?: React.Ref<OrganViewerHandle>\n}\n\n/** Rows for the OrganUI anatomy tree: non-selectable groups of coloured leaves. */\nexport function organAnatomyNodes(model: OrganModel) {\n  const groups = [...new Set(model.structures.map(({ group }) => group))]\n  return groups.map((group) => ({\n    id: `group-${group.toLowerCase().replace(/[^a-z0-9]+/g, \"-\")}`,\n    label: group,\n    selectable: false,\n    children: model.structures\n      .filter((structure) => structure.group === group)\n      .map(({ id, label, color }) => ({ id, label, color })),\n  }))\n}\n\n/** The explorer's initial visibility, as an anatomy tree visibility map. */\nexport function organDefaultVisibility(\n  model: OrganModel\n): Record<string, boolean> {\n  return Object.fromEntries((model.hiddenIds ?? []).map((id) => [id, false]))\n}\n\n// Shared with every OrganUI explorer so named views fill the stage by the same\n// rule whatever the organ's shape.\nconst FRAME_FILL = 0.8\nconst STAGE_TONE = new Color(\"#e4e3dc\")\n\nfunction surfacePoints(meshes: Mesh[], stride = 16) {\n  const points: Vector3[] = []\n  for (const mesh of meshes) {\n    mesh.updateWorldMatrix(true, false)\n    const position = mesh.geometry.getAttribute(\"position\")\n    for (let i = 0; i < position.count; i += stride)\n      points.push(\n        new Vector3()\n          .fromBufferAttribute(position, i)\n          .applyMatrix4(mesh.matrixWorld)\n      )\n  }\n  return points\n}\n\n// Distance along `direction` at which every sampled point sits inside\n// FRAME_FILL of the frustum.\nfunction fitDistance(\n  points: Vector3[],\n  center: Vector3,\n  direction: Vector3,\n  fov: number,\n  aspect: number\n) {\n  const forward = direction.clone().normalize()\n  const right = new Vector3(0, 1, 0).cross(forward)\n  if (right.lengthSq() < 1e-8) right.set(1, 0, 0)\n  right.normalize()\n  const up = forward.clone().cross(right).normalize()\n  const tanV = Math.tan((fov * Math.PI) / 360) * FRAME_FILL\n  const tanH = tanV * aspect\n  const offset = new Vector3()\n  let distance = 0\n  for (const point of points) {\n    offset.subVectors(point, center)\n    distance = Math.max(\n      distance,\n      offset.dot(forward) +\n        Math.max(\n          Math.abs(offset.dot(right)) / tanH,\n          Math.abs(offset.dot(up)) / tanV\n        )\n    )\n  }\n  return distance\n}\n\n// Ghosts stay pickable when nothing solid is behind them, but solid anatomy\n// seen through a translucent shell wins the click.\nfunction pickVisibleMesh(hits: Intersection<Object3D>[]) {\n  const meshes = hits\n    .map(({ object }) => object)\n    .filter((object): object is Mesh => {\n      if (!(object instanceof Mesh)) return false\n      for (let parent: Object3D | null = object; parent; parent = parent.parent)\n        if (!parent.visible) return false\n      return true\n    })\n  return (\n    meshes.find((mesh) => {\n      const material = mesh.material as Material\n      return !material.transparent || material.opacity >= 1\n    }) ?? meshes[0]\n  )\n}\n\n// A structure may be a mesh, or a node whose meshes are its children.\nfunction structureId(object: Object3D, known: ReadonlySet<string>) {\n  for (let node: Object3D | null = object; node; node = node.parent) {\n    const id: string = node.userData.structureId || node.name\n    if (known.has(id)) return id\n  }\n}\n\nfunction dispose(model: Group) {\n  model.traverse((object) => {\n    if (!(object instanceof Mesh)) return\n    object.geometry.dispose()\n    const materials = Array.isArray(object.material)\n      ? object.material\n      : [object.material]\n    materials.forEach((material: Material) => material.dispose())\n  })\n}\n\nasync function loadModel(\n  src: string,\n  expected: number | undefined,\n  signal: AbortSignal,\n  onProgress: (percent: number) => void\n) {\n  const response = await fetch(src, { signal })\n  if (!response.ok)\n    throw new Error(`The model request returned HTTP ${response.status}.`)\n  const total = Number(response.headers.get(\"content-length\")) || expected || 0\n  const reader = response.body?.getReader()\n  let data: ArrayBuffer\n  if (reader) {\n    const chunks: Uint8Array[] = []\n    let received = 0\n    for (;;) {\n      const { done, value } = await reader.read()\n      if (done) break\n      chunks.push(value)\n      received += value.length\n      if (total) onProgress(Math.min(95, Math.round((received / total) * 95)))\n    }\n    const bytes = new Uint8Array(received)\n    let offset = 0\n    for (const chunk of chunks) {\n      bytes.set(chunk, offset)\n      offset += chunk.length\n    }\n    data = bytes.buffer\n  } else data = await response.arrayBuffer()\n  if (\n    data.byteLength < 12 ||\n    new DataView(data).getUint32(0, true) !== 0x46546c67\n  )\n    throw new Error(\"The model file is missing or is not a valid GLB.\")\n  return (await new GLTFLoader().parseAsync(data, \"\")).scene\n}\n\ntype Engine = OrganViewerHandle & {\n  paint: (\n    selectedId: string | null,\n    visibility: Readonly<Record<string, boolean>>\n  ) => void\n}\n\nconst iconProps = {\n  width: 16,\n  height: 16,\n  viewBox: \"0 0 24 24\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 2,\n  strokeLinecap: \"round\" as const,\n  strokeLinejoin: \"round\" as const,\n  \"aria-hidden\": true,\n}\n\nfunction ToolbarButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            aria-label={label}\n            onClick={onClick}\n          />\n        }\n      >\n        {children}\n      </TooltipTrigger>\n      <TooltipContent side=\"bottom\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\nexport function OrganViewer({\n  model,\n  src = model.src,\n  selectedId = null,\n  onSelectionChange,\n  visibility,\n  defaultView = model.defaultView,\n  onViewChange,\n  controls = true,\n  attribution = true,\n  label = `Interactive ${model.name.toLowerCase()} model`,\n  className,\n  ref,\n}: OrganViewerProps) {\n  const host = React.useRef<HTMLDivElement>(null)\n  const [attempt, setAttempt] = React.useState(0)\n  // Load results are keyed by request so a new src or retry starts clean\n  // without resetting state inside the effect.\n  const request = `${attempt}:${src}`\n  const [result, setResult] = React.useState<{\n    request: string\n    scene?: Group\n    error?: string\n  } | null>(null)\n  const [loading, setLoading] = React.useState({ request, progress: 0 })\n  const scene = result?.request === request ? (result.scene ?? null) : null\n  const error = result?.request === request ? (result.error ?? null) : null\n  const progress = loading.request === request ? loading.progress : 0\n  const [graphicsFailed, setGraphicsFailed] = React.useState(false)\n  const [engine, setEngine] = React.useState<Engine | null>(null)\n  const [view, setView] = React.useState<string | null>(defaultView)\n  const [interacted, setInteracted] = React.useState(false)\n  const latest = React.useRef({\n    model,\n    defaultView,\n    onSelectionChange,\n    onViewChange,\n  })\n  React.useLayoutEffect(() => {\n    latest.current = { model, defaultView, onSelectionChange, onViewChange }\n  })\n  const instructions = React.useId()\n\n  const shown = React.useMemo(\n    () => visibility ?? organDefaultVisibility(model),\n    [visibility, model]\n  )\n  const viewRef = React.useRef(view)\n  const changeView = React.useCallback((next: string | null) => {\n    if (viewRef.current === next) return\n    viewRef.current = next\n    setView(next)\n    latest.current.onViewChange?.(next)\n  }, [])\n\n  React.useEffect(() => {\n    const abort = new AbortController()\n    let loaded: Group | null = null\n    const timeout = setTimeout(() => abort.abort(\"timeout\"), 60_000)\n    loadModel(src, latest.current.model.bytes, abort.signal, (percent) =>\n      setLoading({ request, progress: percent })\n    )\n      .then((group) => {\n        loaded = group\n        if (abort.signal.aborted) return dispose(group)\n        const known = new Set(\n          latest.current.model.structures.map(({ id }) => id)\n        )\n        const found = new Set<string>()\n        group.traverse((object) => {\n          const id = object instanceof Mesh && structureId(object, known)\n          if (id) found.add(id)\n        })\n        const missing = [...known].filter((id) => !found.has(id))\n        if (missing.length)\n          throw new Error(\"The model does not contain the expected structures.\")\n        setResult({ request, scene: group })\n      })\n      .catch((reason: unknown) => {\n        if (abort.signal.aborted && abort.signal.reason !== \"timeout\") return\n        setResult({\n          request,\n          error:\n            abort.signal.reason === \"timeout\"\n              ? \"The download timed out.\"\n              : reason instanceof Error\n                ? reason.message\n                : \"The model could not be decoded.\",\n        })\n      })\n      .finally(() => clearTimeout(timeout))\n    return () => {\n      abort.abort(\"unmount\")\n      clearTimeout(timeout)\n      if (loaded) dispose(loaded)\n    }\n  }, [src, request])\n\n  React.useEffect(() => {\n    const container = host.current\n    if (!scene || !container || graphicsFailed) return\n    const structures = new Map(\n      latest.current.model.structures.map((structure) => [\n        structure.id,\n        structure,\n      ])\n    )\n    const meshes = new Map<string, Mesh[]>()\n    const known = new Set(structures.keys())\n    scene.traverse((object) => {\n      const id = object instanceof Mesh && structureId(object, known)\n      if (!id) return\n      // Materials may be shared in the GLB, but emphasis is per structure.\n      const source = object.material as MeshStandardMaterial\n      object.material =\n        source instanceof MeshStandardMaterial\n          ? source.clone()\n          : new MeshStandardMaterial()\n      meshes.set(id, [...(meshes.get(id) ?? []), object])\n    })\n\n    const canvas = document.createElement(\"canvas\")\n    canvas.className = \"block size-full touch-none outline-none\"\n    canvas.setAttribute(\"aria-hidden\", \"true\")\n    container.prepend(canvas)\n    let renderer: WebGLRenderer\n    try {\n      renderer = new WebGLRenderer({\n        canvas,\n        antialias: true,\n        alpha: true,\n        powerPreference: \"low-power\",\n      })\n    } catch {\n      canvas.remove()\n      // WebGL is an external system; its failure is reported once per attempt.\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setGraphicsFailed(true)\n      return\n    }\n    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))\n    const stage = new Scene()\n    stage.add(new AmbientLight(\"#ffffff\", 0.8))\n    stage.add(new HemisphereLight(\"#fffcf3\", \"#a49b8f\", 1.6))\n    const lights: [number[], number, string][] = [\n      [[-4, 6, 6], 2.6, \"#fff5e6\"],\n      [[5, 1, -4], 1.8, \"#e1edff\"],\n      [[-3, -3, 0], 0.45, \"#ffffff\"],\n    ]\n    for (const [position, intensity, color] of lights) {\n      const light = new DirectionalLight(color, intensity)\n      light.position.set(position[0]!, position[1]!, position[2]!)\n      stage.add(light)\n    }\n    stage.add(scene)\n    const camera = new PerspectiveCamera(34, 1, 0.05, 100)\n    const orbit = new OrbitControls(camera, canvas)\n    orbit.enablePan = false\n    orbit.rotateSpeed = 0.65\n    orbit.zoomSpeed = 0.8\n    const motion = matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const applyMotion = () => (orbit.enableDamping = !motion.matches)\n    applyMotion()\n    motion.addEventListener(\"change\", applyMotion)\n\n    const allMeshes = [...meshes.values()].flat()\n    const points = surfacePoints(allMeshes)\n    const center = new Box3().setFromPoints(points).getCenter(new Vector3())\n    const radius =\n      new Box3().setFromObject(scene).getSize(new Vector3()).length() / 2\n    const framing = new Map(\n      latest.current.model.views.map((entry) => {\n        const subset = entry.structureIds?.flatMap((id) => meshes.get(id) ?? [])\n        const framed = subset?.length ? surfacePoints(subset) : points\n        const target = subset?.length\n          ? new Box3().setFromPoints(framed).getCenter(new Vector3())\n          : center\n        return [entry.name, { entry, framed, target }]\n      })\n    )\n    // Views of a few structures, such as the head of a whole-body model, may\n    // come closer than the whole model allows.\n    const closest = Math.min(\n      radius,\n      ...[...framing.values()].map(\n        ({ framed }) =>\n          new Box3().setFromPoints(framed).getSize(new Vector3()).length() / 2\n      )\n    )\n    camera.near = closest / 100\n    camera.far = radius * 20\n    orbit.minDistance = closest * 1.25\n    orbit.maxDistance = radius * 6\n\n    let dirty = true\n    const render = () => (dirty = true)\n    let frame = 0\n    const tick = () => {\n      if (orbit.update() || dirty) {\n        renderer.render(stage, camera)\n        dirty = false\n      }\n      frame = requestAnimationFrame(tick)\n    }\n\n    let current: string | null = latest.current.defaultView\n    const preset = (name: string) => {\n      const target = framing.get(name) ?? framing.values().next().value\n      if (!target) return\n      orbit.reset()\n      const direction = new Vector3(...target.entry.direction).normalize()\n      const distance = Math.min(\n        orbit.maxDistance,\n        Math.max(\n          orbit.minDistance,\n          fitDistance(\n            target.framed,\n            target.target,\n            direction,\n            camera.fov,\n            camera.aspect\n          )\n        )\n      )\n      orbit.target.copy(target.target)\n      camera.position\n        .copy(direction)\n        .multiplyScalar(distance)\n        .add(target.target)\n      camera.lookAt(target.target)\n      orbit.update()\n      current = target.entry.name\n      changeView(current)\n      render()\n    }\n\n    const resize = () => {\n      const width = container.clientWidth\n      const height = container.clientHeight\n      if (!width || !height) return\n      const aspect = width / height\n      const changed = Math.abs(aspect - camera.aspect) > 1e-6\n      renderer.setSize(width, height, false)\n      camera.aspect = aspect\n      camera.updateProjectionMatrix()\n      // A named view is refitted; a free camera keeps its target and distance.\n      if (changed && current) preset(current)\n      render()\n    }\n    const observer = new ResizeObserver(resize)\n    observer.observe(container)\n    camera.aspect = container.clientWidth / Math.max(1, container.clientHeight)\n    renderer.setSize(container.clientWidth, container.clientHeight, false)\n    camera.updateProjectionMatrix()\n    preset(latest.current.defaultView)\n\n    let interacting = false\n    const lastRotation = camera.quaternion.clone()\n    const onStart = () => (interacting = true)\n    const onEnd = () => (interacting = false)\n    const onChange = () => {\n      if (interacting) {\n        setInteracted(true)\n        if (1 - Math.abs(lastRotation.dot(camera.quaternion)) > 1e-10) {\n          current = null\n          changeView(null)\n        }\n      }\n      lastRotation.copy(camera.quaternion)\n      render()\n    }\n    orbit.addEventListener(\"start\", onStart)\n    orbit.addEventListener(\"end\", onEnd)\n    orbit.addEventListener(\"change\", onChange)\n\n    const raycaster = new Raycaster()\n    const down = new Vector2()\n    const onPointerDown = (event: PointerEvent) =>\n      down.set(event.clientX, event.clientY)\n    const onPointerUp = (event: PointerEvent) => {\n      if (down.distanceTo(new Vector2(event.clientX, event.clientY)) > 4) return\n      const bounds = canvas.getBoundingClientRect()\n      raycaster.setFromCamera(\n        new Vector2(\n          ((event.clientX - bounds.left) / bounds.width) * 2 - 1,\n          -((event.clientY - bounds.top) / bounds.height) * 2 + 1\n        ),\n        camera\n      )\n      const mesh = pickVisibleMesh(raycaster.intersectObjects(allMeshes, false))\n      const id = mesh && structureId(mesh, known)\n      if (id) latest.current.onSelectionChange?.(id)\n    }\n    canvas.addEventListener(\"pointerdown\", onPointerDown)\n    canvas.addEventListener(\"pointerup\", onPointerUp)\n    const onContextLost = (event: Event) => {\n      event.preventDefault()\n      setGraphicsFailed(true)\n    }\n    canvas.addEventListener(\"webglcontextlost\", onContextLost)\n\n    const ghosts = new Map<string, Color>()\n    setEngine({\n      paint(selected, shown) {\n        for (const [id, list] of meshes) {\n          const color = structures.get(id)!.color\n          const state = !selected\n            ? \"normal\"\n            : id === selected\n              ? \"selected\"\n              : \"ghosted\"\n          let ghost = ghosts.get(color)\n          if (!ghost) {\n            ghost = new Color(color).lerp(STAGE_TONE, 0.5)\n            ghosts.set(color, ghost)\n          }\n          for (const mesh of list) {\n            const material = mesh.material as MeshStandardMaterial\n            mesh.visible = shown[id] !== false\n            if (state === \"ghosted\") material.color.copy(ghost)\n            else material.color.set(color)\n            material.emissive.set(state === \"selected\" ? color : \"#000000\")\n            material.emissiveIntensity = state === \"selected\" ? 0.35 : 0\n            material.transparent = state === \"ghosted\"\n            material.opacity = state === \"ghosted\" ? 0.22 : 1\n            material.depthWrite = state !== \"ghosted\"\n            material.roughness = 0.58\n            // glTF defaults to fully metallic when a file omits the factor.\n            material.metalness = 0\n            material.needsUpdate = true\n          }\n        }\n        render()\n      },\n      setView: preset,\n      reset: () => preset(latest.current.defaultView),\n      zoom(factor) {\n        const offset = camera.position.clone().sub(orbit.target)\n        offset.setLength(\n          Math.min(\n            orbit.maxDistance,\n            Math.max(orbit.minDistance, offset.length() * factor)\n          )\n        )\n        camera.position.copy(orbit.target).add(offset)\n        orbit.update()\n        setInteracted(true)\n        render()\n      },\n      rotate(azimuth, polar = 0) {\n        const spherical = new Spherical().setFromVector3(\n          camera.position.clone().sub(orbit.target)\n        )\n        spherical.theta += azimuth\n        spherical.phi += polar\n        spherical.makeSafe()\n        camera.position\n          .copy(orbit.target)\n          .add(new Vector3().setFromSpherical(spherical))\n        orbit.update()\n        current = null\n        changeView(null)\n        setInteracted(true)\n        render()\n      },\n    })\n    frame = requestAnimationFrame(tick)\n\n    return () => {\n      cancelAnimationFrame(frame)\n      setEngine(null)\n      observer.disconnect()\n      motion.removeEventListener(\"change\", applyMotion)\n      canvas.removeEventListener(\"pointerdown\", onPointerDown)\n      canvas.removeEventListener(\"pointerup\", onPointerUp)\n      canvas.removeEventListener(\"webglcontextlost\", onContextLost)\n      orbit.dispose()\n      stage.remove(scene)\n      renderer.dispose()\n      canvas.remove()\n    }\n  }, [scene, graphicsFailed, changeView])\n\n  React.useEffect(() => {\n    engine?.paint(selectedId, shown)\n  }, [engine, selectedId, shown])\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      setView: (name) => engine?.setView(name),\n      zoom: (factor) => engine?.zoom(factor),\n      rotate: (azimuth, polar) => engine?.rotate(azimuth, polar),\n      reset: () => engine?.reset(),\n    }),\n    [engine]\n  )\n\n  const retry = () => {\n    setGraphicsFailed(false)\n    setAttempt((n) => n + 1)\n  }\n  const message = error\n    ? {\n        title: `The ${model.name.toLowerCase()} model couldn’t load`,\n        text: `${error} Check your connection and try again.`,\n      }\n    : graphicsFailed\n      ? {\n          title: \"3D view unavailable\",\n          text: \"WebGL could not start or its connection was interrupted. Try again, or use a browser with hardware acceleration enabled.\",\n        }\n      : null\n\n  return (\n    <div\n      ref={host}\n      role=\"region\"\n      aria-label={label}\n      aria-describedby={instructions}\n      tabIndex={0}\n      className={cn(\n        \"relative h-96 w-full overflow-hidden rounded-xl border bg-muted/30 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-ring\",\n        className\n      )}\n      onKeyDown={(event) => {\n        if (event.target !== event.currentTarget || !engine) return\n        const actions: Record<string, () => void> = {\n          ArrowLeft: () => engine.rotate(-0.2),\n          ArrowRight: () => engine.rotate(0.2),\n          ArrowUp: () => engine.rotate(0, -0.2),\n          ArrowDown: () => engine.rotate(0, 0.2),\n          \"+\": () => engine.zoom(0.85),\n          \"=\": () => engine.zoom(0.85),\n          \"-\": () => engine.zoom(1.18),\n        }\n        const action = actions[event.key]\n        if (!action) return\n        event.preventDefault()\n        action()\n      }}\n    >\n      <p id={instructions} className=\"sr-only\">\n        Drag or use the arrow keys to rotate, and scroll, pinch or press plus\n        and minus to zoom. Click a structure to select it.\n      </p>\n      {message ? (\n        <div className=\"absolute inset-0 flex items-center justify-center p-6\">\n          <Alert className=\"max-w-sm\">\n            <svg {...iconProps}>\n              <circle cx=\"12\" cy=\"12\" r=\"10\" />\n              <path d=\"M12 8v4M12 16h.01\" />\n            </svg>\n            <AlertTitle>{message.title}</AlertTitle>\n            <AlertDescription>\n              <p>{message.text}</p>\n              <Button\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={retry}\n                className=\"mt-3 text-foreground\"\n              >\n                Try again\n              </Button>\n            </AlertDescription>\n          </Alert>\n        </div>\n      ) : !scene ? (\n        <div\n          role=\"status\"\n          className=\"absolute inset-0 flex flex-col items-center justify-center gap-3 p-6\"\n        >\n          <Progress\n            value={progress}\n            aria-label={`Loading ${model.name.toLowerCase()} model`}\n            className=\"w-40\"\n          />\n          <p className=\"text-muted-foreground\">{progress}% · Loading anatomy</p>\n        </div>\n      ) : null}\n      {engine && controls && (\n        <TooltipProvider>\n          <div\n            role=\"toolbar\"\n            aria-label=\"View controls\"\n            className=\"absolute top-2 right-2 flex items-center gap-0.5 rounded-lg border bg-background/90 p-1 shadow-sm backdrop-blur\"\n          >\n            <Select\n              items={[\n                { label: \"Custom view\", value: null },\n                ...model.views.map(({ name }) => ({\n                  label: name,\n                  value: name,\n                })),\n              ]}\n              value={view}\n              onValueChange={(next) => {\n                if (next) engine.setView(next)\n              }}\n            >\n              <SelectTrigger aria-label=\"View\" className=\"border-transparent\">\n                <SelectValue />\n              </SelectTrigger>\n              <SelectContent>\n                <SelectGroup>\n                  {model.views.map(({ name }) => (\n                    <SelectItem key={name} value={name}>\n                      {name}\n                    </SelectItem>\n                  ))}\n                </SelectGroup>\n              </SelectContent>\n            </Select>\n            <ToolbarButton label=\"Zoom in\" onClick={() => engine.zoom(0.85)}>\n              <svg {...iconProps}>\n                <path d=\"M5 12h14M12 5v14\" />\n              </svg>\n            </ToolbarButton>\n            <ToolbarButton label=\"Zoom out\" onClick={() => engine.zoom(1.18)}>\n              <svg {...iconProps}>\n                <path d=\"M5 12h14\" />\n              </svg>\n            </ToolbarButton>\n            <ToolbarButton label=\"Reset view\" onClick={() => engine.reset()}>\n              <svg {...iconProps}>\n                <path d=\"M3 12a9 9 0 1 0 3-6.7L3 8\" />\n                <path d=\"M3 3v5h5\" />\n              </svg>\n            </ToolbarButton>\n          </div>\n        </TooltipProvider>\n      )}\n      {engine && !interacted && (\n        <p\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 bottom-8 text-center text-xs text-muted-foreground\"\n        >\n          Drag to rotate · Scroll or pinch to zoom\n        </p>\n      )}\n      {attribution && (\n        <p className=\"absolute right-2 bottom-1.5 left-2 flex justify-end gap-1 text-[11px] text-muted-foreground\">\n          <a\n            href={model.attribution.source}\n            className=\"min-w-0 truncate underline-offset-2 hover:underline\"\n          >\n            {model.attribution.credit}\n          </a>\n          <span aria-hidden=\"true\">·</span>\n          <a\n            href={model.attribution.licenseUrl}\n            className=\"shrink-0 underline-offset-2 hover:underline\"\n          >\n            {model.attribution.license}\n          </a>\n        </p>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Organ viewer docs: https://organui.com/docs/components/organ-viewer. Needs one of shadcn's Base UI styles, such as base-nova: its controls use Base UI's render prop, so on a Radix style its buttons do nothing.",
  "categories": [
    "anatomy"
  ],
  "type": "registry:component"
}