{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scene-container",
  "title": "SceneContainer",
  "description": "Base R3F canvas wrapper with theme-aware environment and camera setup.",
  "dependencies": [
    "three",
    "@react-three/fiber",
    "@react-three/drei"
  ],
  "files": [
    {
      "path": "components/threecn/scene-container.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Canvas } from \"@react-three/fiber\"\nimport { OrbitControls } from \"@react-three/drei\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  useShadcnTheme,\n  type ThemeMode,\n} from \"@/components/hooks/use-shadcn-theme\"\n\nexport type EnvironmentPreset = \"studio\" | \"city\" | \"dawn\" | \"night\"\n\ntype LightRig = {\n  ambient: { color: string; intensity: number }\n  key: { color: string; intensity: number; position: [number, number, number] }\n  fill: { color: string; intensity: number; position: [number, number, number] }\n  rim: { color: string; intensity: number; position: [number, number, number] }\n}\n\n/**\n * Offline lighting rigs. We avoid drei's <Environment preset> because it\n * fetches HDRIs from a CDN — scenes must work with zero external assets.\n */\nconst RIGS: Record<EnvironmentPreset, LightRig> = {\n  studio: {\n    ambient: { color: \"#ffffff\", intensity: 0.6 },\n    key: { color: \"#ffffff\", intensity: 2.2, position: [5, 6, 5] },\n    fill: { color: \"#dfe3ff\", intensity: 0.8, position: [-6, 2, 4] },\n    rim: { color: \"#ffffff\", intensity: 1.4, position: [0, 4, -6] },\n  },\n  city: {\n    ambient: { color: \"#c7d2fe\", intensity: 0.7 },\n    key: { color: \"#e0e7ff\", intensity: 1.8, position: [4, 7, 4] },\n    fill: { color: \"#a5b4fc\", intensity: 0.9, position: [-5, 1, 5] },\n    rim: { color: \"#818cf8\", intensity: 1.2, position: [-2, 3, -6] },\n  },\n  dawn: {\n    ambient: { color: \"#fde2c8\", intensity: 0.5 },\n    key: { color: \"#ffb27a\", intensity: 2.0, position: [6, 4, 4] },\n    fill: { color: \"#9fb4ff\", intensity: 0.7, position: [-5, 2, 4] },\n    rim: { color: \"#ffd9a8\", intensity: 1.0, position: [0, 5, -5] },\n  },\n  night: {\n    ambient: { color: \"#1e1b4b\", intensity: 0.4 },\n    key: { color: \"#a78bfa\", intensity: 1.6, position: [4, 5, 4] },\n    fill: { color: \"#4338ca\", intensity: 0.6, position: [-5, 1, 4] },\n    rim: { color: \"#7c3aed\", intensity: 1.6, position: [-2, 3, -6] },\n  },\n}\n\nfunction Rig({\n  preset,\n  fog,\n  theme,\n}: {\n  preset: EnvironmentPreset\n  fog: boolean\n  theme: ThemeMode\n}) {\n  const { bgColor } = useShadcnTheme(theme)\n  const rig = RIGS[preset]\n\n  return (\n    <>\n      {fog ? <fog attach=\"fog\" args={[bgColor.getHex(), 8, 24]} /> : null}\n      <ambientLight color={rig.ambient.color} intensity={rig.ambient.intensity} />\n      <directionalLight\n        color={rig.key.color}\n        intensity={rig.key.intensity}\n        position={rig.key.position}\n      />\n      <directionalLight\n        color={rig.fill.color}\n        intensity={rig.fill.intensity}\n        position={rig.fill.position}\n      />\n      <pointLight\n        color={rig.rim.color}\n        intensity={rig.rim.intensity * 12}\n        position={rig.rim.position}\n        distance={30}\n      />\n    </>\n  )\n}\n\n/**\n * Track whether an element is near the viewport. WebGL contexts are a scarce\n * resource (browsers cap them at ~8-16), so scenes only hold one while they are\n * on (or close to) screen. The generous `rootMargin` pre-mounts a scene just\n * before it scrolls into view, avoiding a blank first frame.\n */\nfunction useInView(\n  ref: React.RefObject<HTMLElement | null>,\n  rootMargin: string\n): boolean {\n  const [inView, setInView] = React.useState(false)\n\n  React.useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    if (typeof IntersectionObserver === \"undefined\") {\n      // No observer available: render eagerly rather than never mounting.\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setInView(true)\n      return\n    }\n    const observer = new IntersectionObserver(\n      ([entry]) => setInView(entry.isIntersecting),\n      { rootMargin }\n    )\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [ref, rootMargin])\n\n  return inView\n}\n\nexport type SceneContainerProps = {\n  className?: string\n  theme?: ThemeMode\n  environment?: EnvironmentPreset\n  fog?: boolean\n  /** Camera position, defaults to a gentle 3/4 view. */\n  camera?: [number, number, number]\n  /** Vertical field of view. */\n  fov?: number\n  children?: React.ReactNode\n  /** Rendered as an HTML overlay above the canvas (not inside the 3D scene). */\n  overlay?: React.ReactNode\n  /**\n   * Drag to orbit the camera (rotate only — zoom and pan disabled). Defaults to\n   * true. Set false if the scene provides its own controls.\n   */\n  orbit?: boolean\n  /**\n   * Mount the WebGL canvas only while it is near the viewport, and tear it down\n   * once it scrolls away. Keeps a grid of many scenes from exhausting the\n   * browser's WebGL context limit and stops `useFrame` from running offscreen.\n   * Defaults to true. Set false for a scene that must always render (e.g. a\n   * persistent hero background).\n   */\n  lazy?: boolean\n}\n\n/**\n * The canvas wrapper every threecn scene builds on. Sets up a transparent\n * canvas (so your CSS background shows through), responsive DPR, a themed\n * lighting rig and optional fog.\n */\nexport function SceneContainer({\n  className,\n  theme = \"auto\",\n  environment = \"studio\",\n  fog = false,\n  camera = [0, 0, 6],\n  fov = 45,\n  children,\n  overlay,\n  orbit = true,\n  lazy = true,\n}: SceneContainerProps) {\n  const wrapRef = React.useRef<HTMLDivElement>(null)\n  const inView = useInView(wrapRef, \"300px\")\n  const active = lazy ? inView : true\n\n  return (\n    <div ref={wrapRef} className={cn(\"relative h-full w-full\", className)}>\n      {active ? (\n        <Canvas\n          dpr={[1, 2]}\n          gl={{ antialias: true, alpha: true, powerPreference: \"high-performance\" }}\n          camera={{ position: camera, fov, near: 0.1, far: 100 }}\n        >\n          <Rig preset={environment} fog={fog} theme={theme} />\n          <React.Suspense fallback={null}>{children}</React.Suspense>\n          {orbit ? (\n            <OrbitControls\n              makeDefault\n              enablePan={false}\n              enableZoom={false}\n              enableDamping\n              dampingFactor={0.1}\n            />\n          ) : null}\n        </Canvas>\n      ) : null}\n      {overlay ? <div className=\"pointer-events-none absolute inset-0\">{overlay}</div> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "components/hooks/use-shadcn-theme.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Color } from \"three\"\n\n/**\n * The set of theme-derived colors exposed to R3F scenes.\n * Every value is a live `THREE.Color`, ready to assign to a material.\n */\nexport type ShadcnThemeColors = {\n  primaryColor: Color\n  primaryForegroundColor: Color\n  bgColor: Color\n  foregroundColor: Color\n  borderColor: Color\n  mutedColor: Color\n  accentColor: Color\n  isDark: boolean\n}\n\n/** CSS custom properties read from `:root` / `.dark`. */\nconst CSS_VARS = {\n  primaryColor: \"--primary\",\n  primaryForegroundColor: \"--primary-foreground\",\n  bgColor: \"--background\",\n  foregroundColor: \"--foreground\",\n  borderColor: \"--border\",\n  mutedColor: \"--muted-foreground\",\n  accentColor: \"--accent\",\n} as const\n\n/**\n * Neutral SSR defaults (a dark-ish indigo palette) used before the DOM is\n * available, so the first paint is never a flash of pure black.\n */\nfunction createDefaults(): ShadcnThemeColors {\n  return {\n    primaryColor: new Color().setHSL(263 / 360, 0.7, 0.6),\n    primaryForegroundColor: new Color().setHSL(0, 0, 0.98),\n    bgColor: new Color().setHSL(240 / 360, 0.1, 0.039),\n    foregroundColor: new Color().setHSL(0, 0, 0.98),\n    borderColor: new Color().setHSL(240 / 360, 0.037, 0.159),\n    mutedColor: new Color().setHSL(240 / 360, 0.05, 0.649),\n    accentColor: new Color().setHSL(240 / 360, 0.037, 0.159),\n    isDark: true,\n  }\n}\n\n/** Matches a bare shadcn HSL triplet, e.g. \"263 70% 50%\" (optional \"/ alpha\"). */\nconst HSL_TRIPLET = /^-?[\\d.]+\\s+-?[\\d.]+%\\s+-?[\\d.]+%(\\s*\\/\\s*[\\d.]+%?)?$/\n\n/**\n * A lazily-created 1x1 canvas 2D context used to normalise arbitrary CSS\n * colors. The browser's own color parser resolves ANY format the CSS spec\n * supports — including `oklch()` (Tailwind v4 / recent shadcn), `color()`,\n * `hsl()`, `rgb()`, hex and named colors — and serialises the result back as\n * `#rrggbb` / `rgba(...)`, which `THREE.Color.setStyle` understands.\n */\nlet probeCtx: CanvasRenderingContext2D | null | undefined\nfunction getProbeCtx(): CanvasRenderingContext2D | null {\n  if (probeCtx !== undefined) return probeCtx\n  if (typeof document === \"undefined\") return (probeCtx = null)\n  probeCtx = document.createElement(\"canvas\").getContext(\"2d\")\n  return probeCtx\n}\n\n/**\n * Normalise any CSS color string to a form THREE can parse (or `null` if the\n * browser rejects it). Assigning an invalid color to `fillStyle` is a no-op, so\n * we probe with two different sentinels: if the result differs, the input was\n * valid and both reflect its normalised value; if it \"sticks\" to a sentinel,\n * the input was invalid.\n */\nfunction normalizeColor(input: string): string | null {\n  const ctx = getProbeCtx()\n  if (!ctx) return null\n  ctx.fillStyle = \"#000\"\n  ctx.fillStyle = input\n  const a = ctx.fillStyle\n  ctx.fillStyle = \"#fff\"\n  ctx.fillStyle = input\n  const b = ctx.fillStyle\n  return a === b ? a : null\n}\n\n/**\n * Parse a raw CSS color (as read from a custom property) into a `THREE.Color`.\n *\n * shadcn ships colors as bare HSL triplets (\"263 70% 50%\"), which we wrap in\n * `hsl(...)`. Everything else — `oklch()`, `rgb()`, `#hex`, `hsl()`, named — is\n * handed to the browser's parser via a canvas probe, so modern oklch-based\n * themes resolve correctly instead of silently falling back to the default.\n */\nfunction parseColor(raw: string, target: Color): Color {\n  const value = raw.trim()\n  if (!value) return target\n\n  const candidate = HSL_TRIPLET.test(value) ? `hsl(${value})` : value\n\n  // Preferred path: let the browser resolve any CSS color format.\n  const normalized = normalizeColor(candidate)\n  if (normalized) {\n    try {\n      return target.setStyle(normalized)\n    } catch {\n      /* fall through to the manual paths below */\n    }\n  }\n\n  // Fallbacks for environments without a canvas (e.g. exotic SSR shims).\n  const triplet = value.match(/^(-?[\\d.]+)\\s+(-?[\\d.]+)%\\s+(-?[\\d.]+)%/)\n  if (triplet) {\n    const h = parseFloat(triplet[1]) / 360\n    const s = parseFloat(triplet[2]) / 100\n    const l = parseFloat(triplet[3]) / 100\n    return target.setHSL(h, s, l)\n  }\n  try {\n    return target.setStyle(value)\n  } catch {\n    return target\n  }\n}\n\nfunction readColors(previous?: ShadcnThemeColors): ShadcnThemeColors {\n  if (typeof window === \"undefined\" || typeof document === \"undefined\") {\n    return previous ?? createDefaults()\n  }\n\n  const root = document.documentElement\n  const styles = getComputedStyle(root)\n  const base = previous ?? createDefaults()\n\n  const next: ShadcnThemeColors = {\n    ...base,\n    primaryColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.primaryColor),\n      base.primaryColor.clone()\n    ),\n    primaryForegroundColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.primaryForegroundColor),\n      base.primaryForegroundColor.clone()\n    ),\n    bgColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.bgColor),\n      base.bgColor.clone()\n    ),\n    foregroundColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.foregroundColor),\n      base.foregroundColor.clone()\n    ),\n    borderColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.borderColor),\n      base.borderColor.clone()\n    ),\n    mutedColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.mutedColor),\n      base.mutedColor.clone()\n    ),\n    accentColor: parseColor(\n      styles.getPropertyValue(CSS_VARS.accentColor),\n      base.accentColor.clone()\n    ),\n    isDark: root.classList.contains(\"dark\"),\n  }\n\n  return next\n}\n\nexport type ThemeMode = \"auto\" | \"light\" | \"dark\"\n\n/**\n * Bridges your shadcn/ui CSS variables into Three.js.\n *\n * - Reads `--primary`, `--background`, `--border`, ... on mount.\n * - Watches `<html class>` with a MutationObserver and re-reads on dark-mode\n *   toggles (works with next-themes, which flips the `.dark` class).\n * - SSR-safe: returns neutral defaults until the DOM is ready.\n *\n * @param mode `\"auto\"` (default) tracks the document theme. `\"light\"` / `\"dark\"`\n *   force a palette by temporarily toggling a detached probe element.\n */\nexport function useShadcnTheme(mode: ThemeMode = \"auto\"): ShadcnThemeColors {\n  const [colors, setColors] = React.useState<ShadcnThemeColors>(() =>\n    createDefaults()\n  )\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\") return\n\n    // These reads pull the live values out of an external system (the DOM's\n    // computed CSS variables), which is a legitimate effect → state sync.\n    if (mode !== \"auto\") {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setColors((prev) => readForcedMode(mode, prev))\n      return\n    }\n\n    // Initial read once mounted (handles hydration + system theme).\n    setColors((prev) => readColors(prev))\n\n    const root = document.documentElement\n    const observer = new MutationObserver(() => {\n      setColors((prev) => readColors(prev))\n    })\n    observer.observe(root, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    })\n\n    return () => observer.disconnect()\n  }, [mode])\n\n  return colors\n}\n\n/**\n * Read the palette as it would appear under a forced light/dark mode.\n * We synchronously toggle the `.dark` class on `<html>`, read the computed\n * variables, then restore the original class in the same synchronous block.\n * getComputedStyle forces a style flush, so the values are accurate and the\n * browser never paints the intermediate state (no flicker).\n */\nfunction readForcedMode(\n  mode: \"light\" | \"dark\",\n  previous: ShadcnThemeColors\n): ShadcnThemeColors {\n  if (typeof document === \"undefined\") return previous\n\n  const root = document.documentElement\n  const wasDark = root.classList.contains(\"dark\")\n  const wantDark = mode === \"dark\"\n\n  if (wantDark !== wasDark) root.classList.toggle(\"dark\", wantDark)\n  const result = readColors(previous)\n  if (wantDark !== wasDark) root.classList.toggle(\"dark\", wasDark)\n\n  return result\n}\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:ui"
}