|
|
@@ -0,0 +1,301 @@
|
|
|
+import React, { Suspense, useEffect, useMemo, useRef ,useState} from 'react'
|
|
|
+import { Canvas, useThree } from '@react-three/fiber'
|
|
|
+import { OrbitControls, Html, useGLTF, Bounds, useFBX, useAnimations } from '@react-three/drei'
|
|
|
+import * as THREE from 'three'
|
|
|
+
|
|
|
+// 轨道控制器,限制缩放比和距离
|
|
|
+function ControlsWithLimits({ controlsRef, zoomMin, zoomMax, baseDistance }) {
|
|
|
+ const { camera } = useThree()
|
|
|
+ // autoRotateSpeed!==0即自动旋转
|
|
|
+ const [autoRotateSpeed,setAutoRotateSpeed] = useState(0)
|
|
|
+ useEffect(() => {
|
|
|
+ const controls = controlsRef.current
|
|
|
+ if (!controls || baseDistance == null) return
|
|
|
+ controls.maxDistance = baseDistance * Math.max(1, zoomMin)
|
|
|
+ }, [camera, controlsRef, baseDistance, zoomMin, zoomMax])
|
|
|
+ useEffect(()=>{
|
|
|
+ window.sceneFc = {
|
|
|
+ resetView: () => {
|
|
|
+ if (controlsRef.current) {
|
|
|
+ controlsRef.current.reset()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ setAutoRotate: (speed = 1.0) => {
|
|
|
+ const clampedSpeed = Math.max(0.1, Math.min(8, Number(speed) || 1))
|
|
|
+ setAutoRotateSpeed(clampedSpeed)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },[])
|
|
|
+ // 添加双击归位
|
|
|
+ useEffect(() => {
|
|
|
+ const onDoubleClick = () => {
|
|
|
+ if (controlsRef.current) {
|
|
|
+ console.log(123)
|
|
|
+ controlsRef.current.reset()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ window.addEventListener('dblclick', onDoubleClick)
|
|
|
+ return () => window.removeEventListener('dblclick', onDoubleClick)
|
|
|
+ }, [controlsRef])
|
|
|
+
|
|
|
+ return (
|
|
|
+ <OrbitControls
|
|
|
+ ref={controlsRef}
|
|
|
+ enableDamping
|
|
|
+ dampingFactor={0.08}
|
|
|
+ rotateSpeed={0.8}
|
|
|
+ zoomSpeed={1.0}
|
|
|
+ enableZoom
|
|
|
+ enablePan
|
|
|
+ panSpeed={0.8}
|
|
|
+ screenSpacePanning
|
|
|
+ touches={{
|
|
|
+ ONE: THREE.TOUCH.ROTATE,
|
|
|
+ TWO: THREE.TOUCH.DOLLY_PAN
|
|
|
+ }}
|
|
|
+ makeDefault
|
|
|
+ autoRotate={!!autoRotateSpeed}
|
|
|
+ autoRotateSpeed={autoRotateSpeed}
|
|
|
+ />
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// glb/gltf文件加载
|
|
|
+function Model({ url }) {
|
|
|
+ const { scene, animations } = useGLTF(url, true)
|
|
|
+ const ref = useRef()
|
|
|
+ const { actions, names } = useAnimations(animations, ref)
|
|
|
+ useEffect(() => {
|
|
|
+ if (!actions || !names || names.length === 0) return
|
|
|
+ const a = actions[names[0]]
|
|
|
+ if (a) {
|
|
|
+ a.reset()
|
|
|
+ a.play()
|
|
|
+ }
|
|
|
+ }, [actions, names])
|
|
|
+ return <primitive ref={ref} object={scene} />
|
|
|
+}
|
|
|
+
|
|
|
+// fbx文件加载
|
|
|
+function FBXModel({ url }) {
|
|
|
+ const obj = useFBX(url)
|
|
|
+ const ref = useRef()
|
|
|
+ const { actions, names } = useAnimations(obj.animations || [], ref)
|
|
|
+ useEffect(() => {
|
|
|
+ if (!actions || !names || names.length === 0) return
|
|
|
+ const a = actions[names[0]]
|
|
|
+ if (a) {
|
|
|
+ a.reset()
|
|
|
+ a.play()
|
|
|
+ }
|
|
|
+ }, [actions, names])
|
|
|
+ return <primitive ref={ref} object={obj} />
|
|
|
+}
|
|
|
+
|
|
|
+// 方便模型类型
|
|
|
+function pickModel(url) {
|
|
|
+ const u = (url || '').toLowerCase()
|
|
|
+ const clean = u.split('?')[0].split('#')[0]
|
|
|
+ if (clean.endsWith('.fbx')) return 'fbx'
|
|
|
+ return 'gltf'
|
|
|
+}
|
|
|
+
|
|
|
+// 场景
|
|
|
+function Scene({ url, zoomMin, zoomMax }) {
|
|
|
+ const controlsRef = useRef()
|
|
|
+ const { camera ,gl} = useThree()
|
|
|
+ const [baseDistance, setBaseDistance] = useState(null)
|
|
|
+ const [initialState, setInitialState] = useState(null) // 保存 fit 后的初始相机 + target
|
|
|
+
|
|
|
+ const kind = pickModel(url)
|
|
|
+
|
|
|
+ // 平滑复位函数
|
|
|
+ const resetToInitial = () => {
|
|
|
+ if (!controlsRef.current || !initialState) {
|
|
|
+ console.warn('无法复位:controls 或 initialState 未就绪')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const controls = controlsRef.current
|
|
|
+
|
|
|
+ const startPos = camera.position.clone()
|
|
|
+ const startTarget = controls.target.clone()
|
|
|
+ const endPos = initialState.position
|
|
|
+ const endTarget = initialState.target
|
|
|
+
|
|
|
+ let t = 0
|
|
|
+ const duration = 800 // ms,可调
|
|
|
+ const startTime = performance.now()
|
|
|
+
|
|
|
+ const animate = (time) => {
|
|
|
+ const elapsed = time - startTime
|
|
|
+ t = Math.min(elapsed / duration, 1)
|
|
|
+
|
|
|
+ camera.position.lerpVectors(startPos, endPos, t)
|
|
|
+ controls.target.lerpVectors(startTarget, endTarget, t)
|
|
|
+ controls.update()
|
|
|
+
|
|
|
+ if (t < 1) {
|
|
|
+ requestAnimationFrame(animate)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ requestAnimationFrame(animate)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 监听模型适应大小后,回调确认位置
|
|
|
+function FitWatcher({ controlsRef, onBase }) {
|
|
|
+ const { camera } = useThree()
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!controlsRef.current) return
|
|
|
+
|
|
|
+ let rafId
|
|
|
+ let attempts = 0
|
|
|
+ const maxAttempts = 30 // 防死循环,大约 0.5 秒
|
|
|
+
|
|
|
+ const tryCapture = () => {
|
|
|
+ const controls = controlsRef.current
|
|
|
+ const d = camera.position.distanceTo(controls.target)
|
|
|
+
|
|
|
+ // 如果连续几帧距离几乎不变,就认为 fit 完成了
|
|
|
+ if (Math.abs(d - (window.lastD || d)) < 0.001 && attempts > 5) {
|
|
|
+ onBase(d)
|
|
|
+ setTimeout(() => {
|
|
|
+ if (!initialState && controlsRef.current) {
|
|
|
+ console.log(controlsRef.current.target.clone())
|
|
|
+ setInitialState({
|
|
|
+ position: camera.position.clone(),
|
|
|
+ target: controlsRef.current.target.clone(),
|
|
|
+ })
|
|
|
+ console.log('稳定后保存初始状态:', camera.position)
|
|
|
+ }
|
|
|
+ }, 300)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ window.lastD = d
|
|
|
+ attempts++
|
|
|
+ if (attempts < maxAttempts) {
|
|
|
+ rafId = requestAnimationFrame(tryCapture)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ rafId = requestAnimationFrame(tryCapture)
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ cancelAnimationFrame(rafId)
|
|
|
+ delete window.lastD
|
|
|
+ }
|
|
|
+ }, [onBase, camera, controlsRef])
|
|
|
+
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+ // 在 fit 完成后保存初始状态
|
|
|
+ useEffect(() => {
|
|
|
+ setTimeout(() => {
|
|
|
+ setInitialState({
|
|
|
+ position: camera.position.clone(),
|
|
|
+ target: controlsRef.current.target.clone(),
|
|
|
+ })
|
|
|
+ }, 200);
|
|
|
+ if (baseDistance && !initialState && controlsRef.current) {
|
|
|
+ console.log(camera.position.clone(),controlsRef.current.target.clone())
|
|
|
+ setInitialState({
|
|
|
+ position: camera.position.clone(),
|
|
|
+ target: controlsRef.current.target.clone(),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }, [baseDistance, camera, initialState])
|
|
|
+
|
|
|
+ // 暴露到 window(只暴露一次,组件卸载时清理)
|
|
|
+ useEffect(() => {
|
|
|
+ // 创建或覆盖 window.sceneFc
|
|
|
+ window.sceneFc = window.sceneFc || {}
|
|
|
+ window.sceneFc.reset = resetToInitial
|
|
|
+
|
|
|
+ console.log('window.sceneFc.reset 已暴露,可外部调用')
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ // 清理(可选,但推荐)
|
|
|
+ if (window.sceneFc && window.sceneFc.reset === resetToInitial) {
|
|
|
+ delete window.sceneFc.reset
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [resetToInitial]) // 依赖 resetToInitial,确保函数稳定
|
|
|
+
|
|
|
+ // 双击复位监听(使用 click 事件判断双击,更可靠)
|
|
|
+ useEffect(() => {
|
|
|
+ let lastClickTime = 0
|
|
|
+ let clickCount = 0
|
|
|
+ let timeoutId
|
|
|
+
|
|
|
+ const handleClick = (e) => {
|
|
|
+ const now = Date.now()
|
|
|
+
|
|
|
+ // 如果点击在 OrbitControls 控制区域内(避免误触 UI)
|
|
|
+ if (e.target !== gl.domElement) return
|
|
|
+
|
|
|
+ clickCount++
|
|
|
+
|
|
|
+ if (clickCount === 1) {
|
|
|
+ timeoutId = setTimeout(() => {
|
|
|
+ clickCount = 0
|
|
|
+ }, 300)
|
|
|
+ }
|
|
|
+
|
|
|
+ if (clickCount === 2) {
|
|
|
+ clearTimeout(timeoutId)
|
|
|
+ clickCount = 0
|
|
|
+
|
|
|
+ // 阻止默认行为(如果有)
|
|
|
+ e.preventDefault()
|
|
|
+ e.stopPropagation()
|
|
|
+
|
|
|
+ resetToInitial()
|
|
|
+ }
|
|
|
+
|
|
|
+ lastClickTime = now
|
|
|
+ }
|
|
|
+
|
|
|
+ const dom = gl.domElement
|
|
|
+ dom.addEventListener('click', handleClick)
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ dom.removeEventListener('click', handleClick)
|
|
|
+ }
|
|
|
+ }, [gl, resetToInitial])
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <ambientLight intensity={0.8} />
|
|
|
+ <directionalLight position={[5, 5, 5]} intensity={1.0} />
|
|
|
+ {url ? (
|
|
|
+ <Suspense
|
|
|
+ fallback={
|
|
|
+ <Html center style={{ color: '#222', fontSize: 14 }}>
|
|
|
+ 模型加载中…
|
|
|
+ </Html>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Bounds fit clip observe margin={1.2}>
|
|
|
+ {kind === 'fbx' ? <FBXModel url={url} /> : kind === 'usdz' ? <USDZModel url={url} /> : <Model url={url} />}
|
|
|
+ <ControlsWithLimits controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} />
|
|
|
+ <FitWatcher controlsRef={controlsRef} onBase={setBaseDistance} />
|
|
|
+ </Bounds>
|
|
|
+ </Suspense>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <ControlsWithLimits controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} />
|
|
|
+ <FitWatcher controlsRef={controlsRef} onBase={setBaseDistance} />
|
|
|
+ <Html center style={{ color: '#222', fontSize: 14 }}>
|
|
|
+ 请在地址栏提供 ?src=GLB地址
|
|
|
+ </Html>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export default Scene
|