{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "model-viewer",
  "title": "3D Model Viewer",
  "description": "A composable React Three Fiber viewer with shared state, optional controls, inspection, animation, and fixed split views. Uses the host Base UI shadcn primitives and theme.",
  "dependencies": [
    "@react-three/drei",
    "@react-three/fiber",
    "lucide-react",
    "three",
    "three-stdlib",
    "@base-ui/react",
    "class-variance-authority"
  ],
  "devDependencies": [
    "@types/three"
  ],
  "registryDependencies": [
    "alert",
    "button",
    "tooltip",
    "dropdown-menu"
  ],
  "files": [
    {
      "path": "src/components/ui/model-viewer/lazy.tsx",
      "content": "\"use client\";\n\n// Keep this entry independent of the eager barrel: no Three/R3F runtime imports.\nimport { Component, lazy, type ReactNode, Suspense } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ModelViewerProps } from \"./model-viewer-types\";\nimport { ViewerLoader } from \"./viewer-loader\";\nimport \"./model-viewer.css\";\n\nexport type { ModelViewerSkeletonProps } from \"./model-viewer-skeleton\";\nexport { ModelViewerSkeleton } from \"./model-viewer-skeleton\";\n\nconst Viewer = lazy(() =>\n  import(\"./model-viewer\").then((module) => ({ default: module.ModelViewer })),\n);\n\n/**\n * Loads the renderer chunk only when mounted. Use this entry for optional embeds.\n * `loadingFallback` and `poster` cover the chunk download and model loading alike.\n */\nexport function ModelViewer(props: ModelViewerProps) {\n  const { className, height, style, poster, loadingFallback, showFileName = false, src } = props;\n  return (\n    <ImportErrorBoundary\n      className={className}\n      height={height}\n      style={style}\n      onError={props.onError}\n    >\n      <Suspense\n        fallback={\n          <div\n            data-slot=\"model-viewer-lazy\"\n            className={cn(\"model-viewer\", className)}\n            style={{ height, ...style }}\n          >\n            {poster && <img className=\"viewer-poster\" src={poster} alt=\"\" aria-hidden=\"true\" />}\n            <ViewerLoader\n              fallback={loadingFallback}\n              poster={Boolean(poster)}\n              showFileName={showFileName}\n              src={src}\n            />\n          </div>\n        }\n      >\n        <Viewer {...props} />\n      </Suspense>\n    </ImportErrorBoundary>\n  );\n}\n\n// This boundary lives outside the downloaded module, so it can handle a failed\n// chunk request. Reloading also recovers stale chunk URLs after a deployment;\n// remounting React.lazy alone would reuse its cached rejection.\nclass ImportErrorBoundary extends Component<\n  Pick<ModelViewerProps, \"className\" | \"height\" | \"style\" | \"onError\"> & { children: ReactNode },\n  { failed: boolean }\n> {\n  state = { failed: false };\n\n  static getDerivedStateFromError() {\n    return { failed: true };\n  }\n\n  componentDidCatch(error: Error) {\n    this.props.onError?.(error);\n  }\n\n  render() {\n    if (!this.state.failed) return this.props.children;\n    const { className, height, style } = this.props;\n    return (\n      <div\n        data-slot=\"model-viewer-lazy\"\n        className={cn(\"model-viewer\", className)}\n        style={{ height, ...style }}\n      >\n        <div className=\"viewer-loader\" role=\"alert\">\n          <span>Unable to load the viewer.</span>\n          <button\n            type=\"button\"\n            className=\"underline underline-offset-4\"\n            onClick={() => window.location.reload()}\n          >\n            Reload page\n          </button>\n        </div>\n      </div>\n    );\n  }\n}\n\nexport type { ModelViewerProps } from \"./model-viewer-types\";\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-skeleton.tsx",
      "content": "// Safe for the lazy entry: no Three/R3F runtime imports.\nimport type { ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ModelViewerSkeletonProps = ComponentProps<\"div\"> & {\n  /** Placeholder blocks for the top toolbar. */\n  showToolbar?: boolean;\n  /** Placeholder block for the view cube. */\n  showViewCube?: boolean;\n  /** Screen-reader text, announced by the viewer loader's status region. Pass an empty string to omit it. */\n  label?: string;\n};\n\n/**\n * Viewer-shaped placeholder. Fills its nearest positioned ancestor, so it works\n * as `loadingFallback` or inside any `.model-viewer` frame.\n */\nexport function ModelViewerSkeleton({\n  showToolbar = true,\n  showViewCube = true,\n  label = \"Loading 3D viewer…\",\n  className,\n  ...props\n}: ModelViewerSkeletonProps) {\n  return (\n    <div data-slot=\"model-viewer-skeleton\" className={cn(\"viewer-skeleton\", className)} {...props}>\n      {showToolbar && (\n        <div className=\"viewer-skeleton-toolbar\">\n          <span className=\"viewer-skeleton-block\" style={{ width: 104 }} />\n          <span className=\"viewer-skeleton-block\" style={{ width: 72 }} />\n          <span className=\"viewer-skeleton-block\" style={{ width: 36 }} />\n        </div>\n      )}\n      {showViewCube && <span className=\"viewer-skeleton-block viewer-skeleton-cube\" />}\n      <svg className=\"viewer-skeleton-model\" viewBox=\"0 0 120 120\" aria-hidden=\"true\">\n        <ellipse className=\"viewer-skeleton-shadow\" cx=\"60\" cy=\"108\" rx=\"44\" ry=\"8\" />\n        <path className=\"viewer-skeleton-face\" d=\"M60 14 102 36 60 58 18 36Z\" />\n        <path className=\"viewer-skeleton-face is-left\" d=\"M18 36 60 58v46L18 82Z\" />\n        <path className=\"viewer-skeleton-face is-right\" d=\"M102 36 60 58v46l42-22Z\" />\n      </svg>\n      {label && <span className=\"sr-only\">{label}</span>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/viewer-loader.tsx",
      "content": "\"use client\";\n\n// Shared by the scene and the lazy entry: no Three/R3F runtime imports.\nimport { cn } from \"@/lib/utils\";\nimport type { ModelViewerProps } from \"./model-viewer-types\";\nimport { ViewerLoaderSpinner } from \"./viewer-loader-spinner\";\n\nexport function ViewerLoader({\n  fallback,\n  poster,\n  showFileName,\n  src,\n}: {\n  fallback?: ModelViewerProps[\"loadingFallback\"];\n  poster: boolean;\n  showFileName: boolean;\n  src?: string;\n}) {\n  // Drei's useProgress observes the global loading manager, which also tracks\n  // assets belonging to other viewer instances. This is intentionally\n  // indeterminate until this viewer's own scene reports ready or error.\n  const data = { active: true, progress: 0, item: src ?? \"\", loaded: 0, total: 0 };\n  const custom = typeof fallback === \"function\" ? fallback(data) : fallback;\n  if (fallback !== undefined && custom == null) return null;\n  return (\n    <div className={cn(\"viewer-loader\", poster && \"has-poster\")} role=\"status\" aria-live=\"polite\">\n      {fallback !== undefined ? (\n        custom\n      ) : (\n        <>\n          <ViewerLoaderSpinner />\n          <span>Loading model…</span>\n          {showFileName && src && <small>{fileName(src)}</small>}\n        </>\n      )}\n    </div>\n  );\n}\n\nfunction fileName(path: string) {\n  return path.split(/[?#]/)[0].split(/[\\\\/]/).pop() || \"Model asset\";\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/viewer-loader-spinner.tsx",
      "content": "\"use client\";\n\nimport { LoaderCircle } from \"lucide-react\";\nimport { useLayoutEffect, useRef } from \"react\";\n\n/** Keeps the indeterminate loader's phase stable when one loading view replaces another. */\nexport function ViewerLoaderSpinner() {\n  const spinner = useRef<SVGSVGElement>(null);\n\n  useLayoutEffect(() => {\n    const animation = spinner.current?.getAnimations()[0];\n    if (animation) animation.startTime = 0;\n  }, []);\n\n  return <LoaderCircle ref={spinner} className=\"viewer-loader-spinner\" aria-hidden=\"true\" />;\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-orientation.tsx",
      "content": "\"use client\";\n\nimport { Compass } from \"lucide-react\";\nimport type { ComponentProps } from \"react\";\nimport { DropdownMenu, DropdownMenuItem, DropdownMenuTrigger } from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { useViewerRuntime } from \"./model-viewer-context\";\nimport type { ViewerCameraPreset } from \"./model-viewer-types\";\nimport { DropdownMenuContent } from \"./viewer-popups\";\nimport { ViewerControlButton } from \"./viewer-ui\";\n\nconst directions: ViewerCameraPreset[] = [\n  \"front\",\n  \"right\",\n  \"back\",\n  \"left\",\n  \"top\",\n  \"bottom\",\n  \"isometric\",\n];\n\n/** Keyboard counterpart to the canvas gizmo. Reveals itself when focused. */\nexport function ModelViewerOrientationControls({ className, ...props }: ComponentProps<\"div\">) {\n  const { state, showOrientation } = useViewerRuntime();\n  if (!showOrientation || !state.viewCube || state.mode !== \"orbit\" || state.status !== \"ready\")\n    return null;\n  return (\n    <div\n      data-slot=\"model-viewer-orientation\"\n      className={cn(\"viewer-orientation-controls\", className)}\n      {...props}\n    >\n      <DropdownMenu modal={false}>\n        <DropdownMenuTrigger\n          render={\n            <ViewerControlButton aria-label=\"Orient view\" variant=\"outline\" size=\"sm\">\n              <Compass /> Orient view\n            </ViewerControlButton>\n          }\n        />\n        <DropdownMenuContent align=\"end\">\n          {directions.map((direction) => (\n            <DropdownMenuItem\n              key={direction}\n              onClick={() => {\n                state.setCameraPreset(direction);\n                state.resetView();\n              }}\n            >\n              {direction[0].toUpperCase() + direction.slice(1)} view\n            </DropdownMenuItem>\n          ))}\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-toolbar.tsx",
      "content": "\"use client\";\n\nimport {\n  Box,\n  Camera,\n  ChevronDown,\n  CloudSun,\n  Copy,\n  Download,\n  Footprints,\n  Gauge,\n  Grid2X2,\n  Layers3,\n  LayoutGrid,\n  ListTree,\n  Pause,\n  Play,\n  Rotate3D,\n  RotateCcw,\n  RotateCw,\n  ScanSearch,\n} from \"lucide-react\";\nimport {\n  type ComponentProps,\n  type ReactNode,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  DropdownMenu,\n  DropdownMenuItem,\n  DropdownMenuRadioGroup,\n  DropdownMenuRadioItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport type { ViewerLighting, ViewerMode, ViewerShading, ViewerViewCube } from \"./model-viewer\";\nimport { useOptionalViewerRuntime } from \"./model-viewer-context\";\nimport { DropdownMenuContent } from \"./viewer-popups\";\nimport { ViewerControlButton as Button } from \"./viewer-ui\";\nimport \"./model-viewer.css\";\n\nconst shadingLabels: Record<ViewerShading, string> = {\n  realistic: \"Realistic\",\n  solid: \"Solid\",\n  normals: \"Normals\",\n  wireframe: \"Wireframe\",\n};\n\nexport function ModelViewerToolbar({\n  className,\n  onKeyDown,\n  ref,\n  placement = \"top\",\n  ...props\n}: ComponentProps<\"div\"> & { placement?: \"top\" | \"animation\" | \"static\" }) {\n  const elementRef = useRef<HTMLDivElement>(null);\n  useImperativeHandle(ref, () => elementRef.current!, []);\n  const runtime = useOptionalViewerRuntime();\n  const reportToolbar = runtime?.reportToolbar;\n  const viewerRef = runtime?.viewerRef;\n  useLayoutEffect(() => {\n    const element = elementRef.current;\n    if (!element || !reportToolbar || !viewerRef || placement !== \"top\") return;\n    const measure = () => {\n      const root = viewerRef.current;\n      if (!root) return;\n      const rect = element.getBoundingClientRect();\n      reportToolbar(element, Math.ceil(rect.bottom - root.getBoundingClientRect().top + 12));\n    };\n    const observer = new ResizeObserver(measure);\n    observer.observe(element);\n    if (viewerRef.current) observer.observe(viewerRef.current);\n    measure();\n    return () => {\n      observer.disconnect();\n      reportToolbar(element, null);\n    };\n  }, [reportToolbar, viewerRef, placement, className, props.style]);\n  return (\n    <div\n      ref={elementRef}\n      data-slot=\"model-viewer-toolbar\"\n      role=\"toolbar\"\n      aria-label=\"3D viewer controls\"\n      className={cn(\n        \"viewer-toolbar\",\n        placement === \"top\" && \"viewer-toolbar-top\",\n        placement === \"animation\" && \"viewer-animation-controls\",\n        className,\n      )}\n      {...props}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;\n        if (![\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) return;\n        // Ignore events from portaled menus and editable controls.\n        const target = event.target as HTMLElement;\n        if (!event.currentTarget.contains(target) || !target.closest(\"button\")) return;\n        const buttons = [\n          ...event.currentTarget.querySelectorAll<HTMLButtonElement>(\"button:not(:disabled)\"),\n        ];\n        const current = buttons.indexOf(target.closest(\"button\")!);\n        if (current < 0 || buttons.length === 0) return;\n        const next =\n          event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? buttons.length - 1\n              : (current + (event.key === \"ArrowRight\" ? 1 : -1) + buttons.length) % buttons.length;\n        event.preventDefault();\n        buttons[next].focus();\n      }}\n    />\n  );\n}\n\nexport function ModelViewerToolbarGroup({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: This groups toolbar commands, not form inputs.\n    <div\n      data-slot=\"model-viewer-toolbar-group\"\n      role=\"group\"\n      className={cn(\n        \"viewer-toolbar-group rounded-lg border bg-popover p-1 text-popover-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport type ModelViewerToolbarButtonProps = ComponentProps<typeof Button> & {\n  label: string;\n  active?: boolean;\n  icon?: ReactNode;\n};\n\nexport function ModelViewerToolbarButton({\n  label,\n  active,\n  icon,\n  children,\n  ...props\n}: ModelViewerToolbarButtonProps) {\n  return (\n    <Button\n      data-slot=\"model-viewer-toolbar-button\"\n      data-state={active === undefined ? undefined : active ? \"on\" : \"off\"}\n      size=\"icon-sm\"\n      variant={active ? \"secondary\" : \"ghost\"}\n      aria-label={label}\n      aria-pressed={active}\n      tooltip={label}\n      {...props}\n    >\n      {children ?? icon}\n    </Button>\n  );\n}\n\nexport type ModelViewerControlsProps = Omit<ComponentProps<\"div\">, \"onReset\"> & {\n  mode: ViewerMode;\n  onModeChange: (mode: ViewerMode) => void;\n  shading: ViewerShading;\n  onShadingChange: (shading: ViewerShading) => void;\n  lighting: ViewerLighting;\n  onLightingChange: (lighting: ViewerLighting) => void;\n  grid: boolean;\n  onGridChange: (visible: boolean) => void;\n  floor?: boolean;\n  onFloorChange?: (visible: boolean) => void;\n  autoRotate?: boolean;\n  onAutoRotateChange?: (rotating: boolean) => void;\n  projection: \"perspective\" | \"orthographic\";\n  onProjectionChange: (projection: \"perspective\" | \"orthographic\") => void;\n  viewCube: ViewerViewCube | false;\n  onViewCubeChange: (viewCube: ViewerViewCube | false) => void;\n  inspectorOpen: boolean;\n  onInspectorChange: (open: boolean) => void;\n  onReset: () => void;\n  captureDisabled?: boolean;\n  onCapture: (action: \"copy\" | \"download\") => void;\n};\n\n/** The default controls, also usable outside ModelViewer with controlled state. */\nexport function ModelViewerControls({\n  mode,\n  onModeChange,\n  shading,\n  onShadingChange,\n  lighting,\n  onLightingChange,\n  grid,\n  onGridChange,\n  floor = false,\n  onFloorChange,\n  autoRotate = false,\n  onAutoRotateChange,\n  projection,\n  onProjectionChange,\n  viewCube,\n  onViewCubeChange,\n  inspectorOpen,\n  onInspectorChange,\n  onReset,\n  captureDisabled,\n  onCapture,\n  ...props\n}: ModelViewerControlsProps) {\n  const [captureMenuOpen, setCaptureMenuOpen] = useState(false);\n  return (\n    <ModelViewerToolbar {...props}>\n      <ModelViewerToolbarGroup aria-label=\"Shading\">\n        <DropdownMenu modal={false}>\n          <DropdownMenuTrigger\n            render={\n              <Button\n                type=\"button\"\n                variant=\"ghost\"\n                size=\"sm\"\n                className=\"viewer-shading-trigger\"\n                aria-label={`Shading: ${shading}`}\n              >\n                {shadingLabels[shading]}\n                <ChevronDown />\n              </Button>\n            }\n          />\n          <DropdownMenuContent align=\"start\" sideOffset={6} className=\"min-w-40\">\n            <DropdownMenuRadioGroup\n              aria-label=\"Shading\"\n              value={shading}\n              onValueChange={onShadingChange}\n            >\n              {([\"realistic\", \"solid\", \"normals\", \"wireframe\"] as const).map((option) => (\n                <DropdownMenuRadioItem key={option} value={option} closeOnClick>\n                  {shadingLabels[option]}\n                </DropdownMenuRadioItem>\n              ))}\n            </DropdownMenuRadioGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </ModelViewerToolbarGroup>\n      <ModelViewerToolbarGroup aria-label=\"Interaction mode\">\n        <ModelViewerToolbarButton\n          icon={<Rotate3D />}\n          label=\"Orbit camera\"\n          active={mode === \"orbit\"}\n          onClick={() => onModeChange(\"orbit\")}\n        />\n        <ModelViewerToolbarButton\n          icon={<LayoutGrid />}\n          label=\"Four-view split\"\n          active={mode === \"split\"}\n          onClick={() => onModeChange(\"split\")}\n        />\n        <ModelViewerToolbarButton\n          icon={<Footprints />}\n          label=\"Fly camera\"\n          active={mode === \"firstPerson\"}\n          onClick={() => onModeChange(\"firstPerson\")}\n        />\n        {onAutoRotateChange && (\n          <ModelViewerToolbarButton\n            icon={<RotateCw />}\n            label=\"Rotate automatically\"\n            active={autoRotate}\n            disabled={mode !== \"orbit\"}\n            onClick={() => onAutoRotateChange(!autoRotate)}\n          />\n        )}\n      </ModelViewerToolbarGroup>\n      <ModelViewerToolbarGroup aria-label=\"Scene options\">\n        <DropdownMenu modal={false}>\n          <DropdownMenuTrigger\n            render={\n              <Button\n                type=\"button\"\n                size=\"icon-sm\"\n                variant=\"ghost\"\n                aria-label={`Lighting: ${lighting}`}\n                tooltip=\"Lighting\"\n              >\n                <CloudSun />\n              </Button>\n            }\n          />\n          <DropdownMenuContent className=\"min-w-40\">\n            <DropdownMenuRadioGroup\n              aria-label=\"Lighting\"\n              value={lighting}\n              onValueChange={onLightingChange}\n            >\n              {([\"day\", \"night\", \"outside\"] as const).map((value) => (\n                <DropdownMenuRadioItem key={value} value={value} closeOnClick>\n                  {value === \"outside\"\n                    ? \"Outside sky\"\n                    : value === \"day\"\n                      ? \"Day studio\"\n                      : \"Night studio\"}\n                </DropdownMenuRadioItem>\n              ))}\n            </DropdownMenuRadioGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n        <ModelViewerToolbarButton\n          icon={<Grid2X2 />}\n          label=\"Show grid\"\n          active={grid}\n          onClick={() => onGridChange(!grid)}\n        />\n        {onFloorChange && (\n          <ModelViewerToolbarButton\n            icon={<Layers3 />}\n            label=\"Show floor\"\n            active={floor}\n            onClick={() => onFloorChange(!floor)}\n          />\n        )}\n        <ModelViewerToolbarButton\n          icon={<ScanSearch />}\n          label=\"Orthographic view\"\n          active={projection === \"orthographic\"}\n          onClick={() =>\n            onProjectionChange(projection === \"orthographic\" ? \"perspective\" : \"orthographic\")\n          }\n        />\n        <DropdownMenu modal={false}>\n          <DropdownMenuTrigger\n            render={\n              <Button type=\"button\" size=\"icon-sm\" variant=\"ghost\" aria-label=\"View cube options\">\n                <Box />\n              </Button>\n            }\n          />\n          <DropdownMenuContent className=\"min-w-40\">\n            <DropdownMenuRadioGroup\n              aria-label=\"View cube\"\n              value={viewCube}\n              onValueChange={onViewCubeChange}\n            >\n              {([false, \"drei\", \"asset-studio\"] as const).map((value) => (\n                <DropdownMenuRadioItem key={String(value)} value={value} closeOnClick>\n                  {value === false ? \"Off\" : value === \"drei\" ? \"Drei cube\" : \"Asset Studio\"}\n                </DropdownMenuRadioItem>\n              ))}\n            </DropdownMenuRadioGroup>\n          </DropdownMenuContent>\n        </DropdownMenu>\n        <ModelViewerToolbarButton\n          icon={<ListTree />}\n          label=\"Inspect model\"\n          active={inspectorOpen}\n          onClick={() => onInspectorChange(!inspectorOpen)}\n        />\n        <ModelViewerToolbarButton icon={<RotateCcw />} label=\"Reset view\" onClick={onReset} />\n        <DropdownMenu modal={false} open={captureMenuOpen} onOpenChange={setCaptureMenuOpen}>\n          <DropdownMenuTrigger\n            render={\n              <Button\n                type=\"button\"\n                size=\"icon-sm\"\n                variant={captureMenuOpen ? \"secondary\" : \"ghost\"}\n                aria-label=\"Screenshot options\"\n                title=\"Screenshot options\"\n                disabled={captureDisabled}\n              >\n                <Camera />\n              </Button>\n            }\n          />\n          <DropdownMenuContent align=\"end\" sideOffset={8} className=\"min-w-52\">\n            <div className=\"flex items-center justify-between border-b px-2 py-1.5 text-xs font-medium\">\n              Capture view <span className=\"text-muted-foreground\">PNG</span>\n            </div>\n            <DropdownMenuItem onClick={() => void onCapture(\"copy\")}>\n              <Copy /> Copy to clipboard\n            </DropdownMenuItem>\n            <DropdownMenuItem onClick={() => void onCapture(\"download\")}>\n              <Download /> Download image\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </ModelViewerToolbarGroup>\n    </ModelViewerToolbar>\n  );\n}\n\nconst animationSpeeds = [0.5, 1, 1.5, 2];\n\nexport type ModelViewerAnimationControlsProps = ComponentProps<\"div\"> & {\n  clips: string[];\n  animation: string | null;\n  onAnimationChange: (clip: string) => void;\n  playing: boolean;\n  onPlayingChange: (playing: boolean) => void;\n  speed: number;\n  onSpeedChange: (speed: number) => void;\n  onRestart: () => void;\n};\n\nexport function ModelViewerAnimationControls({\n  clips,\n  animation,\n  onAnimationChange,\n  playing,\n  onPlayingChange,\n  speed,\n  onSpeedChange,\n  onRestart,\n  className,\n  ...props\n}: ModelViewerAnimationControlsProps) {\n  return (\n    <ModelViewerToolbar\n      placement=\"animation\"\n      data-slot=\"model-viewer-animation-controls\"\n      aria-label=\"Animation controls\"\n      className={cn(\"rounded-lg border bg-popover p-1 text-popover-foreground\", className)}\n      {...props}\n    >\n      <ModelViewerToolbarButton\n        icon={playing ? <Pause /> : <Play />}\n        label={playing ? \"Pause animation\" : \"Play animation\"}\n        active={playing}\n        onClick={() => onPlayingChange(!playing)}\n      />\n      <DropdownMenu modal={false}>\n        <DropdownMenuTrigger\n          render={\n            <Button type=\"button\" size=\"sm\" variant=\"ghost\" className=\"viewer-animation-name\">\n              <span className=\"truncate\">{animation ?? \"No animation\"}</span>\n              <ChevronDown />\n            </Button>\n          }\n        />\n        <DropdownMenuContent align=\"start\" sideOffset={6} className=\"min-w-52\">\n          <DropdownMenuRadioGroup\n            aria-label=\"Animation clip\"\n            value={animation}\n            onValueChange={onAnimationChange}\n          >\n            {clips.map((name) => (\n              <DropdownMenuRadioItem key={name} value={name} closeOnClick>\n                {name}\n              </DropdownMenuRadioItem>\n            ))}\n          </DropdownMenuRadioGroup>\n        </DropdownMenuContent>\n      </DropdownMenu>\n      <ModelViewerToolbarButton\n        icon={<RotateCcw />}\n        label=\"Restart animation\"\n        onClick={onRestart}\n      />\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        aria-label={`Animation speed ${speed}×`}\n        title={`Animation speed ${speed}×`}\n        onClick={() =>\n          onSpeedChange(\n            animationSpeeds[(animationSpeeds.indexOf(speed) + 1) % animationSpeeds.length] ?? 1,\n          )\n        }\n      >\n        <Gauge />\n        <span className=\"tabular-nums\">{speed}×</span>\n      </Button>\n    </ModelViewerToolbar>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-inspector.tsx",
      "content": "\"use client\";\n\nimport {\n  Box,\n  ChevronDown,\n  ChevronRight,\n  Crosshair,\n  Image,\n  Layers,\n  Search,\n  Triangle,\n  X,\n} from \"lucide-react\";\nimport { type ComponentProps, useId, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { indexHierarchy } from \"./inspector-hierarchy\";\nimport type { ModelInspection } from \"./model-inspection\";\nimport type { ModelInspectorPosition } from \"./model-viewer-types\";\nimport {\n  ViewerControlButton as Button,\n  type ViewerUiComponents,\n  ViewerUiProvider,\n} from \"./viewer-ui\";\nimport \"./model-viewer.css\";\n\nexport type ModelInspectorProps = ComponentProps<\"aside\"> & {\n  inspection: ModelInspection;\n  selectedMesh?: string | null;\n  onSelectMesh: (id: string | null) => void;\n  onClose?: () => void;\n  /** Side of the viewer used by the positioned inspector. Defaults to right. */\n  position?: ModelInspectorPosition;\n  className?: string;\n  components?: Partial<ViewerUiComponents>;\n};\n\nexport function ModelInspector({\n  inspection,\n  selectedMesh,\n  onSelectMesh,\n  onClose,\n  position = \"right\",\n  className,\n  components,\n  ...props\n}: ModelInspectorProps) {\n  const [query, setQuery] = useState(\"\");\n  const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());\n  const heading = useId();\n  const selected = inspection.nodes.find((node) => node.id === selectedMesh);\n  const hierarchy = useMemo(() => indexHierarchy(inspection.nodes), [inspection.nodes]);\n  const visible = useMemo(() => hierarchy.visible(query, collapsed), [hierarchy, query, collapsed]);\n  const [page, setPage] = useState({ nodes: inspection.nodes, query, limit: 200 });\n  const limit = page.nodes === inspection.nodes && page.query === query ? page.limit : 200;\n  function changeQuery(next: string) {\n    setQuery(next);\n    setPage({ nodes: inspection.nodes, query: next, limit: 200 });\n  }\n  function toggle(id: string) {\n    setCollapsed((current) => {\n      const next = new Set(current);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  }\n\n  return (\n    <ViewerUiProvider components={components}>\n      <aside\n        data-slot=\"model-inspector\"\n        data-position={position}\n        className={cn(\"viewer-inspector\", className)}\n        aria-labelledby={heading}\n        {...props}\n      >\n        <header className=\"inspector-header\">\n          <span className=\"inspector-emblem\">\n            <Box size={18} />\n          </span>\n          <div>\n            <span className=\"inspector-eyebrow\">Scene overview</span>\n            <h3 id={heading}>Model inspector</h3>\n          </div>\n          {onClose && (\n            <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Close inspector\" onClick={onClose}>\n              <X size={16} />\n            </Button>\n          )}\n        </header>\n        <div className=\"inspector-metrics\">\n          {[\n            { label: \"Triangles\", value: inspection.triangles, Icon: Triangle },\n            { label: \"Materials\", value: inspection.materials, Icon: Layers },\n            { label: \"Textures\", value: inspection.textures, Icon: Image },\n          ].map(({ label, value, Icon }) => (\n            <div key={label}>\n              <Icon size={14} />\n              <strong>{value.toLocaleString()}</strong>\n              <span>{label}</span>\n            </div>\n          ))}\n        </div>\n        <section className=\"inspector-dimensions\" aria-label=\"Dimensions\">\n          <div className=\"inspector-section-label\">\n            Dimensions <span>model units</span>\n          </div>\n          <div className=\"inspector-axis-values\">\n            {inspection.dimensions.map((value, index) => (\n              <div key={[\"X\", \"Y\", \"Z\"][index]}>\n                <span data-axis={index}>{[\"X\", \"Y\", \"Z\"][index]}</span>\n                <strong>{Number(value.toPrecision(4))}</strong>\n              </div>\n            ))}\n          </div>\n        </section>\n        <div className=\"inspector-hierarchy-heading\">\n          <span className=\"inspector-section-label\">Hierarchy</span>\n          <span>{inspection.nodes.filter((node) => node.mesh).length} meshes</span>\n        </div>\n        <label className=\"inspector-search\">\n          <Search size={14} />\n          <input\n            aria-label=\"Search hierarchy\"\n            placeholder=\"Find an object…\"\n            value={query}\n            onChange={(event) => changeQuery(event.target.value)}\n          />\n          {query && (\n            <Button\n              size=\"icon-sm\"\n              variant=\"ghost\"\n              aria-label=\"Clear search\"\n              onClick={() => changeQuery(\"\")}\n            >\n              <X size={12} />\n            </Button>\n          )}\n        </label>\n        <ul className=\"inspector-tree\" aria-label=\"Scene hierarchy\">\n          {visible.slice(0, limit).map((node) => {\n            const hasChildren = hierarchy.branches.has(node.id);\n            return (\n              <li\n                data-slot=\"model-inspector-node\"\n                data-state={selectedMesh === node.id ? \"selected\" : undefined}\n                key={node.id}\n                style={{ paddingLeft: Math.min(node.depth, 8) * 12 }}\n                className={cn(selectedMesh === node.id && \"is-selected\")}\n              >\n                {hasChildren ? (\n                  <Button\n                    size=\"icon-sm\"\n                    variant=\"ghost\"\n                    aria-label={`${collapsed.has(node.id) ? \"Expand\" : \"Collapse\"} ${node.name}`}\n                    aria-expanded={!collapsed.has(node.id)}\n                    onClick={() => toggle(node.id)}\n                  >\n                    {collapsed.has(node.id) ? (\n                      <ChevronRight size={12} />\n                    ) : (\n                      <ChevronDown size={12} />\n                    )}\n                  </Button>\n                ) : (\n                  <span className=\"inspector-spacer\" />\n                )}\n                <Button\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"inspector-node min-w-0 flex-1 justify-start\"\n                  disabled={!node.mesh}\n                  aria-pressed={node.mesh ? selectedMesh === node.id : undefined}\n                  onClick={() => onSelectMesh(node.id)}\n                >\n                  {node.mesh ? <Box size={13} /> : <Layers size={13} />}\n                  <span>{node.name}</span>\n                  <small className=\"ml-auto rounded-sm bg-muted px-1 text-xs text-muted-foreground\">\n                    {node.mesh ? \"Mesh\" : node.type}\n                  </small>\n                </Button>\n              </li>\n            );\n          })}\n        </ul>\n        {visible.length > limit && (\n          <Button\n            variant=\"outline\"\n            size=\"sm\"\n            onClick={() => setPage({ nodes: inspection.nodes, query, limit: limit + 200 })}\n          >\n            Show {Math.min(200, visible.length - limit)} more objects\n          </Button>\n        )}\n        {visible.length === 0 && <div className=\"inspector-empty\">No objects match “{query}”.</div>}\n        <footer className=\"inspector-selection\">\n          <Crosshair size={15} />\n          <div>\n            <span>{selected ? \"Selected object\" : \"Nothing selected\"}</span>\n            <strong>{selected?.name ?? \"Choose a mesh to inspect\"}</strong>\n          </div>\n          {selected && (\n            <Button\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label=\"Clear selection\"\n              onClick={() => onSelectMesh(null)}\n            >\n              <X size={14} />\n            </Button>\n          )}\n        </footer>\n      </aside>\n    </ViewerUiProvider>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/viewer-ui.tsx",
      "content": "\"use client\";\n\nimport {\n  type ComponentProps,\n  type ComponentType,\n  createContext,\n  type ReactElement,\n  type ReactNode,\n  useContext,\n  useMemo,\n} from \"react\";\nimport { Button as ShadcnButton } from \"@/components/ui/button\";\nimport { Tooltip, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\n\nimport { TooltipContent } from \"./viewer-popups\";\n\nexport type ViewerTooltipProps = { children: ReactElement; content: ReactNode };\nexport type ViewerUiComponents = {\n  /** Forward the ref, event handlers, aria attributes, and className to the underlying button. */\n  Button: ComponentType<ComponentProps<typeof ShadcnButton>>;\n  /** Render children as the trigger, preserving its ref and event handlers. */\n  Tooltip: ComponentType<ViewerTooltipProps>;\n};\n\nfunction DefaultTooltip({ children, content }: ViewerTooltipProps) {\n  const hasProvider = useContext(ViewerUiContext) !== null;\n  const tooltip = (\n    <Tooltip>\n      <TooltipTrigger render={children} />\n      <TooltipContent>{content}</TooltipContent>\n    </Tooltip>\n  );\n  return hasProvider ? tooltip : <TooltipProvider delay={350}>{tooltip}</TooltipProvider>;\n}\n\nconst defaultComponents: ViewerUiComponents = {\n  Button: ShadcnButton,\n  Tooltip: DefaultTooltip,\n};\nconst ViewerUiContext = createContext<ViewerUiComponents | null>(null);\n\nexport function ViewerUiProvider({\n  components,\n  children,\n}: {\n  components?: Partial<ViewerUiComponents>;\n  children: ReactNode;\n}) {\n  const inherited = useContext(ViewerUiContext) ?? defaultComponents;\n  const value = useMemo(() => ({ ...inherited, ...components }), [inherited, components]);\n  return (\n    <ViewerUiContext.Provider value={value}>\n      <TooltipProvider delay={350}>{children}</TooltipProvider>\n    </ViewerUiContext.Provider>\n  );\n}\n\nexport type ViewerControlButtonProps = ComponentProps<typeof ShadcnButton> & {\n  /** Override the accessible-label tooltip, or use false to disable it. */\n  tooltip?: ReactNode | false;\n};\n\nexport function ViewerControlButton({\n  title,\n  tooltip,\n  type = \"button\",\n  ...props\n}: ViewerControlButtonProps) {\n  const { Button, Tooltip: TooltipImpl } = useContext(ViewerUiContext) ?? defaultComponents;\n  const label = tooltip === undefined ? (title ?? props[\"aria-label\"]) : tooltip;\n  const button = <Button type={type} {...props} />;\n  return label ? <TooltipImpl content={label}>{button}</TooltipImpl> : button;\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/view-cube.tsx",
      "content": "\"use client\";\n\nimport { GizmoHelper, GizmoViewcube } from \"@react-three/drei\";\nimport type { ComponentProps } from \"react\";\nimport { ViewHelper } from \"./view-helper\";\n\nexport type ViewCubePosition = NonNullable<ComponentProps<typeof GizmoHelper>[\"alignment\"]>;\nexport type ViewCubeProps = {\n  variant?: \"drei\" | \"asset-studio\";\n  position?: ViewCubePosition;\n  margin?: [number, number];\n  renderPriority?: number;\n};\n\n/** Compose inside an R3F Canvas with default camera controls. */\nexport function ViewCube({\n  variant = \"asset-studio\",\n  position = \"top-right\",\n  margin = [64, 64],\n  renderPriority = 1,\n}: ViewCubeProps) {\n  return (\n    <GizmoHelper alignment={position} margin={margin} renderPriority={renderPriority}>\n      {variant === \"drei\" ? <GizmoViewcube /> : <ViewHelper />}\n    </GizmoHelper>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-inspection.ts",
      "content": "import {\n  type AnimationClip,\n  AnimationMixer,\n  Box3,\n  type Material,\n  type Mesh,\n  type Object3D,\n  Sphere,\n  type Texture,\n  Vector3,\n} from \"three\";\nimport { clone } from \"three/addons/utils/SkeletonUtils.js\";\n\nconst sampled = new WeakSet<Object3D>();\n\n/** Cache a conservative sampled envelope without changing the loader's shared pose. */\nexport function prepareAnimationBounds(root: Object3D, clips: AnimationClip[]) {\n  if (sampled.has(root) || clips.length === 0) return;\n  sampled.add(root);\n  root.updateMatrixWorld(true);\n  const copy = clone(root);\n  copy.updateMatrixWorld(true);\n  const mixer = new AnimationMixer(copy);\n  const box = new Box3().setFromObject(copy, true);\n  for (const clip of clips) {\n    const action = mixer.clipAction(clip).play();\n    for (let sample = 0; sample <= 16; sample++) {\n      mixer.setTime((clip.duration * sample) / 16);\n      copy.updateMatrixWorld(true);\n      box.union(new Box3().setFromObject(copy, true));\n    }\n    action.stop();\n  }\n  mixer.stopAllAction();\n  mixer.uncacheRoot(copy);\n  copy.updateMatrixWorld(true);\n  box.applyMatrix4(copy.matrixWorld.clone().invert());\n  root.userData.viewerAnimationBounds = [box.min.toArray(), box.max.toArray()];\n}\n\nexport type ModelInspection = {\n  dimensions: [number, number, number];\n  triangles: number;\n  materials: number;\n  textures: number;\n  nodes: { id: string; name: string; type: string; depth: number; mesh: boolean }[];\n};\n\nexport function inspectModel(root: Object3D): ModelInspection {\n  root.updateWorldMatrix(true, true);\n  const materials = new Set<Material>();\n  const textures = new Set<Texture>();\n  let triangles = 0;\n  const nodes: ModelInspection[\"nodes\"] = [];\n  function visit(object: Object3D, path: string, depth: number) {\n    object.userData.viewerNodeId = path;\n    const mesh = object as Mesh;\n    nodes.push({\n      id: path,\n      name: object.name || object.type,\n      type: object.type,\n      depth,\n      mesh: Boolean(mesh.isMesh),\n    });\n    if (mesh.isMesh) {\n      triangles +=\n        ((mesh.geometry.index?.count ?? mesh.geometry.attributes.position?.count ?? 0) / 3) *\n        ((mesh as Mesh & { count?: number }).count ?? 1);\n      for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) {\n        materials.add(material);\n        for (const value of Object.values(material)) if (value?.isTexture) textures.add(value);\n      }\n    }\n    object.children.forEach((child, index) => visit(child, `${path}/${index}`, depth + 1));\n  }\n  visit(root, \"0\", 0);\n  return {\n    dimensions: new Box3().setFromObject(root, true).getSize(new Vector3()).toArray(),\n    triangles: Math.round(triangles),\n    materials: materials.size,\n    textures: textures.size,\n    nodes,\n  };\n}\n\nexport function frameBounds(object: Object3D, aspect: number, fov = 42) {\n  object.updateWorldMatrix(true, true);\n  const box = new Box3().setFromObject(object, true);\n  object.traverse((node) => {\n    const bounds = node.userData.viewerAnimationBounds as [number[], number[]] | undefined;\n    if (bounds)\n      box.union(\n        new Box3(\n          new Vector3().fromArray(bounds[0]),\n          new Vector3().fromArray(bounds[1]),\n        ).applyMatrix4(node.matrixWorld),\n      );\n  });\n  const sphere = box.getBoundingSphere(new Sphere());\n  const radius = Number.isFinite(sphere.radius) && sphere.radius > 0 ? sphere.radius : 0.001;\n  const center = box.isEmpty() ? new Vector3() : sphere.center;\n  const halfFov = (fov * Math.PI) / 360;\n  const limitingAngle = Math.min(halfFov, Math.atan(Math.tan(halfFov) * Math.max(aspect, 0.001)));\n  const distance = (radius / Math.sin(limitingAngle)) * 1.15;\n  return {\n    center,\n    radius,\n    distance,\n    near: Math.max(radius / 1000, 1e-8),\n    far: distance + radius * 100,\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer.tsx",
      "content": "\"use client\";\nimport {\n  ModelViewerAnimationBar,\n  ModelViewerDefaultToolbar,\n  ModelViewerFullscreen,\n  ModelViewerInspector,\n  ModelViewerOverlay,\n  ModelViewerStatus,\n} from \"./model-viewer-parts\";\nimport { ModelViewerRoot } from \"./model-viewer-root\";\nimport { ModelViewerScene } from \"./model-viewer-scene\";\nimport type { ModelViewerProps } from \"./model-viewer-types\";\n\n// Keep existing direct type imports working.\nexport type {\n  ModelInspectorPosition,\n  ModelViewerProps,\n  ViewerCameraOptions,\n  ViewerCameraPreset,\n  ViewerCameraState,\n  ViewerLighting,\n  ViewerMode,\n  ViewerProgress,\n  ViewerShading,\n  ViewerViewCube,\n} from \"./model-viewer-types\";\n\n/** Drop-in preset built entirely from the exported compound parts. */\nexport function ModelViewer({\n  toolbar,\n  overlay,\n  sceneContent,\n  children,\n  showUi = true,\n  showAnimationControls = true,\n  inspectorPosition = \"right\",\n  ...props\n}: ModelViewerProps) {\n  return (\n    <ModelViewerRoot showUi={showUi} {...props}>\n      <ModelViewerScene sceneContent={sceneContent} />\n      {overlay && <ModelViewerOverlay>{overlay}</ModelViewerOverlay>}\n      {showUi && (\n        <>\n          {toolbar === undefined ? <ModelViewerDefaultToolbar /> : toolbar}\n          {showAnimationControls && <ModelViewerAnimationBar />}\n          <ModelViewerStatus />\n          <ModelViewerFullscreen />\n        </>\n      )}\n      <ModelViewerInspector position={inspectorPosition} />\n      {children}\n    </ModelViewerRoot>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer.css",
      "content": "/* Declare the order even when a consumer imports this before Tailwind. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  .model-viewer {\n    --viewer-scene-border: #d4d4d4;\n    container-type: inline-size;\n    container-name: viewer;\n    position: relative;\n    isolation: isolate;\n    width: 100%;\n    height: 620px;\n    min-height: 320px;\n    overflow: hidden;\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    background: #f5f5f5;\n    color: var(--foreground);\n  }\n  .model-viewer.is-night {\n    --viewer-scene-border: #40516a;\n    background: #17243a;\n  }\n  .model-viewer.is-outside {\n    --viewer-scene-border: #afc4cb;\n    background: #dbe9ee;\n  }\n  .model-viewer canvas {\n    display: block;\n  }\n  .model-viewer:fullscreen,\n  .model-viewer.is-expanded {\n    width: 100vw;\n    height: 100vh !important;\n    margin: 0;\n    border: 0;\n    border-radius: 0;\n  }\n  .model-viewer.is-expanded {\n    position: fixed;\n    inset: 0;\n    z-index: 100;\n  }\n  .viewer-stage {\n    position: absolute;\n    inset: 0;\n  }\n  .viewer-canvas {\n    position: absolute !important;\n    inset: 0;\n    z-index: 0;\n    background: transparent !important;\n  }\n  .viewer-scenes {\n    position: absolute;\n    inset: 0;\n    z-index: 1;\n    display: flex;\n    pointer-events: none;\n  }\n  .viewer-scene {\n    position: relative;\n    flex: 1;\n    min-width: 0;\n    pointer-events: none;\n  }\n  .model-viewer:not([data-viewer-mode=\"split\"]) .viewer-scene-secondary {\n    display: none;\n  }\n  .viewer-view {\n    position: absolute;\n    inset: 0;\n    pointer-events: auto;\n    touch-action: none;\n  }\n  .model-viewer[data-viewer-mode=\"split\"] .viewer-scenes {\n    display: grid;\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n    grid-template-rows: repeat(2, minmax(0, 1fr));\n  }\n  .model-viewer[data-viewer-mode=\"split\"] .viewer-scenes {\n    top: var(--viewer-toolbar-offset, 0px);\n  }\n  .model-viewer[data-viewer-mode=\"split\"] .viewer-scene:nth-child(even) {\n    border-left: 1px solid var(--viewer-scene-border);\n  }\n  .model-viewer[data-viewer-mode=\"split\"] .viewer-scene:nth-child(n + 3) {\n    border-top: 1px solid var(--viewer-scene-border);\n  }\n  .viewer-pane-label {\n    position: absolute;\n    z-index: 2;\n    top: 12px;\n    left: 12px;\n    padding: 5px 8px;\n    border: 1px solid var(--border);\n    border-radius: calc(var(--radius) - 4px);\n    background: var(--popover);\n    color: var(--popover-foreground);\n    font-size: 12px;\n    font-weight: 500;\n    pointer-events: none;\n  }\n  .viewer-help,\n  .viewer-fullscreen,\n  .viewer-animation-controls,\n  .viewer-overlay-slot {\n    position: absolute;\n    z-index: 3;\n  }\n  .viewer-toolbar {\n    display: flex;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 8px;\n  }\n  .viewer-toolbar-top {\n    position: absolute;\n    z-index: 3;\n    top: 14px;\n    left: 14px;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 8px;\n    max-width: calc(100% - 68px);\n  }\n  .viewer-toolbar-group,\n  .viewer-animation-controls {\n    top: auto;\n    display: flex;\n    align-items: center;\n    gap: 2px;\n  }\n\n  .viewer-animation-controls {\n    right: 58px;\n    bottom: 14px;\n    max-width: min(390px, calc(100% - 28px));\n  }\n  .viewer-animation-name {\n    min-width: 0;\n    max-width: 180px;\n    flex-shrink: 1;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  .viewer-help {\n    bottom: 14px;\n    left: 14px;\n    max-width: calc(100% - 76px);\n\n    pointer-events: none;\n  }\n\n  .viewer-fullscreen {\n    right: 14px;\n    bottom: 14px;\n  }\n  .viewer-orientation-controls {\n    position: absolute;\n    top: max(54px, var(--viewer-toolbar-offset, 0px));\n    right: 14px;\n    z-index: 4;\n  }\n  .viewer-orientation-controls:not(:focus-within):not(:has([data-popup-open])) {\n    clip-path: inset(50%);\n    width: 1px;\n    height: 1px;\n    overflow: hidden;\n    white-space: nowrap;\n  }\n  .viewer-overlay-slot {\n    inset: 0;\n    pointer-events: none;\n  }\n  .viewer-poster {\n    position: absolute;\n    inset: 0;\n    z-index: 0;\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n  .viewer-loader {\n    position: absolute;\n    inset: 0;\n    z-index: 4;\n    display: grid;\n    place-content: center;\n    justify-items: center;\n    gap: 8px;\n    background: var(--background);\n    color: var(--popover-foreground);\n    font-size: 14px;\n  }\n  .viewer-loader.has-poster {\n    background: color-mix(in srgb, var(--background) 72%, transparent);\n    backdrop-filter: blur(8px);\n  }\n  .viewer-loader small {\n    max-width: 220px;\n    overflow: hidden;\n    color: inherit;\n    opacity: 0.65;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .viewer-loader-spinner {\n    width: 24px;\n    height: 24px;\n    color: var(--muted-foreground);\n    animation: viewer-spin 1s linear infinite;\n  }\n  .viewer-error-wrap {\n    position: absolute;\n    inset: 0;\n    z-index: 5;\n    display: grid;\n    place-items: center;\n    padding: 24px;\n  }\n  .viewer-skeleton {\n    --viewer-skeleton-fill: color-mix(in srgb, var(--muted-foreground) 16%, transparent);\n    position: absolute;\n    inset: 0;\n    z-index: 4;\n    display: grid;\n    place-items: center;\n    pointer-events: none;\n  }\n  .viewer-skeleton-block,\n  .viewer-skeleton-model {\n    animation: viewer-skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n  }\n  .viewer-skeleton-block {\n    display: block;\n    height: 36px;\n    border-radius: calc(var(--radius) - 2px);\n    background: var(--viewer-skeleton-fill);\n  }\n  .viewer-skeleton-toolbar {\n    position: absolute;\n    top: 14px;\n    left: 14px;\n    display: flex;\n    gap: 8px;\n  }\n  .viewer-skeleton-cube {\n    position: absolute;\n    top: 14px;\n    right: 14px;\n    width: 56px;\n    height: 56px;\n  }\n  .viewer-skeleton-model {\n    width: min(40%, 180px);\n    height: auto;\n  }\n  .viewer-skeleton-face {\n    fill: var(--viewer-skeleton-fill);\n    stroke: color-mix(in srgb, var(--muted-foreground) 24%, transparent);\n    stroke-width: 1.5;\n    stroke-linejoin: round;\n  }\n  .viewer-skeleton-face.is-left {\n    fill: color-mix(in srgb, var(--muted-foreground) 24%, transparent);\n  }\n  .viewer-skeleton-face.is-right {\n    fill: color-mix(in srgb, var(--muted-foreground) 10%, transparent);\n  }\n  .viewer-skeleton-shadow {\n    fill: color-mix(in srgb, var(--muted-foreground) 10%, transparent);\n  }\n  @keyframes viewer-skeleton-pulse {\n    50% {\n      opacity: 0.5;\n    }\n  }\n  @keyframes viewer-spin {\n    to {\n      transform: rotate(360deg);\n    }\n  }\n  @container viewer (max-width: 680px) {\n    .viewer-toolbar-top {\n      top: 9px;\n      left: 9px;\n      gap: 4px;\n      max-width: calc(100% - 58px);\n    }\n\n    .viewer-animation-controls {\n      top: auto;\n      bottom: 9px;\n      right: 53px;\n      max-width: calc(100% - 18px);\n    }\n\n    .viewer-animation-name {\n      max-width: 128px;\n    }\n    .viewer-help {\n      bottom: 9px;\n      left: 9px;\n      max-width: calc(100% - 62px);\n    }\n    .viewer-fullscreen {\n      right: 9px;\n      bottom: 9px;\n    }\n    .model-viewer:has(.viewer-animation-controls) .viewer-help {\n      bottom: 62px;\n    }\n    .viewer-pane-label {\n      top: 8px;\n      left: 8px;\n    }\n  }\n  .viewer-inspector {\n    position: absolute;\n    z-index: 4;\n    top: max(14px, var(--viewer-toolbar-offset, 0px));\n    width: min(330px, calc(100% - 28px));\n    max-height: calc(100% - max(14px, var(--viewer-toolbar-offset, 0px)) - 80px);\n    display: flex;\n    flex-direction: column;\n    overflow: hidden;\n    border: 1px solid var(--border);\n    border-radius: var(--radius);\n    background: var(--popover);\n    color: var(--popover-foreground);\n    box-shadow:\n      0 4px 6px -1px rgb(0 0 0 / 0.1),\n      0 2px 4px -2px rgb(0 0 0 / 0.1);\n    font-size: 11px;\n  }\n  .viewer-inspector[data-position=\"left\"] {\n    left: 14px;\n  }\n  .viewer-inspector[data-position=\"right\"] {\n    right: 14px;\n  }\n  .inspector-header {\n    display: flex;\n    align-items: center;\n    gap: 10px;\n    padding: 15px 16px;\n    border-bottom: 1px solid var(--border);\n  }\n  .inspector-header > button {\n    margin-left: auto;\n  }\n  .inspector-emblem {\n    display: grid;\n    place-items: center;\n    width: 35px;\n    height: 35px;\n    border: 1px solid var(--border);\n    border-radius: calc(var(--radius) - 2px);\n    background: var(--secondary);\n    color: var(--primary);\n  }\n  .inspector-eyebrow {\n    font-size: 9px;\n    font-weight: 600;\n    text-transform: uppercase;\n    letter-spacing: 0.12em;\n    color: var(--muted-foreground);\n  }\n  .inspector-header h3 {\n    margin: 2px 0 0;\n    font-size: 14px;\n    font-weight: 650;\n    letter-spacing: -0.02em;\n  }\n  .inspector-metrics {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: 6px;\n    margin: 13px 14px;\n  }\n  .inspector-metrics > div {\n    display: grid;\n    gap: 4px;\n    padding: 10px;\n    border: 1px solid var(--border);\n    border-radius: calc(var(--radius) - 2px);\n    background: color-mix(in srgb, var(--muted) 40%, transparent);\n  }\n  .inspector-metrics svg {\n    color: var(--muted-foreground);\n  }\n  .inspector-metrics strong {\n    font-size: 18px;\n    font-weight: 650;\n    font-variant-numeric: tabular-nums;\n    letter-spacing: -0.04em;\n  }\n  .inspector-metrics span {\n    font-size: 9px;\n    color: var(--muted-foreground);\n  }\n  .inspector-dimensions {\n    margin: 0 14px 14px;\n  }\n  .inspector-section-label {\n    display: flex;\n    justify-content: space-between;\n    font-size: 10px;\n    font-weight: 650;\n  }\n  .inspector-section-label > span {\n    font-size: 9px;\n    font-weight: 400;\n    color: var(--muted-foreground);\n  }\n  .inspector-axis-values {\n    display: grid;\n    grid-template-columns: repeat(3, 1fr);\n    gap: 6px;\n    margin-top: 7px;\n  }\n  .inspector-axis-values > div {\n    display: flex;\n    align-items: center;\n    gap: 7px;\n    padding: 6px 8px;\n    border: 1px solid var(--border);\n    border-radius: 6px;\n  }\n  .inspector-axis-values strong {\n    font-family: ui-monospace, monospace;\n    font-size: 10px;\n    font-weight: 500;\n    overflow-wrap: anywhere;\n  }\n  .inspector-axis-values [data-axis] {\n    font-size: 9px;\n    font-weight: 750;\n  }\n  .inspector-axis-values [data-axis=\"0\"] {\n    color: #b85b63;\n  }\n  .inspector-axis-values [data-axis=\"1\"] {\n    color: #58855c;\n  }\n  .inspector-axis-values [data-axis=\"2\"] {\n    color: #527da9;\n  }\n  .inspector-hierarchy-heading {\n    display: flex;\n    justify-content: space-between;\n    padding: 12px 16px 8px;\n    border-top: 1px solid var(--border);\n  }\n  .inspector-hierarchy-heading > span:last-child {\n    color: var(--muted-foreground);\n    font-size: 9px;\n  }\n  .inspector-search {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    margin: 0 14px 8px;\n    padding: 6px 9px;\n    border: 1px solid var(--border);\n    border-radius: 7px;\n    color: var(--muted-foreground);\n  }\n  .inspector-search:focus-within {\n    outline: 2px solid var(--ring);\n    outline-offset: 2px;\n  }\n  .inspector-search input {\n    width: 100%;\n    min-width: 0;\n    outline: none;\n    background: transparent;\n    color: var(--foreground);\n    font-size: 11px;\n  }\n  .inspector-tree {\n    list-style: none;\n    margin: 0;\n    padding: 0 10px 10px;\n    overflow-y: auto;\n    min-height: 60px;\n    flex: 1;\n    scrollbar-width: thin;\n  }\n  .inspector-tree li {\n    display: flex;\n    align-items: center;\n    border-radius: 6px;\n  }\n  .inspector-tree li.is-selected {\n    background: var(--secondary);\n    box-shadow: inset 2px 0 var(--primary);\n  }\n\n  .inspector-spacer {\n    width: 1.75rem;\n    flex-shrink: 0;\n  }\n  .inspector-node {\n    flex: 1 1 0%;\n    min-width: 0;\n    justify-content: flex-start;\n  }\n\n  .inspector-node > span {\n    overflow: hidden;\n    text-overflow: ellipsis;\n  }\n\n  .inspector-selection {\n    display: flex;\n    align-items: center;\n    gap: 9px;\n    padding: 11px 14px;\n    margin: 0;\n    border-top: 1px solid var(--border);\n    background: color-mix(in srgb, var(--secondary) 30%, transparent);\n  }\n  .inspector-selection > div {\n    min-width: 0;\n    flex: 1;\n    display: grid;\n    gap: 2px;\n  }\n  .inspector-selection span {\n    font-size: 9px;\n    color: var(--muted-foreground);\n  }\n  .inspector-selection strong {\n    font-size: 10px;\n    font-weight: 550;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n  .inspector-empty {\n    padding: 18px 14px;\n    color: var(--muted-foreground);\n  }\n  .viewer-inspector {\n    overflow-y: auto;\n    scrollbar-width: thin;\n  }\n  .viewer-inspector > :not(.inspector-tree) {\n    flex-shrink: 0;\n  }\n  @container viewer (max-width: 680px) {\n    .viewer-inspector {\n      max-height: calc(100% - max(9px, var(--viewer-toolbar-offset, 0px)) - 64px);\n    }\n    .viewer-inspector[data-position=\"left\"] {\n      left: 9px;\n    }\n    .viewer-inspector[data-position=\"right\"] {\n      right: 9px;\n    }\n    .inspector-header {\n      padding: 10px 12px;\n    }\n    .inspector-metrics {\n      margin: 9px 12px;\n    }\n    .inspector-metrics > div {\n      padding: 7px;\n      gap: 2px;\n    }\n    .inspector-metrics svg {\n      display: none;\n    }\n    .inspector-dimensions {\n      margin: 0 12px 9px;\n    }\n    .inspector-hierarchy-heading {\n      padding: 8px 12px 6px;\n    }\n    .inspector-selection {\n      padding: 8px 12px;\n    }\n  }\n  @media (prefers-reduced-motion: reduce) {\n    .viewer-loader-spinner,\n    .viewer-skeleton-block,\n    .viewer-skeleton-model {\n      animation: none;\n    }\n  }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/view-helper.tsx",
      "content": "import { useGizmoContext } from \"@react-three/drei\";\nimport { useThree } from \"@react-three/fiber\";\nimport { type PointerEvent, useEffect, useMemo, useState } from \"react\";\nimport { CanvasTexture, Vector3 } from \"three\";\n\nconst axes = [\n  { color: \"#ff4967\", position: [1, 0, 0] as const, rotation: [0, 0, 0] as const },\n  { color: \"#7ee052\", position: [0, 1, 0] as const, rotation: [0, 0, Math.PI / 2] as const },\n  { color: \"#4a83ff\", position: [0, 0, 1] as const, rotation: [0, -Math.PI / 2, 0] as const },\n] as const;\n\nexport function ViewHelper() {\n  return (\n    <group scale={40}>\n      {axes.map((axis) => (\n        <group key={axis.color} rotation={axis.rotation}>\n          <mesh position={[0.4, 0, 0]}>\n            <boxGeometry args={[0.8, 0.04, 0.04]} />\n            <meshBasicMaterial color={axis.color} toneMapped={false} />\n          </mesh>\n        </group>\n      ))}\n      {axes.map((axis) => (\n        <AxisPoint key={`positive-${axis.color}`} color={axis.color} position={axis.position} />\n      ))}\n      {axes.map((axis) => (\n        <AxisPoint\n          key={`negative-${axis.color}`}\n          color=\"#252525\"\n          opacity={0.25}\n          position={axis.position.map((value) => -value) as [number, number, number]}\n        />\n      ))}\n    </group>\n  );\n}\n\nfunction AxisPoint({\n  color,\n  opacity = 1,\n  position,\n}: {\n  color: string;\n  opacity?: number;\n  position: readonly [number, number, number];\n}) {\n  const gl = useThree((state) => state.gl);\n  const { tweenCamera } = useGizmoContext();\n  const [hovered, setHovered] = useState(false);\n  const texture = useMemo(() => {\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = 64;\n    canvas.height = 64;\n    const context = canvas.getContext(\"2d\");\n    context?.beginPath();\n    context?.arc(32, 32, 18, 0, Math.PI * 2);\n    if (context) context.fillStyle = color;\n    context?.fill();\n    const result = new CanvasTexture(canvas);\n    result.anisotropy = gl.capabilities.getMaxAnisotropy() || 1;\n    return result;\n  }, [color, gl]);\n\n  useEffect(() => () => texture.dispose(), [texture]);\n\n  function activate(event: PointerEvent) {\n    event.stopPropagation();\n    tweenCamera(new Vector3(...position));\n  }\n\n  return (\n    <sprite\n      position={position}\n      scale={hovered ? 1.05 : 0.82}\n      onPointerDown={activate}\n      onPointerOver={(event) => {\n        event.stopPropagation();\n        setHovered(true);\n      }}\n      onPointerOut={(event) => {\n        event.stopPropagation();\n        setHovered(false);\n      }}\n    >\n      <spriteMaterial\n        map={texture}\n        alphaTest={0.15}\n        opacity={opacity}\n        transparent\n        toneMapped={false}\n      />\n    </sprite>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/index.ts",
      "content": "export type { ModelInspection } from \"./model-inspection\";\nexport type { ModelInspectorProps } from \"./model-inspector\";\nexport { ModelInspector } from \"./model-inspector\";\nexport type {\n  ModelInspectorPosition,\n  ModelViewerProps,\n  ViewerCameraOptions,\n  ViewerCameraPreset,\n  ViewerCameraState,\n  ViewerLighting,\n  ViewerMode,\n  ViewerProgress,\n  ViewerShading,\n  ViewerViewCube,\n} from \"./model-viewer\";\nexport { ModelViewer } from \"./model-viewer\";\nexport { useModelViewer, useModelViewerCamera } from \"./model-viewer-context\";\nexport { ModelViewerOrientationControls } from \"./model-viewer-orientation\";\nexport type { ModelViewerInspectorProps } from \"./model-viewer-parts\";\nexport {\n  ModelViewerAnimationBar,\n  ModelViewerDefaultToolbar,\n  ModelViewerFullscreen,\n  ModelViewerInspector,\n  ModelViewerOverlay,\n  ModelViewerStatus,\n} from \"./model-viewer-parts\";\nexport { ModelViewerRoot } from \"./model-viewer-root\";\nexport { ModelViewerScene } from \"./model-viewer-scene\";\nexport type { ModelViewerSkeletonProps } from \"./model-viewer-skeleton\";\nexport { ModelViewerSkeleton } from \"./model-viewer-skeleton\";\nexport type {\n  ModelViewerAnimationControlsProps,\n  ModelViewerControlsProps,\n  ModelViewerToolbarButtonProps,\n} from \"./model-viewer-toolbar\";\nexport {\n  ModelViewerAnimationControls,\n  ModelViewerControls,\n  ModelViewerToolbar,\n  ModelViewerToolbarButton,\n  ModelViewerToolbarGroup,\n} from \"./model-viewer-toolbar\";\nexport type {\n  ModelViewerRootProps,\n  ModelViewerState,\n} from \"./model-viewer-types\";\nexport type { ViewCubePosition, ViewCubeProps } from \"./view-cube\";\nexport { ViewCube } from \"./view-cube\";\nexport type {\n  ViewerControlButtonProps,\n  ViewerTooltipProps,\n  ViewerUiComponents,\n} from \"./viewer-ui\";\nexport { ViewerControlButton, ViewerUiProvider } from \"./viewer-ui\";\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-root.tsx",
      "content": "\"use client\";\nimport type { CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ModelViewerContext } from \"./model-viewer-context\";\nimport type { ModelViewerRootProps } from \"./model-viewer-types\";\nimport { useModelViewerRuntime } from \"./use-model-viewer-runtime\";\nimport { ViewerUiProvider } from \"./viewer-ui\";\nimport \"./model-viewer.css\";\n\n/** Owns state and layout only. Mount exactly one ModelViewerScene inside it. */\nexport function ModelViewerRoot(props: ModelViewerRootProps) {\n  const runtime = useModelViewerRuntime(props);\n  const { state, root, viewerRef, viewerKey, toolbarOffset } = runtime;\n  return (\n    <ModelViewerContext.Provider value={runtime}>\n      <ViewerUiProvider components={root.components}>\n        {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: The runtime role is group or dialog, both of which support accessible naming. */}\n        <div\n          ref={viewerRef}\n          data-slot=\"model-viewer\"\n          data-state={state.status}\n          data-rendering={state.renderingPaused ? \"paused\" : \"active\"}\n          data-viewer-mode={state.mode}\n          data-viewer-key={viewerKey}\n          aria-label={runtime.accessibleLabel}\n          aria-labelledby={runtime.ariaLabelledBy}\n          className={cn(\n            \"model-viewer\",\n            toolbarOffset > 0 && \"has-toolbar\",\n            state.lighting === \"night\" && \"is-night\",\n            state.lighting === \"outside\" && \"is-outside\",\n            root.isExpanded && \"is-expanded\",\n            root.className,\n          )}\n          style={\n            {\n              \"--viewer-toolbar-offset\": `${toolbarOffset}px`,\n              height: root.height,\n              ...root.style,\n            } as CSSProperties\n          }\n          {...root.props}\n          role={root.isExpanded ? \"dialog\" : (root.props.role ?? \"group\")}\n          aria-modal={root.isExpanded ? true : root.props[\"aria-modal\"]}\n          tabIndex={root.isExpanded ? -1 : root.props.tabIndex}\n        >\n          {root.children}\n        </div>\n      </ViewerUiProvider>\n    </ModelViewerContext.Provider>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-scene.tsx",
      "content": "\"use client\";\n\nimport {\n  Center,\n  Environment,\n  Grid,\n  Lightformer,\n  OrthographicCamera,\n  PerformanceMonitor,\n  PerspectiveCamera,\n  PointerLockControls,\n  Preload,\n} from \"@react-three/drei\";\nimport { Canvas, useThree } from \"@react-three/fiber\";\nimport { FileWarning } from \"lucide-react\";\nimport {\n  Component,\n  type ComponentProps,\n  type ReactNode,\n  Suspense,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport type { Group, Object3D } from \"three\";\nimport type { GLTFLoader } from \"three-stdlib\";\nimport { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { cn } from \"@/lib/utils\";\nimport { frameBounds, type ModelInspection } from \"./model-inspection\";\nimport type { ViewerCameraStore } from \"./model-viewer-camera\";\nimport { viewerBackgroundColor } from \"./model-viewer-colors\";\nimport { useViewerRuntime } from \"./model-viewer-context\";\nimport { ModelViewerOrientationControls } from \"./model-viewer-orientation\";\nimport {\n  type ModelViewerProps,\n  splitPanes,\n  type ViewerCameraPreset,\n  type ViewerCameraState,\n  type ViewerLighting,\n  type ViewerMode,\n  type ViewerShading,\n  type ViewerViewCube,\n  type ViewFace,\n} from \"./model-viewer-types\";\nimport { OutsideSky } from \"./outside-sky\";\nimport { CameraRig, DragLook, FirstPersonMovement, FitStaticCamera } from \"./scene-camera\";\nimport { SceneObject } from \"./scene-model\";\nimport { RedrawAfterResize, ScissorView } from \"./scene-renderer\";\nimport { ViewCube, type ViewCubePosition } from \"./view-cube\";\nimport { ViewerLoader } from \"./viewer-loader\";\nimport { ViewerControlButton } from \"./viewer-ui\";\nimport \"./model-viewer.css\";\n\nclass ViewerErrorBoundary extends Component<\n  {\n    children: ReactNode;\n    fallback?: ModelViewerProps[\"errorFallback\"];\n    onError?: (error: Error) => void;\n    retry?: () => void;\n  },\n  { error: Error | null }\n> {\n  state = { error: null as Error | null };\n  static getDerivedStateFromError(error: Error) {\n    return { error };\n  }\n  componentDidCatch(error: Error) {\n    this.props.onError?.(error);\n  }\n  render() {\n    if (!this.state.error) return this.props.children;\n    const custom =\n      typeof this.props.fallback === \"function\"\n        ? this.props.fallback(this.state.error)\n        : this.props.fallback;\n    if (this.props.fallback !== undefined && custom == null) return null;\n    return (\n      <div className=\"viewer-error-wrap\">\n        {this.props.fallback !== undefined ? (\n          custom\n        ) : (\n          <Alert className=\"max-w-sm p-5\">\n            <FileWarning className=\"text-muted-foreground\" aria-hidden=\"true\" />\n            <AlertTitle>Unable to load model</AlertTitle>\n            <AlertDescription>\n              <p>Check that the model is available and is a valid GLTF or GLB file.</p>\n              {this.props.retry && (\n                <ViewerControlButton variant=\"outline\" size=\"sm\" onClick={this.props.retry}>\n                  Retry loading model\n                </ViewerControlButton>\n              )}\n            </AlertDescription>\n          </Alert>\n        )}\n      </div>\n    );\n  }\n}\n\nexport function ModelViewerScene({\n  className,\n  children,\n  sceneContent,\n  ...props\n}: ComponentProps<\"div\"> & { sceneContent?: ReactNode }) {\n  const {\n    viewerRef,\n    canvasRef,\n    enableCapture,\n    setCaptureReady,\n    viewerKey,\n    cameraStore,\n    toolbarOffset,\n    setSceneMounted,\n    src,\n    mode,\n    lighting,\n    shading,\n    grid,\n    floor,\n    floorColor,\n    cubes,\n    cameraPreset,\n    resetToken,\n    showUi,\n    showOrientation,\n    viewCube,\n    viewCubePosition,\n    viewCubeMargin,\n    projection,\n    effectiveAutoRotate,\n    autoRotateSpeed,\n    effectiveAnimation,\n    effectiveAnimationPlaying,\n    animationSpeed,\n    animationResetToken,\n    loopAnimation,\n    environment,\n    poster,\n    loadingFallback,\n    showFileName,\n    errorFallback,\n    useDraco,\n    useMeshopt,\n    extendLoader,\n    loaded,\n    status,\n    quality,\n    setQuality,\n    onPerformanceChange,\n    onCameraChange,\n    selectedMesh,\n    setSelectedMesh,\n    reportInspection,\n    pointerLockAvailable,\n    setLocked,\n    paneTracks,\n    markReady,\n    reportAnimations,\n    fail,\n    showRetry,\n    retry,\n    retryToken,\n    renderingPaused,\n  } = useViewerRuntime();\n  useEffect(() => {\n    setSceneMounted(true);\n    return () => {\n      setSceneMounted(false);\n      canvasRef.current = null;\n    };\n  }, [setSceneMounted, canvasRef]);\n  const panes = mode === \"split\" ? splitPanes : [{}];\n  return (\n    <div data-slot=\"model-viewer-scene\" className={cn(\"viewer-stage\", className)} {...props}>\n      {poster && !loaded && (\n        <img className=\"viewer-poster\" src={poster} alt=\"\" aria-hidden=\"true\" />\n      )}\n      <ViewerErrorBoundary\n        key={`${src ?? \"demo\"}:${mode}:${retryToken}`}\n        fallback={errorFallback}\n        onError={fail}\n        retry={showRetry && src ? retry : undefined}\n      >\n        <div className=\"viewer-scenes\">\n          {panes.map((pane, index) => {\n            const paneKey = `${viewerKey}-pane-${index}`;\n            return (\n              <div\n                className={cn(\n                  \"viewer-scene\",\n                  index === 0 ? \"viewer-scene-primary\" : \"viewer-scene-secondary\",\n                )}\n                key={`${mode}-${pane.face ?? \"primary\"}`}\n              >\n                <div ref={paneTracks[index]} className=\"viewer-view\" data-viewer-pane={paneKey} />\n                {showUi && mode === \"split\" && (\n                  <span className=\"viewer-pane-label\">{pane.label}</span>\n                )}\n              </div>\n            );\n          })}\n        </div>\n        <Canvas\n          key={String(enableCapture)}\n          className=\"viewer-canvas\"\n          shadows={floor}\n          dpr={quality}\n          frameloop={renderingPaused ? \"never\" : effectiveAutoRotate ? \"always\" : \"demand\"}\n          gl={{\n            antialias: true,\n            alpha: true,\n            preserveDrawingBuffer: enableCapture,\n            powerPreference: \"high-performance\",\n          }}\n          eventSource={viewerRef}\n          onCreated={({ gl }) => {\n            canvasRef.current = gl.domElement;\n            setCaptureReady(gl.getContext().getContextAttributes()?.preserveDrawingBuffer === true);\n            gl.setClearAlpha(0);\n          }}\n        >\n          <RedrawAfterResize />\n          <PerformanceMonitor\n            onChange={({ factor }) => {\n              setQuality(1 + factor);\n              onPerformanceChange?.(factor);\n            }}\n          />\n          {panes.map((pane, index) => {\n            const paneKey = `${viewerKey}-pane-${index}`;\n            return (\n              <ScissorView\n                key={`${mode}-${pane.face ?? \"primary\"}`}\n                track={paneTracks[index]}\n                index={index + 1}\n                clearColor={viewerBackgroundColor(lighting)}\n              >\n                <ViewerScene\n                  lighting={lighting}\n                  shading={shading}\n                  grid={grid}\n                  floor={floor}\n                  floorColor={floorColor}\n                  src={src}\n                  cubes={cubes}\n                  mode={mode}\n                  face={pane.face}\n                  cameraPreset={pane.face ?? cameraPreset}\n                  resetToken={resetToken}\n                  autoRotate={index === 0 && effectiveAutoRotate}\n                  autoRotateSpeed={autoRotateSpeed}\n                  showOrientation={index === 0 && showOrientation && mode !== \"split\"}\n                  viewCube={viewCube}\n                  viewCubePosition={viewCubePosition}\n                  viewCubeMargin={viewCubeMargin}\n                  toolbarVisible={toolbarOffset > 0}\n                  projection={projection}\n                  onInspect={index === 0 ? reportInspection : undefined}\n                  selectedMesh={selectedMesh}\n                  onSelectMesh={setSelectedMesh}\n                  pointerLockAvailable={pointerLockAvailable}\n                  paneSelector={`[data-viewer-pane=\"${paneKey}\"]`}\n                  environment={environment}\n                  animation={effectiveAnimation}\n                  animationPlaying={effectiveAnimationPlaying}\n                  animationSpeed={animationSpeed}\n                  animationResetToken={animationResetToken}\n                  loopAnimation={loopAnimation}\n                  useDraco={useDraco}\n                  useMeshopt={useMeshopt}\n                  extendLoader={extendLoader}\n                  onReady={() => markReady(index)}\n                  onAnimations={reportAnimations}\n                  onCameraChange={index === 0 ? onCameraChange : undefined}\n                  cameraStore={index === 0 ? cameraStore : undefined}\n                  sceneContent={index === 0 ? sceneContent : undefined}\n                  onLockChange={index === 0 ? setLocked : undefined}\n                />\n              </ScissorView>\n            );\n          })}\n          <Preload all />\n        </Canvas>\n      </ViewerErrorBoundary>\n\n      <ModelViewerOrientationControls />\n\n      {status === \"loading\" && (\n        <ViewerLoader\n          fallback={loadingFallback}\n          poster={Boolean(poster)}\n          showFileName={showFileName}\n          src={src}\n        />\n      )}\n\n      {children}\n    </div>\n  );\n}\n\nfunction ViewerScene({\n  lighting,\n  shading,\n  grid,\n  floor,\n  floorColor,\n  src,\n  cubes,\n  mode,\n  face,\n  cameraPreset,\n  resetToken,\n  autoRotate,\n  autoRotateSpeed,\n  showOrientation,\n  viewCube,\n  viewCubePosition,\n  viewCubeMargin,\n  toolbarVisible,\n  projection,\n  onInspect,\n  selectedMesh,\n  onSelectMesh,\n  pointerLockAvailable,\n  paneSelector,\n  environment,\n  animation,\n  animationPlaying,\n  animationSpeed,\n  animationResetToken,\n  loopAnimation,\n  useDraco,\n  useMeshopt,\n  extendLoader,\n  onReady,\n  onAnimations,\n  onCameraChange,\n  cameraStore,\n  sceneContent,\n  onLockChange,\n}: {\n  lighting: ViewerLighting;\n  shading: ViewerShading;\n  grid: boolean;\n  floor: boolean;\n  floorColor?: string;\n  src?: string;\n  cubes: boolean;\n  mode: ViewerMode;\n  face?: ViewFace;\n  cameraPreset: ViewerCameraPreset;\n  resetToken: number;\n  autoRotate: boolean;\n  autoRotateSpeed: number;\n  showOrientation: boolean;\n  viewCube: ViewerViewCube | false;\n  viewCubePosition: ViewCubePosition;\n  viewCubeMargin?: [number, number];\n  toolbarVisible: boolean;\n  projection: \"perspective\" | \"orthographic\";\n  onInspect?: (value: ModelInspection) => void;\n  selectedMesh: string | null;\n  onSelectMesh: (id: string | null) => void;\n  pointerLockAvailable: boolean;\n  paneSelector: string;\n  environment: boolean;\n  animation: string | null;\n  animationPlaying: boolean;\n  animationSpeed: number;\n  animationResetToken: number;\n  loopAnimation: boolean;\n  useDraco: boolean | string;\n  useMeshopt: boolean;\n  extendLoader?: (loader: GLTFLoader) => void;\n  onReady: () => void;\n  onAnimations: (names: string[]) => void;\n  onCameraChange?: (state: ViewerCameraState) => void;\n  cameraStore?: ViewerCameraStore;\n  sceneContent?: ReactNode;\n  onLockChange?: (locked: boolean) => void;\n}) {\n  const contentRef = useRef<Group | null>(null);\n  const paneWidth = useThree((state) => state.size.width);\n  const cubeMargin: [number, number] = viewCubeMargin ?? [\n    64,\n    toolbarVisible && paneWidth <= 680 && viewCubePosition.startsWith(\"top-\") ? 148 : 64,\n  ];\n  const [fitVersion, setFitVersion] = useState(0);\n  const [gridScale, setGridScale] = useState(1);\n  const handleCentered = useCallback(({ container }: { container: Object3D }) => {\n    // Include the animation envelope, and keep grid density relative to the asset.\n    setGridScale(frameBounds(container, 1).radius / 2);\n    setFitVersion((value) => value + 1);\n  }, []);\n  const background = viewerBackgroundColor(lighting);\n\n  return (\n    <>\n      {projection === \"orthographic\" ? (\n        <OrthographicCamera makeDefault position={[3, 3, 3]} near={0.001} far={1000} />\n      ) : (\n        <PerspectiveCamera makeDefault position={[3, 3, 3]} fov={42} near={0.01} far={1000} />\n      )}\n      <color attach=\"background\" args={[background]} />\n      {lighting === \"outside\" && <OutsideSky />}\n      <ambientLight intensity={lighting === \"night\" ? 0.3 : 0.48} />\n      <directionalLight\n        position={[5 * gridScale, 9 * gridScale, 6 * gridScale]}\n        intensity={lighting === \"night\" ? 1.05 : lighting === \"outside\" ? 2 : 1.7}\n        color={lighting === \"night\" ? \"#bdd5ff\" : \"#fff7e6\"}\n        castShadow={floor}\n        shadow-mapSize={[2048, 2048]}\n        shadow-camera-left={-16 * gridScale}\n        shadow-camera-right={16 * gridScale}\n        shadow-camera-top={16 * gridScale}\n        shadow-camera-bottom={-16 * gridScale}\n        shadow-camera-near={0.1 * gridScale}\n        shadow-camera-far={40 * gridScale}\n        shadow-normalBias={0.02 * gridScale}\n      />\n      <directionalLight\n        position={[-4, 4, -6]}\n        intensity={lighting === \"night\" ? 1.4 : 0.45}\n        color={lighting === \"night\" ? \"#688db3\" : \"#dcebdc\"}\n      />\n      {environment &&\n        (lighting === \"outside\" ? (\n          <Environment resolution={128} frames={1} background={false} environmentIntensity={0.8}>\n            <OutsideSky />\n          </Environment>\n        ) : (\n          <StudioEnvironment key={lighting} lighting={lighting} />\n        ))}\n      {floor && (\n        <>\n          <mesh\n            name=\"Viewer floor\"\n            rotation={[-Math.PI / 2, 0, 0]}\n            position={[0, -0.002 * gridScale, 0]}\n          >\n            <planeGeometry args={[2000 * gridScale, 2000 * gridScale]} />\n            <meshBasicMaterial color={floorColor ?? background} toneMapped={false} />\n          </mesh>\n          <mesh\n            name=\"Viewer floor shadows\"\n            rotation={[-Math.PI / 2, 0, 0]}\n            position={[0, -0.001 * gridScale, 0]}\n            receiveShadow\n          >\n            <planeGeometry args={[2000 * gridScale, 2000 * gridScale]} />\n            <shadowMaterial\n              transparent\n              opacity={lighting === \"night\" ? 0.5 : 0.28}\n              depthWrite={false}\n            />\n          </mesh>\n        </>\n      )}\n      {grid && (\n        <Grid\n          infiniteGrid\n          fadeFrom={0}\n          fadeDistance={18 * gridScale}\n          fadeStrength={1.6}\n          cellSize={0.25 * gridScale}\n          sectionSize={gridScale}\n          cellThickness={0.45}\n          sectionThickness={1.15}\n          cellColor={lighting === \"day\" ? \"#b3b3b3\" : \"#404040\"}\n          sectionColor={lighting === \"day\" ? \"#808080\" : \"#737373\"}\n          position={[0, 0.002 * gridScale, 0]}\n        />\n      )}\n      <Suspense fallback={null}>\n        <Center ref={contentRef} top precise onCentered={handleCentered}>\n          <SceneObject\n            src={src}\n            cubes={cubes}\n            shading={shading}\n            animation={animation}\n            animationPlaying={animationPlaying}\n            animationSpeed={animationSpeed}\n            animationResetToken={animationResetToken}\n            loopAnimation={loopAnimation}\n            useDraco={useDraco}\n            useMeshopt={useMeshopt}\n            extendLoader={extendLoader}\n            onReady={onReady}\n            onAnimations={onAnimations}\n            onInspect={onInspect}\n            selectedMesh={selectedMesh}\n            onSelectMesh={onSelectMesh}\n          />\n        </Center>\n      </Suspense>\n      {mode === \"firstPerson\" && !face ? (\n        <>\n          <FitStaticCamera\n            objectRef={contentRef}\n            fitVersion={fitVersion}\n            preset={cameraPreset}\n            resetToken={resetToken}\n          />\n          {pointerLockAvailable ? (\n            <PointerLockControls\n              makeDefault\n              selector={paneSelector}\n              onLock={() => onLockChange?.(true)}\n              onUnlock={() => onLockChange?.(false)}\n            />\n          ) : (\n            <DragLook selector={paneSelector} />\n          )}\n          <FirstPersonMovement requirePointerLock={pointerLockAvailable} selector={paneSelector} />\n        </>\n      ) : (\n        <>\n          <CameraRig\n            lighting={lighting}\n            fixed={Boolean(face)}\n            paneSelector={paneSelector}\n            objectRef={contentRef}\n            fitVersion={fitVersion}\n            preset={cameraPreset}\n            resetToken={resetToken}\n            autoRotate={autoRotate}\n            autoRotateSpeed={autoRotateSpeed}\n            onCameraChange={onCameraChange}\n            cameraStore={mode === \"orbit\" ? cameraStore : undefined}\n          />\n          {mode === \"orbit\" && sceneContent}\n          {showOrientation && viewCube && (\n            <ViewCube\n              variant={viewCube}\n              position={viewCubePosition}\n              margin={cubeMargin}\n              renderPriority={2}\n            />\n          )}\n        </>\n      )}\n    </>\n  );\n}\n\nfunction StudioEnvironment({ lighting }: { lighting: ViewerLighting }) {\n  const night = lighting === \"night\";\n  return (\n    <Environment\n      resolution={128}\n      frames={1}\n      background={false}\n      environmentIntensity={night ? 0.7 : 0.9}\n    >\n      <Lightformer\n        form=\"rect\"\n        intensity={night ? 2.4 : 3.2}\n        color={night ? \"#91b8ef\" : \"#fff4dc\"}\n        position={[0, 5, 5]}\n        scale={[8, 3, 1]}\n      />\n      <Lightformer\n        form=\"rect\"\n        intensity={night ? 1.8 : 2}\n        color={night ? \"#5f81a9\" : \"#dcebdc\"}\n        position={[-5, 2, -3]}\n        rotation={[0, Math.PI / 2, 0]}\n        scale={[5, 3, 1]}\n      />\n      <Lightformer\n        form=\"ring\"\n        intensity={night ? 1.2 : 1.6}\n        color={night ? \"#d9e8ff\" : \"#ffffff\"}\n        position={[4, 1, -4]}\n        scale={2.5}\n      />\n    </Environment>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-parts.tsx",
      "content": "\"use client\";\n\nimport { Maximize2, Minimize2 } from \"lucide-react\";\nimport type { ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { ModelInspector, type ModelInspectorProps } from \"./model-inspector\";\nimport { useModelViewer, useViewerRuntime } from \"./model-viewer-context\";\nimport { ModelViewerAnimationControls, ModelViewerControls } from \"./model-viewer-toolbar\";\nimport { ViewerControlButton } from \"./viewer-ui\";\n\n/** Ready-made toolbar connected to the nearest root. */\nexport function ModelViewerDefaultToolbar(props: Omit<ComponentProps<\"div\">, \"onReset\">) {\n  const viewer = useModelViewer();\n  return (\n    <ModelViewerControls\n      mode={viewer.mode}\n      onModeChange={viewer.setMode}\n      shading={viewer.shading}\n      onShadingChange={viewer.setShading}\n      lighting={viewer.lighting}\n      onLightingChange={viewer.setLighting}\n      grid={viewer.showGrid}\n      onGridChange={viewer.setShowGrid}\n      floor={viewer.showFloor}\n      onFloorChange={viewer.setShowFloor}\n      autoRotate={viewer.autoRotate}\n      onAutoRotateChange={viewer.setAutoRotate}\n      projection={viewer.projection}\n      onProjectionChange={viewer.setProjection}\n      viewCube={viewer.viewCube}\n      onViewCubeChange={viewer.setViewCube}\n      inspectorOpen={viewer.inspectorOpen}\n      onInspectorChange={viewer.setInspectorOpen}\n      onReset={viewer.resetView}\n      captureDisabled={!viewer.canCapture}\n      onCapture={viewer.capture}\n      {...props}\n    />\n  );\n}\n\n/** Renders only when the current model has animation clips. */\nexport function ModelViewerAnimationBar(props: ComponentProps<\"div\">) {\n  const viewer = useModelViewer();\n  if (viewer.animationNames.length === 0) return null;\n  return (\n    <ModelViewerAnimationControls\n      clips={viewer.animationNames}\n      animation={viewer.animation}\n      onAnimationChange={viewer.setAnimation}\n      playing={viewer.animationPlaying}\n      onPlayingChange={viewer.setAnimationPlaying}\n      speed={viewer.animationSpeed}\n      onSpeedChange={viewer.setAnimationSpeed}\n      onRestart={viewer.restartAnimation}\n      {...props}\n    />\n  );\n}\n\nexport type ModelViewerInspectorProps = Omit<\n  ModelInspectorProps,\n  \"inspection\" | \"selectedMesh\" | \"onSelectMesh\"\n>;\n\n/** Connected inspector; ModelInspector remains available for standalone data. */\nexport function ModelViewerInspector({ onClose, ...props }: ModelViewerInspectorProps) {\n  const viewer = useModelViewer();\n  if (!viewer.inspectorOpen || !viewer.inspection) return null;\n  return (\n    <ModelInspector\n      inspection={viewer.inspection}\n      selectedMesh={viewer.selectedMesh}\n      onSelectMesh={viewer.setSelectedMesh}\n      onClose={() => {\n        viewer.setInspectorOpen(false);\n        onClose?.();\n      }}\n      {...props}\n    />\n  );\n}\n\nexport function ModelViewerStatus({ className, children, ...props }: ComponentProps<\"div\">) {\n  const { state: viewer, pointerLockAvailable, locked } = useViewerRuntime();\n  const instructions =\n    viewer.mode === \"firstPerson\"\n      ? pointerLockAvailable\n        ? locked\n          ? \"WASD to fly · Space up · Shift down · Esc to release\"\n          : \"Click the scene to look · WASD to fly\"\n        : \"Drag to look · WASD to fly · Space / Shift vertically\"\n      : viewer.mode === \"split\"\n        ? \"Fixed views · Drag to pan · Scroll to zoom\"\n        : \"Drag to orbit · Scroll to zoom\";\n  return (\n    <div\n      data-slot=\"model-viewer-status\"\n      className={cn(\n        \"viewer-help rounded-md border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground\",\n        viewer.feedback?.error && \"text-destructive\",\n        className,\n      )}\n      role={viewer.feedback?.error ? \"alert\" : \"status\"}\n      {...props}\n    >\n      {children ?? viewer.feedback?.message ?? instructions}\n    </div>\n  );\n}\n\nexport function ModelViewerFullscreen({\n  className,\n  children,\n  onClick,\n  ...props\n}: ComponentProps<typeof ViewerControlButton>) {\n  const viewer = useModelViewer();\n  return (\n    <ViewerControlButton\n      data-slot=\"model-viewer-fullscreen\"\n      size=\"icon-sm\"\n      variant=\"secondary\"\n      className={cn(\"viewer-fullscreen\", className)}\n      aria-label={viewer.fullscreen ? \"Exit fullscreen\" : \"Enter fullscreen\"}\n      {...props}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) void viewer.toggleFullscreen();\n      }}\n    >\n      {children ?? (viewer.fullscreen ? <Minimize2 /> : <Maximize2 />)}\n    </ViewerControlButton>\n  );\n}\n\nexport function ModelViewerOverlay({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"model-viewer-overlay\"\n      className={cn(\"viewer-overlay-slot\", className)}\n      {...props}\n    />\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-context.ts",
      "content": "\"use client\";\nimport { createContext, useContext, useSyncExternalStore } from \"react\";\nimport type { ModelViewerState } from \"./model-viewer-types\";\nimport type { useModelViewerRuntime } from \"./use-model-viewer-runtime\";\n\n/** Internal renderer context. Not part of the public barrel. */\nexport const ModelViewerContext = createContext<ReturnType<typeof useModelViewerRuntime> | null>(\n  null,\n);\n\nexport function useOptionalViewerRuntime() {\n  return useContext(ModelViewerContext);\n}\n\nexport function useViewerRuntime() {\n  const context = useOptionalViewerRuntime();\n  if (!context)\n    throw new Error(\"Model viewer parts must be used inside <ModelViewerRoot> or <ModelViewer>.\");\n  return context;\n}\n\n/** State and actions scoped to the nearest viewer. */\nexport function useModelViewer(): ModelViewerState {\n  return useViewerRuntime().state;\n}\n\n/** Live primary orbit camera view without rerendering the whole viewer. */\nexport function useModelViewerCamera() {\n  const { cameraStore } = useViewerRuntime();\n  return useSyncExternalStore(cameraStore.subscribe, cameraStore.getSnapshot, () => null);\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-camera.ts",
      "content": "import type { ViewerCameraOptions, ViewerCameraState } from \"./model-viewer-types\";\n\n/** Mutable camera data lives outside React's root render loop. */\nexport function createViewerCameraStore() {\n  let snapshot: ViewerCameraState | null = null;\n  let controller: ((view: ViewerCameraState, transition: boolean) => void) | null = null;\n  const listeners = new Set<() => void>();\n  const getSnapshot = () => snapshot;\n  const subscribe = (listener: () => void) => {\n    listeners.add(listener);\n    return () => {\n      listeners.delete(listener);\n    };\n  };\n  const publish = (view: ViewerCameraState | null) => {\n    const current = snapshot;\n    if (\n      view &&\n      current &&\n      view.position.every((n, i) => Math.abs(n - current.position[i]) < 1e-6) &&\n      view.target.every((n, i) => Math.abs(n - current.target[i]) < 1e-6)\n    )\n      return;\n    if (!view && !snapshot) return;\n    snapshot = view;\n    listeners.forEach((listener) => listener());\n  };\n  const register = (next: typeof controller) => {\n    controller = next;\n    if (!next) publish(null);\n  };\n  const setView = (view: ViewerCameraState, options?: ViewerCameraOptions) => {\n    if (!controller || !validCameraView(view)) return false;\n    controller(view, options?.transition ?? true);\n    return true;\n  };\n  return { getSnapshot, subscribe, publish, register, setView };\n}\n\nexport type ViewerCameraStore = ReturnType<typeof createViewerCameraStore>;\n\nfunction validCameraView(view: ViewerCameraState) {\n  const values = [...view.position, ...view.target];\n  return (\n    values.every(Number.isFinite) &&\n    view.position.some((n, i) => Math.abs(n - view.target[i]) > 1e-9)\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-colors.ts",
      "content": "import type { ViewerLighting } from \"./model-viewer-types\";\n\nexport function viewerBackgroundColor(lighting: ViewerLighting) {\n  if (lighting === \"night\") return \"#17243a\";\n  if (lighting === \"outside\") return \"#dbe9ee\";\n  return \"#f5f5f5\";\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/outside-sky.tsx",
      "content": "import { BackSide } from \"three\";\n\nconst vertexShader = `\n  varying vec3 vDirection;\n  void main() {\n    vDirection = normalize(position);\n    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n    gl_Position.z = gl_Position.w;\n  }\n`;\n\nconst fragmentShader = `\n  varying vec3 vDirection;\n  void main() {\n    float elevation = clamp(vDirection.y, 0.0, 1.0);\n    vec3 horizon = vec3(0.859, 0.914, 0.933);\n    vec3 zenith = vec3(0.31, 0.58, 0.84);\n    vec3 color = mix(horizon, zenith, pow(elevation, 0.7));\n    vec3 sun = normalize(vec3(0.65, 0.6, -0.25));\n    float halo = pow(max(dot(normalize(vDirection), sun), 0.0), 64.0);\n    color = mix(color, vec3(1.0, 0.95, 0.78), halo * 0.4);\n    gl_FragColor = vec4(color, 1.0);\n  }\n`;\n\n/** Self-contained daylight sky: no remote HDRI or asset request. */\nexport function OutsideSky() {\n  return (\n    <mesh scale={900} frustumCulled={false} renderOrder={-1000}>\n      <sphereGeometry args={[1, 32, 16]} />\n      <shaderMaterial\n        side={BackSide}\n        depthWrite={false}\n        depthTest={false}\n        vertexShader={vertexShader}\n        fragmentShader={fragmentShader}\n      />\n    </mesh>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-lifecycle.ts",
      "content": "export type ViewerLifecycle = {\n  status: \"idle\" | \"loading\" | \"ready\" | \"error\";\n  readyPanes: ReadonlySet<number>;\n  generation: number;\n};\n\nexport type ViewerLifecycleEvent =\n  | { type: \"mount\" }\n  | { type: \"reset\" }\n  | { type: \"unmount\" }\n  | { type: \"ready\"; pane: number; expectedPanes: number }\n  | { type: \"error\" };\n\nexport const initialViewerLifecycle: ViewerLifecycle = {\n  status: \"idle\",\n  readyPanes: new Set(),\n  generation: 0,\n};\n\nexport function viewerLifecycleReducer(\n  current: ViewerLifecycle,\n  event: ViewerLifecycleEvent,\n): ViewerLifecycle {\n  switch (event.type) {\n    case \"mount\":\n      return { status: \"loading\", readyPanes: new Set(), generation: current.generation + 1 };\n    case \"reset\":\n      return {\n        status: current.status === \"idle\" ? \"idle\" : \"loading\",\n        readyPanes: new Set(),\n        generation: current.generation + 1,\n      };\n    case \"unmount\":\n      return { status: \"idle\", readyPanes: new Set(), generation: current.generation + 1 };\n    case \"error\":\n      return current.status === \"loading\" || current.status === \"ready\"\n        ? { ...current, status: \"error\" }\n        : current;\n    case \"ready\": {\n      if (current.status !== \"loading\" || current.readyPanes.has(event.pane)) return current;\n      const readyPanes = new Set(current.readyPanes).add(event.pane);\n      return {\n        ...current,\n        readyPanes,\n        status: readyPanes.size >= event.expectedPanes ? \"ready\" : \"loading\",\n      };\n    }\n  }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-viewer-types.ts",
      "content": "import type { ComponentProps, CSSProperties, ReactNode } from \"react\";\nimport type { GLTFLoader } from \"three-stdlib\";\nimport type { ModelInspection } from \"./model-inspection\";\nimport type { ViewCubePosition } from \"./view-cube\";\nimport type { ViewerUiComponents } from \"./viewer-ui\";\n\nexport type ViewerMode = \"orbit\" | \"split\" | \"firstPerson\";\nexport type ViewerLighting = \"day\" | \"night\" | \"outside\";\nexport type ViewerShading = \"realistic\" | \"solid\" | \"normals\" | \"wireframe\";\nexport type ViewerViewCube = \"drei\" | \"asset-studio\";\nexport type ModelInspectorPosition = \"left\" | \"right\";\nexport type ViewerCameraPreset =\n  | \"isometric\"\n  | \"front\"\n  | \"right\"\n  | \"back\"\n  | \"left\"\n  | \"top\"\n  | \"bottom\";\n\nexport type ViewerCameraState = {\n  position: [number, number, number];\n  target: [number, number, number];\n};\n\nexport type ViewerCameraOptions = { transition?: boolean };\n\nexport type ViewerProgress = {\n  /** Indeterminate, viewer-scoped loading state. Counts remain zero until a per-viewer asset manager is supplied. */\n  active: boolean;\n  progress: number;\n  item: string;\n  loaded: number;\n  total: number;\n};\n\nexport interface ModelViewerProps extends Omit<ComponentProps<\"div\">, \"onLoad\" | \"onError\"> {\n  /** Compatibility escape hatch. Prefer local primitives, composition, and render. */\n  components?: Partial<ViewerUiComponents>;\n  src?: string;\n  /** @deprecated Use aria-label (or aria-labelledby) to name the viewer. */\n  alt?: string;\n  className?: string;\n  height?: CSSProperties[\"height\"];\n  mode?: ViewerMode;\n  defaultMode?: ViewerMode;\n  onModeChange?: (mode: ViewerMode) => void;\n  lighting?: ViewerLighting;\n  defaultLighting?: ViewerLighting;\n  onLightingChange?: (lighting: ViewerLighting) => void;\n  shading?: ViewerShading;\n  defaultShading?: ViewerShading;\n  onShadingChange?: (shading: ViewerShading) => void;\n  showGrid?: boolean;\n  defaultShowGrid?: boolean;\n  onGridChange?: (visible: boolean) => void;\n  /** Infinite-looking ground plane that receives model shadows. */\n  showFloor?: boolean;\n  defaultShowFloor?: boolean;\n  onFloorChange?: (visible: boolean) => void;\n  /** Defaults to the current lighting mode's background/horizon color. */\n  floorColor?: string;\n  showCubes?: boolean;\n  showUi?: boolean;\n  /** Preserve the WebGL buffer for PNG capture. Defaults to showUi; enable explicitly for custom controls. */\n  enableCapture?: boolean;\n  showOrientation?: boolean;\n  viewCube?: ViewerViewCube | false;\n  defaultViewCube?: ViewerViewCube | false;\n  viewCubePosition?: ViewCubePosition;\n  viewCubeMargin?: [number, number];\n  /** R3F nodes rendered inside the primary orbit scene, after its camera controls. */\n  sceneContent?: ReactNode;\n  onViewCubeChange?: (value: ViewerViewCube | false) => void;\n  projection?: \"perspective\" | \"orthographic\";\n  defaultProjection?: \"perspective\" | \"orthographic\";\n  onProjectionChange?: (projection: \"perspective\" | \"orthographic\") => void;\n  /** Legacy initial/synchronized visibility. Prefer inspectorOpen/defaultInspectorOpen. */\n  showInspector?: boolean;\n  inspectorOpen?: boolean;\n  defaultInspectorOpen?: boolean;\n  /** Side used by the built-in inspector. Defaults to right. */\n  inspectorPosition?: ModelInspectorPosition;\n  onInspectorOpenChange?: (open: boolean) => void;\n  onInspect?: (inspection: ModelInspection) => void;\n  showAnimationControls?: boolean;\n  /** Controlled camera orbit around the model. Active only in orbit mode. */\n  autoRotate?: boolean;\n  /** Initial auto-rotation for an uncontrolled viewer. */\n  defaultAutoRotate?: boolean;\n  onAutoRotateChange?: (rotating: boolean) => void;\n  autoRotateSpeed?: number;\n  cameraPreset?: ViewerCameraPreset;\n  defaultCameraPreset?: ViewerCameraPreset;\n  onCameraPresetChange?: (preset: ViewerCameraPreset) => void;\n  onCameraChange?: (state: ViewerCameraState) => void;\n  animation?: string | null;\n  defaultAnimation?: string | null;\n  onAnimationChange?: (animation: string | null) => void;\n  animationPlaying?: boolean;\n  defaultAnimationPlaying?: boolean;\n  onAnimationPlayingChange?: (playing: boolean) => void;\n  animationSpeed?: number;\n  defaultAnimationSpeed?: number;\n  onAnimationSpeedChange?: (speed: number) => void;\n  loopAnimation?: boolean;\n  environment?: boolean;\n  poster?: string;\n  /** Display the current asset filename in the default loader. Defaults to false. */\n  showFileName?: boolean;\n  /** Replace loading content. Pass null to hide the loading overlay. */\n  loadingFallback?: ReactNode | ((progress: ViewerProgress) => ReactNode);\n  /** Replace error content. Pass null to hide the error overlay. */\n  errorFallback?: ReactNode | ((error: Error) => ReactNode);\n  /** Opt in to a retry button in the default error state. */\n  showRetry?: boolean;\n  /** Suspend rendering while outside the viewport or in a hidden tab. */\n  pauseWhenHidden?: boolean;\n  overlay?: ReactNode;\n  /** Replace the default toolbar; null hides it. Custom controls can use useModelViewer(). */\n  toolbar?: ReactNode;\n  useDraco?: boolean | string;\n  useMeshopt?: boolean;\n  extendLoader?: (loader: GLTFLoader) => void;\n  clearCacheOnUnmount?: boolean;\n  respectReducedMotion?: boolean;\n  onPerformanceChange?: (factor: number) => void;\n  onLoad?: () => void;\n  onError?: (error: Error) => void;\n}\n\nexport type ModelViewerRootProps = Omit<\n  ModelViewerProps,\n  \"toolbar\" | \"overlay\" | \"showAnimationControls\" | \"sceneContent\" | \"inspectorPosition\"\n>;\n\nexport interface ModelViewerState {\n  mode: ViewerMode;\n  setMode: (mode: ViewerMode) => void;\n  lighting: ViewerLighting;\n  setLighting: (lighting: ViewerLighting) => void;\n  shading: ViewerShading;\n  setShading: (shading: ViewerShading) => void;\n  showGrid: boolean;\n  setShowGrid: (visible: boolean) => void;\n  showFloor: boolean;\n  setShowFloor: (visible: boolean) => void;\n  autoRotate: boolean;\n  setAutoRotate: (rotating: boolean) => void;\n  projection: \"perspective\" | \"orthographic\";\n  setProjection: (projection: \"perspective\" | \"orthographic\") => void;\n  viewCube: ViewerViewCube | false;\n  setViewCube: (cube: ViewerViewCube | false) => void;\n  cameraPreset: ViewerCameraPreset;\n  setCameraPreset: (preset: ViewerCameraPreset) => void;\n  resetView: () => void;\n  /** Latest primary orbit camera view, or null before camera controls mount. */\n  getCameraView: () => ViewerCameraState | null;\n  /** Returns false when orbit controls are unavailable or the view is invalid. */\n  setCameraView: (view: ViewerCameraState, options?: ViewerCameraOptions) => boolean;\n  inspectorOpen: boolean;\n  setInspectorOpen: (open: boolean) => void;\n  inspection: ModelInspection | null;\n  selectedMesh: string | null;\n  setSelectedMesh: (id: string | null) => void;\n  animation: string | null;\n  animationNames: string[];\n  setAnimation: (clip: string | null) => void;\n  animationPlaying: boolean;\n  setAnimationPlaying: (playing: boolean) => void;\n  animationSpeed: number;\n  setAnimationSpeed: (speed: number) => void;\n  restartAnimation: () => void;\n  reducedMotion: boolean;\n  renderingPaused: boolean;\n  /** Retry a failed load of the current URL, clearing Drei's cached failure. */\n  retry: () => void;\n  status: \"idle\" | \"loading\" | \"ready\" | \"error\";\n  canCapture: boolean;\n  capture: (action: \"copy\" | \"download\") => Promise<void>;\n  fullscreen: boolean;\n  toggleFullscreen: () => Promise<void>;\n  feedback: { message: string; error: boolean } | null;\n}\nexport type ViewFace = \"front\" | \"right\" | \"back\" | \"left\";\n\ntype Pane = {\n  face?: ViewFace;\n  label?: string;\n};\n\nexport const splitPanes: Pane[] = [\n  { face: \"front\", label: \"Front\" },\n  { face: \"right\", label: \"Right\" },\n  { face: \"back\", label: \"Back\" },\n  { face: \"left\", label: \"Left\" },\n];\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-model-viewer-runtime.ts",
      "content": "\"use client\";\nimport { useId, useImperativeHandle, useRef, useState } from \"react\";\nimport { createViewerCameraStore } from \"./model-viewer-camera\";\nimport { type ModelViewerRootProps, type ModelViewerState, splitPanes } from \"./model-viewer-types\";\nimport { useViewerCapture } from \"./use-viewer-capture\";\nimport { useViewerFeedback } from \"./use-viewer-feedback\";\nimport { useViewerFullscreen } from \"./use-viewer-fullscreen\";\nimport { useViewerInteractions } from \"./use-viewer-interactions\";\nimport { useViewerPreferences } from \"./use-viewer-preferences\";\nimport { useViewerSession } from \"./use-viewer-session\";\nimport { useReducedMotion, useViewerHidden } from \"./use-viewer-visibility\";\n\nexport function useModelViewerRuntime(input: ModelViewerRootProps) {\n  const {\n    mode,\n    setMode,\n    viewCube,\n    setViewCube,\n    projection,\n    setProjection,\n    inspectorOpen,\n    setInspectorOpen,\n    lighting,\n    setLighting,\n    shading,\n    setShading,\n    grid,\n    setGrid,\n    floor,\n    setFloor,\n    autoRotate,\n    setAutoRotate,\n    cameraPreset,\n    setCameraPreset,\n    animation,\n    setAnimation,\n    animationPlaying,\n    setAnimationPlaying,\n    animationSpeed,\n    setAnimationSpeed,\n    controlledAnimation,\n    props: remainingProps,\n  } = useViewerPreferences(input);\n  const {\n    components,\n    src,\n    alt = \"3D model\",\n    \"aria-label\": ariaLabel,\n    \"aria-labelledby\": ariaLabelledBy,\n    className,\n    style,\n    ref,\n    children,\n    height,\n    floorColor,\n    showCubes: requestedCubes,\n    showUi = true,\n    enableCapture = showUi,\n    showOrientation = showUi,\n    viewCubePosition = \"top-right\",\n    viewCubeMargin,\n    onInspect,\n    autoRotateSpeed = 0.15,\n    onCameraChange,\n    loopAnimation = true,\n    environment = true,\n    poster,\n    showFileName = false,\n    loadingFallback,\n    errorFallback,\n    showRetry = false,\n    pauseWhenHidden = true,\n    useDraco = true,\n    useMeshopt = true,\n    extendLoader,\n    clearCacheOnUnmount = src?.startsWith(\"blob:\") ?? false,\n    respectReducedMotion = true,\n    onPerformanceChange,\n    onLoad,\n    onError,\n    ...props\n  } = remainingProps;\n  const [quality, setQuality] = useState(1.5);\n  const [cameraStore] = useState(createViewerCameraStore);\n  const { feedback, report, clearFeedback } = useViewerFeedback();\n  const {\n    inspection,\n    selectedMesh,\n    setSelectedMesh,\n    animationNames,\n    loaded,\n    viewerError,\n    retryToken,\n    locked,\n    setLocked,\n    captureReady,\n    setCaptureReady,\n    sceneMounted,\n    setSceneMounted,\n    reportInspection,\n    markReady,\n    reportAnimations,\n    fail,\n    retry,\n    status,\n  } = useViewerSession({\n    src,\n    mode,\n    enableCapture,\n    clearCacheOnUnmount,\n    clearFeedback,\n    onLoad,\n    onError,\n    onInspect,\n  });\n  const {\n    pointerLockAvailable,\n    resetToken,\n    animationResetToken,\n    toolbarOffset,\n    reportToolbar,\n    changeMode,\n    resetView,\n    restartAnimation,\n  } = useViewerInteractions(mode, setMode, setLocked);\n  const reducedMotion = useReducedMotion(respectReducedMotion);\n  const viewerRef = useRef<HTMLDivElement | null>(null);\n  const viewerHidden = useViewerHidden(viewerRef);\n  const renderingPaused = pauseWhenHidden && viewerHidden;\n  useImperativeHandle(ref, () => viewerRef.current!, []);\n  const canvasRef = useRef<HTMLCanvasElement | null>(null);\n  const viewerKey = useId();\n  const cubes = requestedCubes ?? !src;\n  const effectiveAutoRotate = autoRotate && !reducedMotion && !renderingPaused && mode === \"orbit\";\n  const effectiveAnimationPlaying = animationPlaying && !reducedMotion && !renderingPaused;\n  const paneTracks = useRef(\n    Array.from({ length: splitPanes.length }, () => ({\n      current: null as HTMLDivElement | null,\n    })),\n  ).current;\n\n  const effectiveAnimation =\n    controlledAnimation === null\n      ? null\n      : animationNames.includes(animation ?? \"\")\n        ? animation\n        : controlledAnimation === undefined\n          ? (animationNames[0] ?? null)\n          : animation;\n  const { fullscreen, isExpanded, toggleFullscreen } = useViewerFullscreen(viewerRef, report);\n\n  const canCapture = enableCapture && captureReady && sceneMounted && loaded && !viewerError;\n  const capture = useViewerCapture({\n    canvasRef,\n    canCapture,\n    lighting,\n    fileName: ariaLabel ?? alt,\n    report,\n  });\n\n  const state: ModelViewerState = {\n    mode,\n    setMode: changeMode,\n    lighting,\n    setLighting,\n    shading,\n    setShading,\n    showGrid: grid,\n    setShowGrid: setGrid,\n    showFloor: floor,\n    setShowFloor: setFloor,\n    autoRotate,\n    setAutoRotate,\n    projection,\n    setProjection,\n    viewCube,\n    setViewCube,\n    cameraPreset,\n    setCameraPreset,\n    resetView,\n    getCameraView: cameraStore.getSnapshot,\n    setCameraView: cameraStore.setView,\n    inspectorOpen,\n    setInspectorOpen,\n    inspection,\n    selectedMesh,\n    setSelectedMesh,\n    animation: effectiveAnimation,\n    animationNames,\n    setAnimation,\n    animationPlaying,\n    setAnimationPlaying,\n    animationSpeed,\n    setAnimationSpeed,\n    restartAnimation,\n    reducedMotion,\n    renderingPaused,\n    retry,\n    status,\n    canCapture,\n    capture,\n    fullscreen,\n    toggleFullscreen,\n    feedback,\n  };\n  return {\n    state,\n    root: { components, className, style, children, height, props, isExpanded },\n    viewerRef,\n    canvasRef,\n    enableCapture,\n    setCaptureReady,\n    viewerKey,\n    cameraStore,\n    toolbarOffset,\n    reportToolbar,\n    setSceneMounted,\n    // Renderer configuration stays private; useModelViewer exposes only state/actions.\n    src,\n    accessibleLabel: ariaLabelledBy ? undefined : (ariaLabel ?? alt),\n    ariaLabelledBy,\n    mode,\n    lighting,\n    shading,\n    grid,\n    floor,\n    floorColor,\n    cubes,\n    cameraPreset,\n    resetToken,\n    showUi,\n    showOrientation,\n    viewCube,\n    viewCubePosition,\n    viewCubeMargin,\n    projection,\n    effectiveAutoRotate,\n    autoRotateSpeed,\n    effectiveAnimation,\n    effectiveAnimationPlaying,\n    animationSpeed,\n    animationResetToken,\n    loopAnimation,\n    environment,\n    poster,\n    showFileName,\n    loadingFallback,\n    errorFallback,\n    showRetry,\n    retryToken,\n    retry,\n    renderingPaused,\n    useDraco,\n    useMeshopt,\n    extendLoader,\n    loaded,\n    status,\n    quality,\n    setQuality,\n    onPerformanceChange,\n    onCameraChange,\n    selectedMesh,\n    setSelectedMesh,\n    reportInspection,\n    pointerLockAvailable,\n    locked,\n    setLocked,\n    paneTracks,\n    markReady,\n    reportAnimations,\n    fail,\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/scene-renderer.tsx",
      "content": "\"use client\";\n\nimport { type ComputeFunction, createPortal, useFrame, useThree } from \"@react-three/fiber\";\nimport {\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useState,\n} from \"react\";\nimport { Scene } from \"three\";\n\nexport function ScissorView({\n  track,\n  index,\n  clearColor,\n  children,\n}: {\n  track: RefObject<HTMLDivElement | null>;\n  index: number;\n  clearColor: string;\n  children: ReactNode;\n}) {\n  const [scene] = useState(() => new Scene());\n  const [size, setSize] = useState({ width: 1, height: 1, top: 0, left: 0 });\n\n  useLayoutEffect(() => {\n    let frame = 0;\n    let observer: ResizeObserver | undefined;\n    const measure = () => {\n      const element = track.current;\n      if (!element) return;\n      const rect = element.getBoundingClientRect();\n      setSize((current) =>\n        current.width === rect.width &&\n        current.height === rect.height &&\n        current.top === rect.top &&\n        current.left === rect.left\n          ? current\n          : {\n              width: rect.width,\n              height: rect.height,\n              top: rect.top,\n              left: rect.left,\n            },\n      );\n    };\n    // Canvas and its DOM tracks commit in separate React roots. The R3F\n    // layout effect can run before the DOM ref has been attached.\n    const observeTrack = () => {\n      const element = track.current;\n      if (!element) {\n        frame = requestAnimationFrame(observeTrack);\n        return;\n      }\n      measure();\n      observer = new ResizeObserver(measure);\n      observer.observe(element);\n    };\n    observeTrack();\n    window.addEventListener(\"scroll\", measure, true);\n    window.addEventListener(\"resize\", measure);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer?.disconnect();\n      window.removeEventListener(\"scroll\", measure, true);\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [track]);\n\n  const compute = useCallback<ComputeFunction>(\n    (event, state) => {\n      const rect = track.current?.getBoundingClientRect();\n      if (!rect || rect.width === 0 || rect.height === 0) return;\n      if (!(event.target instanceof Node) || !track.current?.contains(event.target)) {\n        state.pointer.set(10000, 10000);\n        state.raycaster.setFromCamera(state.pointer, state.camera);\n        return;\n      }\n      state.pointer.set(\n        ((event.clientX - rect.left) / rect.width) * 2 - 1,\n        -((event.clientY - rect.top) / rect.height) * 2 + 1,\n      );\n      state.raycaster.setFromCamera(state.pointer, state.camera);\n    },\n    [track],\n  );\n\n  return createPortal(\n    <ScissorRenderer track={track} size={size} index={index} clearColor={clearColor}>\n      {children}\n    </ScissorRenderer>,\n    scene,\n    { events: { compute, priority: index }, size },\n  );\n}\n\nexport function RedrawAfterResize() {\n  const dpr = useThree((state) => state.viewport.dpr);\n  const size = useThree((state) => state.size);\n  const invalidate = useThree((state) => state.invalidate);\n  useEffect(() => {\n    // Changing pixel ratio clears WebGL's drawing buffer, even when the scene is idle.\n    const frame = requestAnimationFrame(() => invalidate());\n    return () => cancelAnimationFrame(frame);\n  }, [dpr, size.width, size.height, invalidate]);\n  return null;\n}\n\nfunction ScissorRenderer({\n  track,\n  size,\n  index,\n  clearColor,\n  children,\n}: {\n  track: RefObject<HTMLDivElement | null>;\n  size: { width: number; height: number; top: number; left: number };\n  index: number;\n  clearColor: string;\n  children: ReactNode;\n}) {\n  const set = useThree((state) => state.set);\n  useLayoutEffect(() => {\n    // R3F portals refresh injected props when the parent store changes, not\n    // necessarily when the track is measured. Keep this pane's size current\n    // without depending on unrelated root renders or camera interactions.\n    set({ size });\n  }, [set, size]);\n\n  useFrame((state) => {\n    const element = track.current;\n    if (!element) return;\n    const rect = element.getBoundingClientRect();\n    const canvasRect = state.gl.domElement.getBoundingClientRect();\n    const visible =\n      rect.width > 0 &&\n      rect.height > 0 &&\n      rect.right > canvasRect.left &&\n      rect.left < canvasRect.right &&\n      rect.bottom > canvasRect.top &&\n      rect.top < canvasRect.bottom;\n    if (!visible) return;\n\n    if (index === 1) {\n      state.gl.setScissorTest(false);\n      state.gl.setClearColor(clearColor, 1);\n      state.gl.clear(true, true, true);\n    }\n\n    const left = rect.left - canvasRect.left;\n    const bottom = canvasRect.bottom - rect.bottom;\n    const autoClear = state.gl.autoClear;\n    state.gl.autoClear = false;\n    state.gl.setViewport(left, bottom, rect.width, rect.height);\n    state.gl.setScissor(left, bottom, rect.width, rect.height);\n    state.gl.setScissorTest(true);\n    state.gl.clear(false, true, true);\n    state.gl.render(state.scene, state.camera);\n    state.gl.setScissorTest(false);\n    state.gl.autoClear = autoClear;\n  }, index);\n  return (\n    <>\n      {children}\n      <group onPointerOver={() => undefined} />\n    </>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/scene-model.tsx",
      "content": "\"use client\";\n\nimport { Outlines, useGLTF } from \"@react-three/drei\";\nimport { createPortal, useFrame, useThree } from \"@react-three/fiber\";\nimport { useEffect, useMemo, useRef } from \"react\";\nimport {\n  AnimationMixer,\n  type Group,\n  LoopOnce,\n  LoopRepeat,\n  type Mesh,\n  MeshBasicMaterial,\n  MeshNormalMaterial,\n  MeshStandardMaterial,\n} from \"three\";\nimport { clone } from \"three/addons/utils/SkeletonUtils.js\";\nimport type { GLTFLoader } from \"three-stdlib\";\nimport { inspectModel, type ModelInspection, prepareAnimationBounds } from \"./model-inspection\";\nimport { retainModelResources } from \"./model-resources\";\nimport type { ViewerShading } from \"./model-viewer-types\";\n\nexport function SceneObject({\n  src,\n  cubes,\n  shading,\n  animation,\n  animationPlaying,\n  animationSpeed,\n  animationResetToken,\n  loopAnimation,\n  useDraco,\n  useMeshopt,\n  extendLoader,\n  onReady,\n  onAnimations,\n  onInspect,\n  selectedMesh,\n  onSelectMesh,\n}: {\n  onInspect?: (value: ModelInspection) => void;\n  selectedMesh: string | null;\n  onSelectMesh: (id: string | null) => void;\n  src?: string;\n  cubes: boolean;\n  shading: ViewerShading;\n  animation: string | null;\n  animationPlaying: boolean;\n  animationSpeed: number;\n  animationResetToken: number;\n  loopAnimation: boolean;\n  useDraco: boolean | string;\n  useMeshopt: boolean;\n  extendLoader?: (loader: GLTFLoader) => void;\n  onReady: () => void;\n  onAnimations: (names: string[]) => void;\n}) {\n  const group = useRef<Group>(null);\n  useEffect(() => {\n    if (group.current) onInspect?.(inspectModel(group.current));\n  }, [src, cubes, onInspect]);\n  useEffect(() => {\n    if (!src) {\n      onAnimations([]);\n      onReady();\n    }\n  }, [src, onAnimations, onReady]);\n  return (\n    <group\n      ref={group}\n      onClick={(event) => {\n        event.stopPropagation();\n        onSelectMesh(event.object.userData.viewerNodeId ?? null);\n      }}\n    >\n      {src && (\n        <LoadedModel\n          src={src}\n          shading={shading}\n          animation={animation}\n          animationPlaying={animationPlaying}\n          animationSpeed={animationSpeed}\n          animationResetToken={animationResetToken}\n          loopAnimation={loopAnimation}\n          useDraco={useDraco}\n          useMeshopt={useMeshopt}\n          extendLoader={extendLoader}\n          onReady={onReady}\n          onAnimations={onAnimations}\n          selectedMesh={selectedMesh}\n          onSelectMesh={onSelectMesh}\n        />\n      )}\n      {cubes && (\n        <>\n          <mesh name=\"Cube\" castShadow position={[-1.25, 0.45, 0]} rotation={[0, 0.25, 0.08]}>\n            <boxGeometry args={[0.9, 0.9, 0.9]} />\n            <ViewerMaterial shading={shading} color=\"#b3c899\" />\n            {selectedMesh === \"0/0\" && <SelectionOutline />}\n          </mesh>\n          <mesh\n            name=\"Dodecahedron\"\n            castShadow\n            position={[1.05, 0.55, -0.55]}\n            rotation={[0, -0.35, 0]}\n          >\n            <dodecahedronGeometry args={[0.55, 0]} />\n            <ViewerMaterial shading={shading} color=\"#d19a78\" />\n            {selectedMesh === \"0/1\" && <SelectionOutline />}\n          </mesh>\n          <mesh name=\"Sphere\" castShadow position={[0.15, 0.42, 1]}>\n            <sphereGeometry args={[0.42, 48, 48]} />\n            <ViewerMaterial shading={shading} color=\"#7fa7a7\" />\n            {selectedMesh === \"0/2\" && <SelectionOutline />}\n          </mesh>\n        </>\n      )}\n    </group>\n  );\n}\n\nfunction LoadedModel({\n  src,\n  shading,\n  animation,\n  animationPlaying,\n  animationSpeed,\n  animationResetToken,\n  loopAnimation,\n  useDraco,\n  useMeshopt,\n  extendLoader,\n  onReady,\n  onAnimations,\n  selectedMesh,\n  onSelectMesh,\n}: {\n  selectedMesh: string | null;\n  onSelectMesh: (id: string | null) => void;\n  src: string;\n  shading: ViewerShading;\n  animation: string | null;\n  animationPlaying: boolean;\n  animationSpeed: number;\n  animationResetToken: number;\n  loopAnimation: boolean;\n  useDraco: boolean | string;\n  useMeshopt: boolean;\n  extendLoader?: (loader: GLTFLoader) => void;\n  onReady: () => void;\n  onAnimations: (names: string[]) => void;\n}) {\n  const gltf = useGLTF(src, useDraco, useMeshopt, extendLoader);\n  prepareAnimationBounds(gltf.scene, gltf.animations);\n  const model = useMemo(() => {\n    const result = clone(gltf.scene);\n    result.traverse((object) => {\n      if ((object as Mesh).isMesh) (object as Mesh).castShadow = true;\n    });\n    result.updateMatrixWorld(true);\n    return result;\n  }, [gltf.scene]);\n  const selectedObject = useMemo(() => {\n    if (!selectedMesh) return null;\n    let match: Mesh | null = null;\n    model.traverse((object) => {\n      if ((object as Mesh).isMesh && object.userData.viewerNodeId === selectedMesh)\n        match = object as Mesh;\n    });\n    return match;\n  }, [model, selectedMesh]);\n  useEffect(() => retainModelResources(model), [model]);\n  const invalidate = useThree((state) => state.invalidate);\n  const { mixer, actions, names } = useMemo(() => {\n    const mixer = new AnimationMixer(model);\n    return {\n      mixer,\n      names: gltf.animations.map((clip) => clip.name),\n      actions: Object.fromEntries(\n        gltf.animations.map((clip) => [clip.name, mixer.clipAction(clip)]),\n      ),\n    };\n  }, [gltf.animations, model]);\n  useEffect(\n    () => () => {\n      mixer.stopAllAction();\n    },\n    [mixer],\n  );\n  const activeAction = animation ? actions[animation] : undefined;\n  const advancing = useRef(false);\n  useFrame((_, delta) => {\n    // The first demand frame may include seconds spent idle. Do not apply that\n    // elapsed time to a newly resumed or restarted action.\n    mixer.update(advancing.current ? delta : 0);\n    advancing.current = Boolean(\n      activeAction?.isRunning() && activeAction.getEffectiveTimeScale() !== 0,\n    );\n    if (advancing.current) invalidate();\n  });\n  const playback = useRef({ animationPlaying, animationSpeed });\n  playback.current = { animationPlaying, animationSpeed };\n  useEffect(() => {\n    const restore: (() => void)[] = [];\n    model.traverse((object) => {\n      const mesh = object as Mesh;\n      if (!mesh.isMesh) return;\n      if (shading === \"realistic\") return;\n      const original = mesh.material;\n      const material =\n        shading === \"normals\"\n          ? new MeshNormalMaterial()\n          : shading === \"wireframe\"\n            ? new MeshBasicMaterial({ color: \"#34483f\", wireframe: true })\n            : new MeshStandardMaterial({ color: \"#a7aaa5\", roughness: 0.82 });\n      mesh.material = material;\n      restore.push(() => {\n        mesh.material = original;\n        material.dispose();\n      });\n    });\n    return () => restore.forEach((reset) => reset());\n  }, [model, shading]);\n\n  useEffect(() => {\n    onAnimations(names);\n    onReady();\n  }, [gltf, names, onAnimations, onReady]);\n\n  useEffect(() => {\n    if (!activeAction) return;\n    activeAction.reset();\n    activeAction.clampWhenFinished = !loopAnimation;\n    activeAction.setLoop(loopAnimation ? LoopRepeat : LoopOnce, loopAnimation ? Infinity : 1);\n    activeAction.play();\n    advancing.current = false;\n    invalidate();\n    activeAction.paused = !playback.current.animationPlaying;\n    activeAction.setEffectiveTimeScale(playback.current.animationSpeed);\n    return () => {\n      activeAction.stop();\n    };\n  }, [activeAction, animation, loopAnimation, invalidate]);\n\n  useEffect(() => {\n    if (!activeAction) return;\n    activeAction.paused = !animationPlaying;\n    activeAction.setEffectiveTimeScale(animationSpeed);\n    advancing.current = false;\n    invalidate();\n  }, [activeAction, animationPlaying, animationSpeed, invalidate]);\n\n  useEffect(() => {\n    if (!activeAction || animationResetToken === 0) return;\n    activeAction.reset().play();\n    advancing.current = false;\n    invalidate();\n    activeAction.paused = !playback.current.animationPlaying;\n  }, [activeAction, animationResetToken, invalidate]);\n\n  return (\n    <>\n      <primitive\n        object={model}\n        onClick={(event: import(\"@react-three/fiber\").ThreeEvent<MouseEvent>) => {\n          event.stopPropagation();\n          onSelectMesh(event.object.userData.viewerNodeId ?? null);\n        }}\n      />\n      {selectedObject && createPortal(<SelectionOutline />, selectedObject)}\n    </>\n  );\n}\n\nfunction ViewerMaterial({\n  shading,\n  color = \"#a7aaa5\",\n}: {\n  shading: ViewerShading;\n  color?: string;\n}) {\n  if (shading === \"normals\") return <meshNormalMaterial />;\n  if (shading === \"wireframe\") return <meshBasicMaterial color=\"#34483f\" wireframe />;\n  return (\n    <meshStandardMaterial\n      color={shading === \"solid\" ? \"#a7aaa5\" : color}\n      roughness={shading === \"solid\" ? 0.82 : 0.68}\n      metalness={0}\n    />\n  );\n}\n\nfunction SelectionOutline() {\n  return (\n    <Outlines name=\"Viewer selection outline\" color=\"#f2a93b\" thickness={3} toneMapped={false} />\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/scene-camera.tsx",
      "content": "\"use client\";\n\nimport { CameraControls, CameraControlsImpl } from \"@react-three/drei\";\nimport { useFrame, useThree } from \"@react-three/fiber\";\nimport { type RefObject, useCallback, useEffect, useRef } from \"react\";\nimport {\n  type Group,\n  type OrthographicCamera as ThreeOrthographicCamera,\n  type PerspectiveCamera as ThreePerspectiveCamera,\n  Vector3,\n} from \"three\";\nimport { frameBounds } from \"./model-inspection\";\nimport type { ViewerCameraStore } from \"./model-viewer-camera\";\nimport type { ViewerCameraPreset, ViewerCameraState, ViewerLighting } from \"./model-viewer-types\";\n\nconst presetVectors: Record<ViewerCameraPreset, [number, number, number]> = {\n  isometric: [1.7, 1.15, 1.7],\n  front: [0, 0, 1],\n  right: [1, 0, 0],\n  back: [0, 0, -1],\n  left: [-1, 0, 0],\n  top: [0.001, 1, 0.001],\n  bottom: [0.001, -1, 0.001],\n};\n\nexport function CameraRig({\n  lighting,\n  objectRef,\n  fitVersion,\n  preset,\n  resetToken,\n  autoRotate,\n  autoRotateSpeed,\n  onCameraChange,\n  cameraStore,\n  paneSelector,\n  fixed,\n}: {\n  lighting: ViewerLighting;\n  fixed: boolean;\n  paneSelector: string;\n  objectRef: RefObject<Group | null>;\n  fitVersion: number;\n  preset: ViewerCameraPreset;\n  resetToken: number;\n  autoRotate: boolean;\n  autoRotateSpeed: number;\n  onCameraChange?: (state: ViewerCameraState) => void;\n  cameraStore?: ViewerCameraStore;\n}) {\n  const controls = useRef<CameraControlsImpl | null>(null);\n  const invalidate = useThree((state) => state.invalidate);\n  const camera = useThree(\n    (state) => state.camera as ThreePerspectiveCamera | ThreeOrthographicCamera,\n  );\n  const size = useThree((state) => state.size);\n\n  const frameObject = useCallback(\n    (transition: boolean) => {\n      const object = objectRef.current;\n      if (!object || !controls.current) return;\n      const { center, radius, distance, near, far } = frameBounds(\n        object,\n        size.width / size.height,\n        \"fov\" in camera ? camera.fov : 42,\n      );\n      camera.near = near;\n      camera.far = far;\n      camera.updateProjectionMatrix();\n      if (\"isOrthographicCamera\" in camera)\n        void controls.current.zoomTo(Math.min(size.width, size.height) / (radius * 2.3), false);\n      const direction = new Vector3(\n        ...(lighting === \"outside\" && preset === \"isometric\"\n          ? ([1.7, 0.5, 1.7] as const)\n          : presetVectors[preset]),\n      ).normalize();\n      const position = center.clone().add(direction.multiplyScalar(distance));\n      controls.current.minDistance = radius * 0.05;\n      controls.current.maxDistance = distance * 10;\n      void controls.current.setLookAt(\n        position.x,\n        position.y,\n        position.z,\n        center.x,\n        center.y,\n        center.z,\n        transition,\n      );\n      if (!transition) controls.current.saveState();\n    },\n    [camera, lighting, objectRef, preset, size.width, size.height],\n  );\n\n  useEffect(() => {\n    frameObject(false);\n  }, [fitVersion, frameObject, size.width, size.height]);\n  useEffect(() => {\n    if (resetToken > 0) frameObject(true);\n  }, [resetToken, frameObject]);\n\n  useEffect(() => {\n    if (!cameraStore) return;\n    cameraStore.register((view, transition) => {\n      void controls.current?.setLookAt(...view.position, ...view.target, transition);\n      // Imperative commands do not cause a React render. Wake the demand loop\n      // so controls apply the new position and publish the live camera view.\n      invalidate();\n    });\n    const current = controls.current;\n    if (current)\n      cameraStore.publish({\n        position: current.getPosition(new Vector3()).toArray(),\n        target: current.getTarget(new Vector3()).toArray(),\n      });\n    return () => cameraStore.register(null);\n  }, [cameraStore, invalidate]);\n\n  useFrame((_, delta) => {\n    if (autoRotate && controls.current)\n      void controls.current.rotate(autoRotateSpeed * delta, 0, false);\n  });\n\n  function readCamera(): ViewerCameraState | null {\n    if (!controls.current) return null;\n    const position = controls.current.getPosition(new Vector3());\n    const target = controls.current.getTarget(new Vector3());\n    return { position: position.toArray(), target: target.toArray() };\n  }\n\n  function reportLiveCamera() {\n    const view = readCamera();\n    if (view) cameraStore?.publish(view);\n  }\n\n  function reportCamera() {\n    const view = readCamera();\n    if (view) onCameraChange?.(view);\n  }\n\n  const actions = CameraControlsImpl.ACTION;\n  const orthographic = \"isOrthographicCamera\" in camera;\n  return (\n    <CameraControls\n      ref={controls}\n      domElement={document.querySelector<HTMLElement>(paneSelector) ?? undefined}\n      makeDefault\n      smoothTime={0.25}\n      dollyToCursor\n      onChange={reportLiveCamera}\n      onRest={reportCamera}\n      azimuthRotateSpeed={fixed ? 0 : 1}\n      polarRotateSpeed={fixed ? 0 : 1}\n      mouseButtons={{\n        left: fixed ? actions.TRUCK : actions.ROTATE,\n        right: actions.TRUCK,\n        middle: orthographic ? actions.ZOOM : actions.DOLLY,\n        wheel: orthographic ? actions.ZOOM : actions.DOLLY,\n      }}\n      touches={{\n        one: fixed ? actions.TOUCH_TRUCK : actions.TOUCH_ROTATE,\n        two: orthographic ? actions.TOUCH_ZOOM_TRUCK : actions.TOUCH_DOLLY_TRUCK,\n        three: actions.TOUCH_TRUCK,\n      }}\n    />\n  );\n}\n\nexport function FitStaticCamera({\n  objectRef,\n  fitVersion,\n  preset,\n  resetToken,\n}: {\n  objectRef: RefObject<Group | null>;\n  fitVersion: number;\n  preset: ViewerCameraPreset;\n  resetToken: number;\n}) {\n  const camera = useThree(\n    (state) => state.camera as ThreePerspectiveCamera | ThreeOrthographicCamera,\n  );\n  const size = useThree((state) => state.size);\n  useEffect(() => {\n    const object = objectRef.current;\n    if (!object) return;\n    const { center, radius, distance, near, far } = frameBounds(\n      object,\n      size.width / size.height,\n      \"fov\" in camera ? camera.fov : 42,\n    );\n    const direction = new Vector3(...presetVectors[preset]).normalize();\n    camera.position.copy(center).add(direction.multiplyScalar(distance));\n    camera.near = near;\n    camera.far = far;\n    if (\"isOrthographicCamera\" in camera)\n      camera.zoom = Math.min(size.width, size.height) / (radius * 2.3);\n    camera.lookAt(center);\n    camera.updateProjectionMatrix();\n  }, [camera, fitVersion, objectRef, preset, resetToken, size.width, size.height]);\n  return null;\n}\n\nexport function DragLook({ selector }: { selector: string }) {\n  const { camera, invalidate } = useThree();\n  useEffect(() => {\n    const target = document.querySelector<HTMLElement>(selector);\n    if (!target) return;\n    let dragging = false;\n    camera.rotation.reorder(\"YXZ\");\n    target.tabIndex = 0;\n    const down = (event: globalThis.PointerEvent) => {\n      if (event.button === 0) {\n        dragging = true;\n        target.focus();\n        target.setPointerCapture(event.pointerId);\n      }\n    };\n    const move = (event: globalThis.PointerEvent) => {\n      if (!dragging) return;\n      camera.rotation.y -= event.movementX * 0.002;\n      camera.rotation.x = Math.max(\n        -Math.PI / 2 + 0.05,\n        Math.min(Math.PI / 2 - 0.05, camera.rotation.x - event.movementY * 0.002),\n      );\n      invalidate();\n    };\n    const up = (event: globalThis.PointerEvent) => {\n      dragging = false;\n      if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);\n    };\n    target.addEventListener(\"pointerdown\", down);\n    target.addEventListener(\"pointermove\", move);\n    target.addEventListener(\"pointerup\", up);\n    target.addEventListener(\"pointercancel\", up);\n    return () => {\n      target.removeEventListener(\"pointerdown\", down);\n      target.removeEventListener(\"pointermove\", move);\n      target.removeEventListener(\"pointerup\", up);\n      target.removeEventListener(\"pointercancel\", up);\n    };\n  }, [camera, invalidate, selector]);\n  return null;\n}\n\nexport function FirstPersonMovement({\n  requirePointerLock,\n  selector,\n}: {\n  requirePointerLock: boolean;\n  selector: string;\n}) {\n  const { camera, gl, invalidate } = useThree();\n  const pressed = useRef(new Set<string>());\n  useEffect(() => {\n    const target = document.querySelector<HTMLElement>(selector);\n    const down = (event: KeyboardEvent) => {\n      if ((event.target as HTMLElement)?.closest(\"input, textarea, select, [contenteditable]\"))\n        return;\n      if (!requirePointerLock && event.target !== target) return;\n      if (requirePointerLock && document.pointerLockElement !== gl.domElement) return;\n      pressed.current.add(event.code);\n      invalidate();\n      if (\n        (document.pointerLockElement || !requirePointerLock) &&\n        (event.code.startsWith(\"Arrow\") || event.code === \"Space\")\n      )\n        event.preventDefault();\n    };\n    const up = (event: KeyboardEvent) => pressed.current.delete(event.code);\n    const blur = () => pressed.current.clear();\n    window.addEventListener(\"keydown\", down);\n    window.addEventListener(\"keyup\", up);\n    window.addEventListener(\"blur\", blur);\n    return () => {\n      window.removeEventListener(\"keydown\", down);\n      window.removeEventListener(\"keyup\", up);\n      window.removeEventListener(\"blur\", blur);\n    };\n  }, [requirePointerLock, selector, gl, invalidate]);\n  useFrame((_, delta) => {\n    const target = document.querySelector<HTMLElement>(selector);\n    if (\n      requirePointerLock\n        ? document.pointerLockElement !== gl.domElement\n        : document.activeElement !== target\n    )\n      return;\n    const speed = Math.min(delta, 0.05) * 2.2;\n    const keys = pressed.current;\n    const forward =\n      Number(keys.has(\"KeyW\") || keys.has(\"ArrowUp\")) -\n      Number(keys.has(\"KeyS\") || keys.has(\"ArrowDown\"));\n    const sideways =\n      Number(keys.has(\"KeyD\") || keys.has(\"ArrowRight\")) -\n      Number(keys.has(\"KeyA\") || keys.has(\"ArrowLeft\"));\n    const vertical =\n      Number(keys.has(\"Space\") || keys.has(\"KeyE\")) -\n      Number(keys.has(\"ShiftLeft\") || keys.has(\"ShiftRight\") || keys.has(\"KeyQ\"));\n    if (!forward && !sideways && !vertical) return;\n    const scale = 1 / Math.hypot(forward, sideways, vertical);\n    camera.translateZ(-forward * speed * scale);\n    camera.translateX(sideways * speed * scale);\n    camera.position.y += vertical * speed * scale;\n    invalidate();\n  });\n  return null;\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-expanded-viewer-focus.ts",
      "content": "\"use client\";\n\nimport { type RefObject, useEffect, useRef } from \"react\";\n\nconst inertLocks = new WeakMap<HTMLElement, { count: number; previous: boolean }>();\nconst scrollLocks = new WeakMap<Document, { count: number; previous: string }>();\nconst focusable = 'button, a[href], input, select, textarea, [tabindex], [contenteditable=\"true\"]';\n\n/** Contain the fallback fullscreen viewer without remounting its canvas. */\nexport function useExpandedViewerFocus(\n  ref: RefObject<HTMLDivElement | null>,\n  expanded: boolean,\n  returnFocus: RefObject<HTMLElement | null>,\n  onClose: () => void,\n) {\n  const close = useRef(onClose);\n  close.current = onClose;\n  useEffect(() => {\n    const root = ref.current;\n    if (!expanded || !root) return;\n    const doc = root.ownerDocument;\n    const previousFocus = returnFocus.current;\n    let lastFocus: HTMLElement = root;\n    const locks = scrollLocks.get(doc) ?? { count: 0, previous: doc.body.style.overflow };\n    locks.count++;\n    scrollLocks.set(doc, locks);\n    doc.body.style.overflow = \"hidden\";\n    const inertElements = new Set<HTMLElement>();\n    const portals = new Map<HTMLElement, { position: string; zIndex: string }>();\n\n    // Base UI menus and tooltips are portaled. Follow their ARIA ownership\n    // instead of treating every document-level popup as part of this viewer.\n    function insideElements() {\n      const inside = new Set<HTMLElement>([root!]);\n      for (const element of inside) {\n        for (const trigger of element.querySelectorAll(\"[aria-controls], [aria-describedby]\")) {\n          const ids = `${trigger.getAttribute(\"aria-controls\") ?? \"\"} ${trigger.getAttribute(\"aria-describedby\") ?? \"\"}`;\n          for (const id of ids.split(/\\s+/).filter(Boolean)) {\n            const popup = doc.getElementById(id);\n            const portal = popup?.closest<HTMLElement>(\"[data-base-ui-portal]\");\n            if (portal) inside.add(portal);\n          }\n        }\n      }\n      return [...inside];\n    }\n    function tabbables() {\n      return insideElements()\n        .flatMap((element) => [...element.querySelectorAll<HTMLElement>(focusable)])\n        .filter(\n          (element) =>\n            element.tabIndex >= 0 &&\n            !element.matches(\":disabled\") &&\n            !element.closest(\"[inert]\") &&\n            element.getClientRects().length > 0 &&\n            getComputedStyle(element).visibility !== \"hidden\",\n        );\n    }\n    function releaseInert() {\n      for (const element of inertElements) {\n        const lock = inertLocks.get(element)!;\n        if (--lock.count === 0) {\n          element.inert = lock.previous;\n          inertLocks.delete(element);\n        }\n      }\n      inertElements.clear();\n    }\n    function isolate() {\n      releaseInert();\n      const inside = insideElements();\n      for (const portal of inside.slice(1)) {\n        if (!portals.has(portal))\n          portals.set(portal, { position: portal.style.position, zIndex: portal.style.zIndex });\n        portal.style.position = \"relative\";\n        portal.style.zIndex = \"101\";\n      }\n      function visit(parent: HTMLElement) {\n        for (const child of parent.children) {\n          if (!(child instanceof HTMLElement) || inside.includes(child)) continue;\n          if (inside.some((element) => child.contains(element))) visit(child);\n          else {\n            const lock = inertLocks.get(child) ?? { count: 0, previous: child.inert };\n            lock.count++;\n            inertLocks.set(child, lock);\n            inertElements.add(child);\n            child.inert = true;\n          }\n        }\n      }\n      visit(doc.body);\n    }\n    function containFocus(event: FocusEvent) {\n      const target = event.target;\n      if (\n        target instanceof HTMLElement &&\n        insideElements().some((element) => element.contains(target))\n      ) {\n        lastFocus = target;\n      } else {\n        (lastFocus.isConnected && !lastFocus.closest(\"[inert]\") ? lastFocus : root!).focus({\n          preventScroll: true,\n        });\n      }\n    }\n    function keyDown(event: KeyboardEvent) {\n      if (event.defaultPrevented) return;\n      if (event.key === \"Tab\") {\n        const elements = tabbables();\n        const index = elements.indexOf(doc.activeElement as HTMLElement);\n        if (\n          !elements.length ||\n          (event.shiftKey ? index <= 0 : index === -1 || index === elements.length - 1)\n        ) {\n          event.preventDefault();\n          (event.shiftKey ? (elements.at(-1) ?? root!) : (elements[0] ?? root!)).focus();\n        }\n      }\n    }\n    function handleEscape(event: KeyboardEvent) {\n      if (event.key !== \"Escape\") return;\n      // Menus get the first Escape. Tooltips must not consume the viewer's\n      // escape action merely because a toolbar button currently has focus.\n      if (\n        insideElements().some((element) =>\n          [...element.querySelectorAll('[role=\"menu\"], [role=\"listbox\"], [role=\"dialog\"]')].some(\n            (popup) => popup.getClientRects().length > 0 && !popup.hasAttribute(\"data-closed\"),\n          ),\n        )\n      )\n        return;\n      event.preventDefault();\n      event.stopPropagation();\n      close.current();\n    }\n    isolate();\n    if (previousFocus && root.contains(previousFocus)) lastFocus = previousFocus;\n    else (tabbables()[0] ?? root).focus({ preventScroll: true });\n    doc.addEventListener(\"focusin\", containFocus);\n    doc.addEventListener(\"keydown\", keyDown);\n    doc.addEventListener(\"keydown\", handleEscape, true);\n    const observer = new MutationObserver(isolate);\n    observer.observe(doc.body, {\n      subtree: true,\n      childList: true,\n      attributes: true,\n      attributeFilter: [\"aria-controls\", \"aria-describedby\"],\n    });\n    return () => {\n      observer.disconnect();\n      doc.removeEventListener(\"focusin\", containFocus);\n      doc.removeEventListener(\"keydown\", keyDown);\n      doc.removeEventListener(\"keydown\", handleEscape, true);\n      releaseInert();\n      for (const [portal, style] of portals) Object.assign(portal.style, style);\n      if (--locks.count === 0) {\n        doc.body.style.overflow = locks.previous;\n        scrollLocks.delete(doc);\n      }\n      if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });\n    };\n  }, [expanded, ref, returnFocus]);\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-controlled-state.ts",
      "content": "\"use client\";\nimport { useCallback, useState } from \"react\";\n\n/** Controlled/default/onChange triplet; the third item resets the uncontrolled value silently. */\nexport function useControlledState<T>(\n  controlled: T | undefined,\n  defaultValue: T,\n  onChange?: (value: T) => void,\n): [T, (value: T) => void, (value: T) => void] {\n  const [internal, setInternal] = useState(defaultValue);\n  const value = controlled === undefined ? internal : controlled;\n  const setValue = useCallback(\n    (next: T) => {\n      if (controlled === undefined) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [value, setValue, setInternal];\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-capture.ts",
      "content": "\"use client\";\nimport type { RefObject } from \"react\";\nimport { viewerBackgroundColor } from \"./model-viewer-colors\";\nimport type { ViewerLighting } from \"./model-viewer-types\";\nimport type { ReportFeedback } from \"./use-viewer-feedback\";\n\n/** PNG copy/download of the composited canvas over the lighting background. */\nexport function useViewerCapture({\n  canvasRef,\n  canCapture,\n  lighting,\n  fileName,\n  report,\n}: {\n  canvasRef: RefObject<HTMLCanvasElement | null>;\n  canCapture: boolean;\n  lighting: ViewerLighting;\n  fileName: string;\n  report: ReportFeedback;\n}) {\n  return async function capture(action: \"copy\" | \"download\") {\n    const canvas = canvasRef.current;\n    if (!canCapture || !canvas) return;\n    try {\n      const source = document.createElement(\"canvas\");\n      source.width = canvas.width;\n      source.height = canvas.height;\n      const context = source.getContext(\"2d\");\n      if (!context) throw new Error(\"The viewer could not create a PNG.\");\n      context.fillStyle = viewerBackgroundColor(lighting);\n      context.fillRect(0, 0, source.width, source.height);\n      context.drawImage(canvas, 0, 0);\n      const image = new Promise<Blob>((resolve, reject) => {\n        source.toBlob(\n          (blob) =>\n            blob ? resolve(blob) : reject(new Error(\"The viewer could not create a PNG.\")),\n          \"image/png\",\n        );\n      });\n      if (action === \"copy\") {\n        if (!navigator.clipboard?.write || typeof ClipboardItem === \"undefined\")\n          throw new Error(\"Image clipboard access is unavailable. Download the PNG instead.\");\n        await navigator.clipboard.write([new ClipboardItem({ \"image/png\": image })]);\n        report(\"Screenshot copied to clipboard.\");\n      } else {\n        const blob = await image;\n        const url = URL.createObjectURL(blob);\n        const link = document.createElement(\"a\");\n        link.href = url;\n        link.download = `${\n          fileName\n            .replace(/[^a-z0-9-_]+/gi, \"-\")\n            .replace(/^-|-$/g, \"\")\n            .slice(0, 80) || \"model-view\"\n        }.png`;\n        link.click();\n        setTimeout(() => URL.revokeObjectURL(url), 60_000);\n        report(\"Screenshot downloaded as PNG.\");\n      }\n    } catch (error) {\n      report(error instanceof Error ? error.message : \"Could not capture the view.\", true);\n    }\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-feedback.ts",
      "content": "\"use client\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type ViewerFeedback = { message: string; error: boolean };\nexport type ReportFeedback = (message: string, error?: boolean) => void;\n\n/** Short-lived status message shown in the viewer's status part. */\nexport function useViewerFeedback() {\n  const [feedback, setFeedback] = useState<ViewerFeedback | null>(null);\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const clearTimer = useCallback(() => {\n    if (timer.current) clearTimeout(timer.current);\n    timer.current = null;\n  }, []);\n  useEffect(() => clearTimer, [clearTimer]);\n  const report = useCallback<ReportFeedback>(\n    (message, error = false) => {\n      setFeedback({ message, error });\n      clearTimer();\n      timer.current = setTimeout(() => setFeedback(null), 4500);\n    },\n    [clearTimer],\n  );\n  const clearFeedback = useCallback(() => {\n    clearTimer();\n    setFeedback(null);\n  }, [clearTimer]);\n  return { feedback, report, clearFeedback };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-fullscreen.ts",
      "content": "\"use client\";\nimport { type RefObject, useEffect, useRef, useState } from \"react\";\nimport { useExpandedViewerFocus } from \"./use-expanded-viewer-focus\";\nimport type { ReportFeedback } from \"./use-viewer-feedback\";\n\n/** Native fullscreen with an in-page expanded fallback when the browser refuses entry. */\nexport function useViewerFullscreen(\n  viewerRef: RefObject<HTMLDivElement | null>,\n  report: ReportFeedback,\n) {\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [isExpanded, setIsExpanded] = useState(false);\n  const returnFocus = useRef<HTMLElement | null>(null);\n\n  useEffect(() => {\n    const update = () => {\n      const active = document.fullscreenElement === viewerRef.current;\n      setIsFullscreen(active);\n      if (active) setIsExpanded(false);\n    };\n    document.addEventListener(\"fullscreenchange\", update);\n    return () => document.removeEventListener(\"fullscreenchange\", update);\n  }, [viewerRef]);\n\n  useExpandedViewerFocus(viewerRef, isExpanded, returnFocus, () => setIsExpanded(false));\n\n  async function toggleFullscreen() {\n    if (isExpanded) {\n      setIsExpanded(false);\n      return;\n    }\n    const viewer = viewerRef.current;\n    if (viewer && document.fullscreenElement === viewer) {\n      // A failed exit leaves native fullscreen active; never stack the fallback on top of it.\n      try {\n        await document.exitFullscreen();\n      } catch {\n        report(\"Could not exit fullscreen. Press Escape to leave it.\", true);\n      }\n      return;\n    }\n    returnFocus.current =\n      document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    try {\n      if (!document.fullscreenEnabled || !viewer) throw new Error(\"Fullscreen unavailable\");\n      await viewer.requestFullscreen();\n    } catch {\n      if (document.fullscreenElement !== viewer) setIsExpanded(true);\n    }\n  }\n\n  return { fullscreen: isFullscreen || isExpanded, isExpanded, toggleFullscreen };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-visibility.ts",
      "content": "\"use client\";\nimport { type RefObject, useEffect, useState } from \"react\";\n\n/** True while the viewer is scrolled out of view or the page is hidden. */\nexport function useViewerHidden(viewerRef: RefObject<HTMLElement | null>) {\n  const [inViewport, setInViewport] = useState(true);\n  const [pageVisible, setPageVisible] = useState(true);\n  useEffect(() => {\n    const update = () => setPageVisible(document.visibilityState !== \"hidden\");\n    update();\n    document.addEventListener(\"visibilitychange\", update);\n    const observer =\n      typeof IntersectionObserver === \"undefined\"\n        ? null\n        : new IntersectionObserver(([entry]) => setInViewport(entry.isIntersecting));\n    if (viewerRef.current) observer?.observe(viewerRef.current);\n    return () => {\n      document.removeEventListener(\"visibilitychange\", update);\n      observer?.disconnect();\n    };\n  }, [viewerRef]);\n  return !inViewport || !pageVisible;\n}\n\nexport function useReducedMotion(enabled: boolean) {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    if (!enabled) {\n      setReduced(false);\n      return;\n    }\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReduced(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, [enabled]);\n  return reduced;\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/inspector-hierarchy.ts",
      "content": "import type { ModelInspection } from \"./model-inspection\";\n\ntype Node = ModelInspection[\"nodes\"][number];\nconst parentPath = (id: string) => id.slice(0, Math.max(0, id.lastIndexOf(\"/\")));\n\n/** Index parent paths once; searches walk each matching ancestor at most once. */\nexport function indexHierarchy(nodes: Node[]) {\n  const parents = new Map<string, string>();\n  const branches = new Set<string>();\n  const names = new Map<string, string>();\n  for (const node of nodes) {\n    const parent = parentPath(node.id);\n    parents.set(node.id, parent);\n    branches.add(parent);\n    names.set(node.id, node.name.toLowerCase());\n  }\n  return {\n    branches,\n    visible(query: string, collapsed: ReadonlySet<string>): Node[] {\n      const normalized = query.toLowerCase();\n      if (normalized) {\n        const matching = new Set<string>();\n        for (const node of nodes) {\n          if (!names.get(node.id)!.includes(normalized)) continue;\n          let id: string | undefined = node.id;\n          while (id && !matching.has(id)) {\n            matching.add(id);\n            id = parents.get(id) ?? parentPath(id);\n          }\n        }\n        return nodes.filter((node) => matching.has(node.id));\n      }\n      // Memoize hidden ancestry; standalone consumers need not sort parents\n      // before children, and collapsed paths may refer to omitted groups.\n      const hidden = new Map<string, boolean>();\n      return nodes.filter((node) => {\n        const chain: string[] = [];\n        let id = node.id;\n        let result = false;\n        while (id) {\n          if (hidden.has(id)) {\n            result = hidden.get(id)!;\n            break;\n          }\n          chain.push(id);\n          const parent = parents.get(id) ?? parentPath(id);\n          if (collapsed.has(parent)) {\n            result = true;\n            break;\n          }\n          id = parent;\n        }\n        for (const child of chain) hidden.set(child, result);\n        return !result;\n      });\n    },\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/viewer-popups.tsx",
      "content": "\"use client\";\n\n// Own the portal so installed viewers do not require modified host primitives.\nimport { Menu as MenuPrimitive } from \"@base-ui/react/menu\";\nimport { Tooltip as TooltipPrimitive } from \"@base-ui/react/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useOptionalViewerRuntime } from \"./model-viewer-context\";\n\nexport function DropdownMenuContent({\n  align = \"start\",\n  alignOffset = 0,\n  side = \"bottom\",\n  sideOffset = 4,\n  className,\n  ...props\n}: MenuPrimitive.Popup.Props &\n  Pick<MenuPrimitive.Positioner.Props, \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\">) {\n  const runtime = useOptionalViewerRuntime();\n  const container =\n    runtime?.state.fullscreen && !runtime.root.isExpanded ? runtime.viewerRef : undefined;\n  return (\n    <MenuPrimitive.Portal container={container}>\n      <MenuPrimitive.Positioner\n        className=\"isolate z-50 outline-none\"\n        align={align}\n        alignOffset={alignOffset}\n        side={side}\n        sideOffset={sideOffset}\n      >\n        <MenuPrimitive.Popup\n          data-slot=\"dropdown-menu-content\"\n          className={cn(\n            \"cn-menu-target cn-menu-translucent z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95\",\n            className,\n          )}\n          {...props}\n        />\n      </MenuPrimitive.Positioner>\n    </MenuPrimitive.Portal>\n  );\n}\n\nexport function TooltipContent({\n  className,\n  side = \"top\",\n  sideOffset = 4,\n  align = \"center\",\n  alignOffset = 0,\n  children,\n  ...props\n}: TooltipPrimitive.Popup.Props &\n  Pick<TooltipPrimitive.Positioner.Props, \"align\" | \"alignOffset\" | \"side\" | \"sideOffset\">) {\n  const runtime = useOptionalViewerRuntime();\n  const container =\n    runtime?.state.fullscreen && !runtime.root.isExpanded ? runtime.viewerRef : undefined;\n  return (\n    <TooltipPrimitive.Portal container={container}>\n      <TooltipPrimitive.Positioner\n        align={align}\n        alignOffset={alignOffset}\n        side={side}\n        sideOffset={sideOffset}\n        className=\"isolate z-[150]\"\n      >\n        <TooltipPrimitive.Popup\n          data-slot=\"tooltip-content\"\n          className={cn(\n            \"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95\",\n            className,\n          )}\n          {...props}\n        >\n          {children}\n          <TooltipPrimitive.Arrow className=\"z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5\" />\n        </TooltipPrimitive.Popup>\n      </TooltipPrimitive.Positioner>\n    </TooltipPrimitive.Portal>\n  );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/model-resources.ts",
      "content": "import type { Material, Mesh, Object3D, SkinnedMesh, Texture } from \"three\";\n\ntype Resource = { dispose: () => void };\nconst users = new WeakMap<Resource, number>();\n\n/** Release GPU allocations after the last mounted clone stops using them.\n * Cached Three objects and image sources remain reusable on a later mount.\n */\nexport function retainModelResources(model: Object3D) {\n  const resources = new Set<Resource>();\n  function materialResources(material: Material) {\n    resources.add(material);\n    for (const value of Object.values(material)) {\n      if ((value as Texture | null)?.isTexture) resources.add(value as Texture);\n    }\n  }\n  model.traverse((object) => {\n    const mesh = object as Mesh;\n    if (!mesh.isMesh) return;\n    resources.add(mesh.geometry);\n    for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material])\n      materialResources(material);\n    const skinned = object as SkinnedMesh;\n    if (skinned.isSkinnedMesh) resources.add(skinned.skeleton);\n  });\n  for (const resource of resources) users.set(resource, (users.get(resource) ?? 0) + 1);\n  let released = false;\n  return () => {\n    if (released) return;\n    released = true;\n    for (const resource of resources) users.set(resource, users.get(resource)! - 1);\n    // React Strict Mode and mode changes can release and acquire in one commit.\n    // Wait for all mounts before deciding whether shared resources are unused.\n    queueMicrotask(() => {\n      for (const resource of resources) {\n        if (users.get(resource) !== 0) continue;\n        users.delete(resource);\n        resource.dispose();\n      }\n    });\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-preferences.ts",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { ModelViewerRootProps } from \"./model-viewer-types\";\nimport { useControlledState } from \"./use-controlled-state\";\n\n/** Owns controlled/default/onChange preferences and consumes their DOM-ineligible props. */\nexport function useViewerPreferences({\n  mode: controlledMode,\n  defaultMode = \"orbit\",\n  onModeChange,\n  lighting: controlledLighting,\n  defaultLighting = \"day\",\n  onLightingChange,\n  shading: controlledShading,\n  defaultShading = \"realistic\",\n  onShadingChange,\n  showGrid: controlledGrid,\n  defaultShowGrid = false,\n  onGridChange,\n  showFloor: controlledFloor,\n  defaultShowFloor = false,\n  onFloorChange,\n  viewCube: controlledViewCube,\n  defaultViewCube = \"asset-studio\",\n  onViewCubeChange,\n  projection: controlledProjection,\n  defaultProjection = \"perspective\",\n  onProjectionChange,\n  showInspector,\n  inspectorOpen: controlledInspectorOpen,\n  defaultInspectorOpen = false,\n  onInspectorOpenChange,\n  autoRotate: controlledAutoRotate,\n  defaultAutoRotate = false,\n  onAutoRotateChange,\n  cameraPreset: controlledCameraPreset,\n  defaultCameraPreset = \"isometric\",\n  onCameraPresetChange,\n  animation: controlledAnimation,\n  defaultAnimation = null,\n  onAnimationChange,\n  animationPlaying: controlledAnimationPlaying,\n  defaultAnimationPlaying = false,\n  onAnimationPlayingChange,\n  animationSpeed: controlledAnimationSpeed,\n  defaultAnimationSpeed = 1,\n  onAnimationSpeedChange,\n  ...props\n}: ModelViewerRootProps) {\n  const { src } = props;\n  const [mode, setMode] = useControlledState(controlledMode, defaultMode, onModeChange);\n\n  const [viewCube, setViewCube] = useControlledState(\n    controlledViewCube,\n    defaultViewCube,\n    onViewCubeChange,\n  );\n\n  const [projection, setProjection] = useControlledState(\n    controlledProjection,\n    defaultProjection,\n    onProjectionChange,\n  );\n\n  const [inspectorOpen, setInspectorOpen] = useControlledState(\n    controlledInspectorOpen,\n    showInspector ?? defaultInspectorOpen,\n    onInspectorOpenChange,\n  );\n\n  const [lighting, setLighting] = useControlledState(\n    controlledLighting,\n    defaultLighting,\n    onLightingChange,\n  );\n\n  const [shading, setShading] = useControlledState(\n    controlledShading,\n    defaultShading,\n    onShadingChange,\n  );\n\n  const [grid, setGrid] = useControlledState(controlledGrid, defaultShowGrid, onGridChange);\n\n  const [floor, setFloor] = useControlledState(controlledFloor, defaultShowFloor, onFloorChange);\n\n  const [autoRotate, setAutoRotate] = useControlledState(\n    controlledAutoRotate,\n    defaultAutoRotate,\n    onAutoRotateChange,\n  );\n\n  const [cameraPreset, setCameraPreset] = useControlledState(\n    controlledCameraPreset,\n    defaultCameraPreset,\n    onCameraPresetChange,\n  );\n\n  const [animation, setAnimation, resetAnimation] = useControlledState<string | null>(\n    controlledAnimation,\n    defaultAnimation,\n    onAnimationChange,\n  );\n\n  const [animationPlaying, setAnimationPlaying] = useControlledState(\n    controlledAnimationPlaying,\n    defaultAnimationPlaying,\n    onAnimationPlayingChange,\n  );\n\n  const [animationSpeed, setAnimationSpeed] = useControlledState(\n    controlledAnimationSpeed,\n    defaultAnimationSpeed,\n    onAnimationSpeedChange,\n  );\n\n  const previousAnimationSrc = useRef(src);\n  useEffect(() => {\n    if (previousAnimationSrc.current === src) return;\n    previousAnimationSrc.current = src;\n    if (controlledAnimation === undefined) resetAnimation(defaultAnimation);\n  }, [src, defaultAnimation, controlledAnimation, resetAnimation]);\n\n  useEffect(() => {\n    if (showInspector !== undefined && controlledInspectorOpen === undefined)\n      setInspectorOpen(showInspector);\n  }, [showInspector, controlledInspectorOpen, setInspectorOpen]);\n\n  return {\n    mode,\n    setMode,\n    viewCube,\n    setViewCube,\n    projection,\n    setProjection,\n    inspectorOpen,\n    setInspectorOpen,\n    lighting,\n    setLighting,\n    shading,\n    setShading,\n    grid,\n    setGrid,\n    floor,\n    setFloor,\n    autoRotate,\n    setAutoRotate,\n    cameraPreset,\n    setCameraPreset,\n    animation,\n    setAnimation,\n    animationPlaying,\n    setAnimationPlaying,\n    animationSpeed,\n    setAnimationSpeed,\n    controlledAnimation,\n    props,\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-session.ts",
      "content": "\"use client\";\nimport { useGLTF } from \"@react-three/drei\";\nimport { useCallback, useEffect, useReducer, useRef, useState } from \"react\";\nimport type { ModelInspection } from \"./model-inspection\";\nimport { initialViewerLifecycle, viewerLifecycleReducer } from \"./model-viewer-lifecycle\";\nimport { type ModelViewerRootProps, splitPanes, type ViewerMode } from \"./model-viewer-types\";\n\n/** Owns scene generations and data that must be cleared together on replacement/retry. */\nexport function useViewerSession({\n  src,\n  mode,\n  enableCapture,\n  clearCacheOnUnmount,\n  clearFeedback,\n  onLoad,\n  onError,\n  onInspect,\n}: Pick<ModelViewerRootProps, \"src\" | \"onLoad\" | \"onError\" | \"onInspect\"> & {\n  mode: ViewerMode;\n  enableCapture: boolean;\n  clearCacheOnUnmount: boolean;\n  clearFeedback: () => void;\n}) {\n  const [inspection, setInspection] = useState<ModelInspection | null>(null);\n  const [selectedMesh, setSelectedMesh] = useState<string | null>(null);\n  const [animationNames, setAnimationNames] = useState<string[]>([]);\n  const [lifecycle, dispatchLifecycle] = useReducer(viewerLifecycleReducer, initialViewerLifecycle);\n  const loaded = lifecycle.status === \"ready\" || lifecycle.status === \"error\";\n  const viewerError = lifecycle.status === \"error\";\n  const [retryToken, setRetryToken] = useState(0);\n  const [locked, setLocked] = useState(false);\n  const [captureReady, setCaptureReady] = useState(false);\n  const loadReportedGeneration = useRef<number | null>(null);\n  const expectedPanes = mode === \"split\" ? splitPanes.length : 1;\n  const [sceneMounted, setSceneMountedState] = useState(false);\n  /** Clear everything derived from the current scene/load generation. */\n  const resetModelSession = useCallback(() => {\n    setAnimationNames([]);\n    setInspection(null);\n    setSelectedMesh(null);\n    setLocked(false);\n  }, []);\n  const setSceneMounted = useCallback(\n    (mounted: boolean) => {\n      setSceneMountedState(mounted);\n      dispatchLifecycle({ type: mounted ? \"mount\" : \"unmount\" });\n      if (!mounted) {\n        // The canvas goes away with the scene, so capture must wait for the next one.\n        setCaptureReady(false);\n        resetModelSession();\n      }\n    },\n    [resetModelSession],\n  );\n  useEffect(() => {\n    dispatchLifecycle({ type: \"reset\" });\n    clearFeedback();\n    resetModelSession();\n  }, [src, mode, enableCapture, clearFeedback, resetModelSession]);\n\n  useEffect(\n    () => () => {\n      if (src && clearCacheOnUnmount) useGLTF.clear(src);\n    },\n    [src, clearCacheOnUnmount],\n  );\n\n  useEffect(() => {\n    if (lifecycle.status === \"ready\" && loadReportedGeneration.current !== lifecycle.generation) {\n      loadReportedGeneration.current = lifecycle.generation;\n      onLoad?.();\n    }\n  }, [lifecycle.status, lifecycle.generation, onLoad]);\n\n  const reportInspection = useCallback(\n    (value: ModelInspection) => {\n      setInspection(value);\n      onInspect?.(value);\n    },\n    [onInspect],\n  );\n\n  const markReady = useCallback(\n    (index: number) => {\n      dispatchLifecycle({ type: \"ready\", pane: index, expectedPanes });\n    },\n    [expectedPanes],\n  );\n\n  const reportAnimations = useCallback((names: string[]) => {\n    setAnimationNames((current) =>\n      current.length === names.length && current.every((name, index) => name === names[index])\n        ? current\n        : names,\n    );\n  }, []);\n\n  const fail = useCallback(\n    (error: Error) => {\n      dispatchLifecycle({ type: \"error\" });\n      onError?.(error);\n    },\n    [onError],\n  );\n\n  const retry = useCallback(() => {\n    if (!viewerError || !src) return;\n    useGLTF.clear(src);\n    dispatchLifecycle({ type: \"reset\" });\n    clearFeedback();\n    resetModelSession();\n    setRetryToken((value) => value + 1);\n  }, [src, viewerError, clearFeedback, resetModelSession]);\n\n  return {\n    inspection,\n    selectedMesh,\n    setSelectedMesh,\n    animationNames,\n    loaded,\n    viewerError,\n    retryToken,\n    locked,\n    setLocked,\n    captureReady,\n    setCaptureReady,\n    sceneMounted,\n    setSceneMounted,\n    reportInspection,\n    markReady,\n    reportAnimations,\n    fail,\n    retry,\n    status: lifecycle.status,\n  };\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/components/ui/model-viewer/use-viewer-interactions.ts",
      "content": "\"use client\";\nimport {\n  type Dispatch,\n  type SetStateAction,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport type { ViewerMode } from \"./model-viewer-types\";\n\n/** Browser interaction capabilities and layout measurements, independent of the model. */\nexport function useViewerInteractions(\n  mode: ViewerMode,\n  setMode: (mode: ViewerMode) => void,\n  setLocked: Dispatch<SetStateAction<boolean>>,\n) {\n  const [pointerLockAvailable, setPointerLockAvailable] = useState(true);\n  const [resetToken, setResetToken] = useState(0);\n  const [animationResetToken, setAnimationResetToken] = useState(0);\n  const [toolbarOffset, setToolbarOffset] = useState(0);\n  const toolbarMeasurements = useRef(new Map<HTMLElement, number>());\n  const reportToolbar = useCallback((element: HTMLElement, offset: number | null) => {\n    if (offset === null) toolbarMeasurements.current.delete(element);\n    else toolbarMeasurements.current.set(element, offset);\n    setToolbarOffset(Math.max(0, ...toolbarMeasurements.current.values()));\n  }, []);\n  useEffect(() => {\n    const rejectLock = () => {\n      setPointerLockAvailable(false);\n      setLocked(false);\n    };\n    document.addEventListener(\"pointerlockerror\", rejectLock);\n    return () => document.removeEventListener(\"pointerlockerror\", rejectLock);\n  }, [setLocked]);\n  useEffect(() => {\n    setPointerLockAvailable(\"requestPointerLock\" in HTMLElement.prototype);\n  }, []);\n\n  function changeMode(next: ViewerMode) {\n    if (next === mode) return;\n    if (document.pointerLockElement) document.exitPointerLock();\n    setMode(next);\n  }\n\n  return {\n    pointerLockAvailable,\n    resetToken,\n    animationResetToken,\n    toolbarOffset,\n    reportToolbar,\n    changeMode,\n    resetView: () => setResetToken((value) => value + 1),\n    restartAnimation: () => setAnimationResetToken((value) => value + 1),\n  };\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}