{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "ModelViewer-JS-CSS",
	"title": "ModelViewer",
	"description": "Three.js model viewer with orbit controls and lighting presets.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "ModelViewer/ModelViewer.jsx",
			"content": "/* eslint-disable react-hooks/rules-of-hooks */\n/* eslint-disable react/no-unknown-property */\nimport { Suspense, useRef, useLayoutEffect, useEffect, useMemo } from 'react';\nimport { Canvas, useFrame, useLoader, useThree, invalidate } from '@react-three/fiber';\nimport { OrbitControls, useGLTF, useFBX, useProgress, Html, Environment, ContactShadows } from '@react-three/drei';\nimport { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js';\nimport * as THREE from 'three';\n\nconst isTouch = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0);\nconst deg2rad = d => (d * Math.PI) / 180;\nconst DECIDE = 8;\nconst ROTATE_SPEED = 0.005;\nconst INERTIA = 0.925;\nconst PARALLAX_MAG = 0.05;\nconst PARALLAX_EASE = 0.12;\nconst HOVER_MAG = deg2rad(6);\nconst HOVER_EASE = 0.15;\n\nconst Loader = ({ placeholderSrc }) => {\n  const { progress, active } = useProgress();\n  if (!active && placeholderSrc) return null;\n  return (\n    <Html center>\n      {placeholderSrc ? (\n        <img src={placeholderSrc} width={128} height={128} style={{ filter: 'blur(8px)', borderRadius: 8 }} />\n      ) : (\n        `${Math.round(progress)} %`\n      )}\n    </Html>\n  );\n};\n\nconst DesktopControls = ({ pivot, min, max, zoomEnabled }) => {\n  const ref = useRef(null);\n  useFrame(() => ref.current?.target.copy(pivot));\n  return (\n    <OrbitControls\n      ref={ref}\n      makeDefault\n      enablePan={false}\n      enableRotate={false}\n      enableZoom={zoomEnabled}\n      minDistance={min}\n      maxDistance={max}\n    />\n  );\n};\n\nconst ModelInner = ({\n  url,\n  xOff,\n  yOff,\n  pivot,\n  initYaw,\n  initPitch,\n  minZoom,\n  maxZoom,\n  enableMouseParallax,\n  enableManualRotation,\n  enableHoverRotation,\n  enableManualZoom,\n  autoFrame,\n  fadeIn,\n  autoRotate,\n  autoRotateSpeed,\n  onLoaded\n}) => {\n  const outer = useRef(null);\n  const inner = useRef(null);\n  const { camera, gl } = useThree();\n\n  const vel = useRef({ x: 0, y: 0 });\n  const tPar = useRef({ x: 0, y: 0 });\n  const cPar = useRef({ x: 0, y: 0 });\n  const tHov = useRef({ x: 0, y: 0 });\n  const cHov = useRef({ x: 0, y: 0 });\n\n  const ext = useMemo(() => url.split('.').pop().toLowerCase(), [url]);\n  const content = useMemo(() => {\n    if (ext === 'glb' || ext === 'gltf') return useGLTF(url).scene.clone();\n    if (ext === 'fbx') return useFBX(url).clone();\n    if (ext === 'obj') return useLoader(OBJLoader, url).clone();\n    console.error('Unsupported format:', ext);\n    return null;\n  }, [url, ext]);\n\n  const pivotW = useRef(new THREE.Vector3());\n  useLayoutEffect(() => {\n    if (!content) return;\n    const g = inner.current;\n    g.updateWorldMatrix(true, true);\n\n    const sphere = new THREE.Box3().setFromObject(g).getBoundingSphere(new THREE.Sphere());\n    const s = 1 / (sphere.radius * 2);\n    g.position.set(-sphere.center.x, -sphere.center.y, -sphere.center.z);\n    g.scale.setScalar(s);\n\n    g.traverse(o => {\n      if (o.isMesh) {\n        o.castShadow = true;\n        o.receiveShadow = true;\n        if (fadeIn) {\n          o.material.transparent = true;\n          o.material.opacity = 0;\n        }\n      }\n    });\n\n    g.getWorldPosition(pivotW.current);\n    pivot.copy(pivotW.current);\n    outer.current.rotation.set(initPitch, initYaw, 0);\n\n    if (autoFrame && camera.isPerspectiveCamera) {\n      const persp = camera;\n      const fitR = sphere.radius * s;\n      const d = (fitR * 1.2) / Math.sin((persp.fov * Math.PI) / 180 / 2);\n      persp.position.set(pivotW.current.x, pivotW.current.y, pivotW.current.z + d);\n      persp.near = d / 10;\n      persp.far = d * 10;\n      persp.updateProjectionMatrix();\n    }\n\n    if (fadeIn) {\n      let t = 0;\n      const id = setInterval(() => {\n        t += 0.05;\n        const v = Math.min(t, 1);\n        g.traverse(o => {\n          if (o.isMesh) o.material.opacity = v;\n        });\n        invalidate();\n        if (v === 1) {\n          clearInterval(id);\n          onLoaded?.();\n        }\n      }, 16);\n      return () => clearInterval(id);\n    } else onLoaded?.();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [content]);\n\n  useEffect(() => {\n    if (!enableManualRotation || isTouch) return;\n    const el = gl.domElement;\n    let drag = false;\n    let lx = 0,\n      ly = 0;\n    const down = e => {\n      if (e.pointerType !== 'mouse' && e.pointerType !== 'pen') return;\n      drag = true;\n      lx = e.clientX;\n      ly = e.clientY;\n      window.addEventListener('pointerup', up);\n    };\n    const move = e => {\n      if (!drag) return;\n      const dx = e.clientX - lx;\n      const dy = e.clientY - ly;\n      lx = e.clientX;\n      ly = e.clientY;\n      outer.current.rotation.y += dx * ROTATE_SPEED;\n      outer.current.rotation.x += dy * ROTATE_SPEED;\n      vel.current = { x: dx * ROTATE_SPEED, y: dy * ROTATE_SPEED };\n      invalidate();\n    };\n    const up = () => (drag = false);\n    el.addEventListener('pointerdown', down);\n    el.addEventListener('pointermove', move);\n    return () => {\n      el.removeEventListener('pointerdown', down);\n      el.removeEventListener('pointermove', move);\n      window.removeEventListener('pointerup', up);\n    };\n  }, [gl, enableManualRotation]);\n\n  useEffect(() => {\n    if (!isTouch) return;\n    const el = gl.domElement;\n    const pts = new Map();\n\n    let mode = 'idle';\n    let sx = 0,\n      sy = 0,\n      lx = 0,\n      ly = 0,\n      startDist = 0,\n      startZ = 0;\n\n    const down = e => {\n      if (e.pointerType !== 'touch') return;\n      pts.set(e.pointerId, { x: e.clientX, y: e.clientY });\n      if (pts.size === 1) {\n        mode = 'decide';\n        sx = lx = e.clientX;\n        sy = ly = e.clientY;\n      } else if (pts.size === 2 && enableManualZoom) {\n        mode = 'pinch';\n        const [p1, p2] = [...pts.values()];\n        startDist = Math.hypot(p1.x - p2.x, p1.y - p2.y);\n        startZ = camera.position.z;\n        e.preventDefault();\n      }\n      invalidate();\n    };\n\n    const move = e => {\n      const p = pts.get(e.pointerId);\n      if (!p) return;\n      p.x = e.clientX;\n      p.y = e.clientY;\n\n      if (mode === 'decide') {\n        const dx = e.clientX - sx;\n        const dy = e.clientY - sy;\n        if (Math.abs(dx) > DECIDE || Math.abs(dy) > DECIDE) {\n          if (enableManualRotation && Math.abs(dx) > Math.abs(dy)) {\n            mode = 'rotate';\n            el.setPointerCapture(e.pointerId);\n          } else {\n            mode = 'idle';\n            pts.clear();\n          }\n        }\n      }\n\n      if (mode === 'rotate') {\n        e.preventDefault();\n        const dx = e.clientX - lx;\n        const dy = e.clientY - ly;\n        lx = e.clientX;\n        ly = e.clientY;\n        outer.current.rotation.y += dx * ROTATE_SPEED;\n        outer.current.rotation.x += dy * ROTATE_SPEED;\n        vel.current = { x: dx * ROTATE_SPEED, y: dy * ROTATE_SPEED };\n        invalidate();\n      } else if (mode === 'pinch' && pts.size === 2) {\n        e.preventDefault();\n        const [p1, p2] = [...pts.values()];\n        const d = Math.hypot(p1.x - p2.x, p1.y - p2.y);\n        const ratio = startDist / d;\n        camera.position.z = THREE.MathUtils.clamp(startZ * ratio, minZoom, maxZoom);\n        invalidate();\n      }\n    };\n\n    const up = e => {\n      pts.delete(e.pointerId);\n      if (mode === 'rotate' && pts.size === 0) mode = 'idle';\n      if (mode === 'pinch' && pts.size < 2) mode = 'idle';\n    };\n\n    el.addEventListener('pointerdown', down, { passive: true });\n    window.addEventListener('pointermove', move, { passive: false });\n    window.addEventListener('pointerup', up, { passive: true });\n    window.addEventListener('pointercancel', up, { passive: true });\n    return () => {\n      el.removeEventListener('pointerdown', down);\n      window.removeEventListener('pointermove', move);\n      window.removeEventListener('pointerup', up);\n      window.removeEventListener('pointercancel', up);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [gl, enableManualRotation, enableManualZoom, minZoom, maxZoom]);\n\n  useEffect(() => {\n    if (isTouch) return;\n    const mm = e => {\n      if (e.pointerType !== 'mouse') return;\n      const nx = (e.clientX / window.innerWidth) * 2 - 1;\n      const ny = (e.clientY / window.innerHeight) * 2 - 1;\n      if (enableMouseParallax) tPar.current = { x: -nx * PARALLAX_MAG, y: -ny * PARALLAX_MAG };\n      if (enableHoverRotation) tHov.current = { x: ny * HOVER_MAG, y: nx * HOVER_MAG };\n      invalidate();\n    };\n    window.addEventListener('pointermove', mm);\n    return () => window.removeEventListener('pointermove', mm);\n  }, [enableMouseParallax, enableHoverRotation]);\n\n  useFrame((_, dt) => {\n    let need = false;\n    cPar.current.x += (tPar.current.x - cPar.current.x) * PARALLAX_EASE;\n    cPar.current.y += (tPar.current.y - cPar.current.y) * PARALLAX_EASE;\n    const phx = cHov.current.x,\n      phy = cHov.current.y;\n    cHov.current.x += (tHov.current.x - cHov.current.x) * HOVER_EASE;\n    cHov.current.y += (tHov.current.y - cHov.current.y) * HOVER_EASE;\n\n    const ndc = pivotW.current.clone().project(camera);\n    ndc.x += xOff + cPar.current.x;\n    ndc.y += yOff + cPar.current.y;\n    outer.current.position.copy(ndc.unproject(camera));\n\n    outer.current.rotation.x += cHov.current.x - phx;\n    outer.current.rotation.y += cHov.current.y - phy;\n\n    if (autoRotate) {\n      outer.current.rotation.y += autoRotateSpeed * dt;\n      need = true;\n    }\n\n    outer.current.rotation.y += vel.current.x;\n    outer.current.rotation.x += vel.current.y;\n    vel.current.x *= INERTIA;\n    vel.current.y *= INERTIA;\n    if (Math.abs(vel.current.x) > 1e-4 || Math.abs(vel.current.y) > 1e-4) need = true;\n\n    if (\n      Math.abs(cPar.current.x - tPar.current.x) > 1e-4 ||\n      Math.abs(cPar.current.y - tPar.current.y) > 1e-4 ||\n      Math.abs(cHov.current.x - tHov.current.x) > 1e-4 ||\n      Math.abs(cHov.current.y - tHov.current.y) > 1e-4\n    )\n      need = true;\n\n    if (need) invalidate();\n  });\n\n  if (!content) return null;\n  return (\n    <group ref={outer}>\n      <group ref={inner}>\n        <primitive object={content} />\n      </group>\n    </group>\n  );\n};\n\nconst ModelViewer = ({\n  url,\n  width = 400,\n  height = 400,\n  modelXOffset = 0,\n  modelYOffset = 0,\n  defaultRotationX = -50,\n  defaultRotationY = 20,\n  defaultZoom = 0.5,\n  minZoomDistance = 0.5,\n  maxZoomDistance = 10,\n  enableMouseParallax = true,\n  enableManualRotation = true,\n  enableHoverRotation = true,\n  enableManualZoom = true,\n  ambientIntensity = 0.3,\n  keyLightIntensity = 1,\n  fillLightIntensity = 0.5,\n  rimLightIntensity = 0.8,\n  environmentPreset = 'forest',\n  autoFrame = false,\n  placeholderSrc,\n  showScreenshotButton = true,\n  fadeIn = false,\n  autoRotate = false,\n  autoRotateSpeed = 0.35,\n  onModelLoaded\n}) => {\n  useEffect(() => void useGLTF.preload(url), [url]);\n  const pivot = useRef(new THREE.Vector3()).current;\n  const contactRef = useRef(null);\n  const rendererRef = useRef(null);\n  const sceneRef = useRef(null);\n  const cameraRef = useRef(null);\n\n  const initYaw = deg2rad(defaultRotationX);\n  const initPitch = deg2rad(defaultRotationY);\n  const camZ = Math.min(Math.max(defaultZoom, minZoomDistance), maxZoomDistance);\n\n  const capture = () => {\n    const g = rendererRef.current,\n      s = sceneRef.current,\n      c = cameraRef.current;\n    if (!g || !s || !c) return;\n    g.shadowMap.enabled = false;\n    const tmp = [];\n    s.traverse(o => {\n      if (o.isLight && 'castShadow' in o) {\n        tmp.push({ l: o, cast: o.castShadow });\n        o.castShadow = false;\n      }\n    });\n    if (contactRef.current) contactRef.current.visible = false;\n    g.render(s, c);\n    const urlPNG = g.domElement.toDataURL('image/png');\n    const a = document.createElement('a');\n    a.download = 'model.png';\n    a.href = urlPNG;\n    a.click();\n    g.shadowMap.enabled = true;\n    tmp.forEach(({ l, cast }) => (l.castShadow = cast));\n    if (contactRef.current) contactRef.current.visible = true;\n    invalidate();\n  };\n\n  return (\n    <div\n      style={{\n        width,\n        height,\n        touchAction: 'pan-y pinch-zoom',\n        position: 'relative'\n      }}\n    >\n      {showScreenshotButton && (\n        <button\n          onClick={capture}\n          style={{\n            position: 'absolute',\n            border: '1px solid #fff',\n            right: 16,\n            top: 16,\n            zIndex: 10,\n            cursor: 'pointer',\n            padding: '8px 16px',\n            borderRadius: 10\n          }}\n        >\n          Take Screenshot\n        </button>\n      )}\n\n      <Canvas\n        shadows\n        frameloop=\"demand\"\n        gl={{ preserveDrawingBuffer: true }}\n        onCreated={({ gl, scene, camera }) => {\n          rendererRef.current = gl;\n          sceneRef.current = scene;\n          cameraRef.current = camera;\n          gl.toneMapping = THREE.ACESFilmicToneMapping;\n          gl.outputColorSpace = THREE.SRGBColorSpace;\n        }}\n        camera={{ fov: 50, position: [0, 0, camZ], near: 0.01, far: 100 }}\n        style={{ touchAction: 'pan-y pinch-zoom' }}\n      >\n        {environmentPreset !== 'none' && <Environment preset={environmentPreset} background={false} />}\n\n        <ambientLight intensity={ambientIntensity} />\n        <directionalLight position={[5, 5, 5]} intensity={keyLightIntensity} castShadow />\n        <directionalLight position={[-5, 2, 5]} intensity={fillLightIntensity} />\n        <directionalLight position={[0, 4, -5]} intensity={rimLightIntensity} />\n\n        <ContactShadows ref={contactRef} position={[0, -0.5, 0]} opacity={0.35} scale={10} blur={2} />\n\n        <Suspense fallback={<Loader placeholderSrc={placeholderSrc} />}>\n          <ModelInner\n            url={url}\n            xOff={modelXOffset}\n            yOff={modelYOffset}\n            pivot={pivot}\n            initYaw={initYaw}\n            initPitch={initPitch}\n            minZoom={minZoomDistance}\n            maxZoom={maxZoomDistance}\n            enableMouseParallax={enableMouseParallax}\n            enableManualRotation={enableManualRotation}\n            enableHoverRotation={enableHoverRotation}\n            enableManualZoom={enableManualZoom}\n            autoFrame={autoFrame}\n            fadeIn={fadeIn}\n            autoRotate={autoRotate}\n            autoRotateSpeed={autoRotateSpeed}\n            onLoaded={onModelLoaded}\n          />\n        </Suspense>\n\n        {!isTouch && (\n          <DesktopControls pivot={pivot} min={minZoomDistance} max={maxZoomDistance} zoomEnabled={enableManualZoom} />\n        )}\n      </Canvas>\n    </div>\n  );\n};\n\nexport default ModelViewer;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@react-three/fiber@^9.3.0",
		"@react-three/drei@^10.7.4",
		"three@^0.180.0"
	]
}