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