import { useEffect, useRef } from 'react' import * as THREE from 'three' // A self-contained three.js stage that slowly turns a single model and lets the viewer // swipe to spin it faster, the momentum easing back to the idle rate — the same feel as // the inventory rack, reused for one-off showpieces like a merit award. export function SpinningModel({ build, className, cameraPos = [0.6, 0.4, 5], baseSpin = 0.008 }: { build: () => THREE.Group className?: string cameraPos?: [number, number, number] baseSpin?: number }) { const stageRef = useRef(null) const [camX, camY, camZ] = cameraPos useEffect(() => { const stage = stageRef.current if (!stage) return const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) stage.appendChild(renderer.domElement) renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%' const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100) camera.position.set(camX, camY, camZ) camera.lookAt(0, 0, 0) scene.add(new THREE.AmbientLight(0x40483a, 0.9)) const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key) const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim) const holder = new THREE.Group(); holder.add(build()); scene.add(holder) const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } } resize(); const ro = new ResizeObserver(resize); ro.observe(stage) // Drag to spin (see inventory rack): a swipe rotates 1:1 and hands its speed to `spin`. let spin = baseSpin let dragging = false let lastX = 0 const dom = renderer.domElement dom.style.touchAction = 'none' dom.style.cursor = 'grab' const onDown = (event: PointerEvent) => { dragging = true; lastX = event.clientX; dom.style.cursor = 'grabbing'; dom.setPointerCapture(event.pointerId) } const onMove = (event: PointerEvent) => { if (!dragging) return const delta = (event.clientX - lastX) * 0.01 lastX = event.clientX holder.rotation.y += delta spin = THREE.MathUtils.clamp(delta, -0.35, 0.35) } const onUp = (event: PointerEvent) => { dragging = false; dom.style.cursor = 'grab'; try { dom.releasePointerCapture(event.pointerId) } catch { /* already released */ } } dom.addEventListener('pointerdown', onDown) dom.addEventListener('pointermove', onMove) dom.addEventListener('pointerup', onUp) dom.addEventListener('pointercancel', onUp) let raf = 0 const tick = () => { if (!dragging) { holder.rotation.y += spin; spin += (baseSpin - spin) * 0.04 } renderer.render(scene, camera); raf = requestAnimationFrame(tick) } tick() return () => { cancelAnimationFrame(raf); ro.disconnect() dom.removeEventListener('pointerdown', onDown); dom.removeEventListener('pointermove', onMove) dom.removeEventListener('pointerup', onUp); dom.removeEventListener('pointercancel', onUp) renderer.dispose(); dom.remove() scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } }) } }, [build, camX, camY, camZ, baseSpin]) return
}