74 lines
3.5 KiB
TypeScript
74 lines
3.5 KiB
TypeScript
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<HTMLDivElement>(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 <div ref={stageRef} className={className || 'model3d-stage'} />
|
||
|
|
}
|