lanxin 2 settimane fa
commit
403e286774
9 ha cambiato i file con 2781 aggiunte e 0 eliminazioni
  1. 24 0
      .gitignore
  2. 25 0
      index.html
  3. 2365 0
      package-lock.json
  4. 23 0
      package.json
  5. BIN
      public/138.fbx
  6. 32 0
      src/App.jsx
  7. 5 0
      src/main.jsx
  8. 301 0
      src/scene.jsx
  9. 6 0
      vite.config.js

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 25 - 0
index.html

@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="color-scheme" content="light" />
+    <title>GLB 加载演示</title>
+    <style>
+      html,
+      body,
+      #root {
+        height: 100%;
+      }
+      body {
+        margin: 0;
+        background: #f5f7fa;
+        overscroll-behavior: none;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.jsx"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 2365 - 0
package-lock.json


+ 23 - 0
package.json

@@ -0,0 +1,23 @@
+{
+  "name": "glb-loader-react",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "scripts": {
+    "dev": "vite --host",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@react-three/drei": "^9.122.0",
+    "@react-three/fiber": "^8.18.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "three": "^0.160.1"
+  },
+  "devDependencies": {
+    "@types/three": "^0.182.0",
+    "@vitejs/plugin-react": "^4.0.0",
+    "vite": "^5.0.0"
+  }
+}

BIN
public/138.fbx


+ 32 - 0
src/App.jsx

@@ -0,0 +1,32 @@
+import React, { Suspense, useEffect, useMemo, useRef ,useState} from 'react'
+import { Canvas, useThree } from '@react-three/fiber'
+import Scene from './scene'
+
+// 获取 URL 参数 src:完整模型链接 max:最大缩放比 min:最小缩放比
+function readParams() {
+  const params = new URLSearchParams(window.location.search)
+  const src = params.get('src') || '138.fbx'
+  const rawMin = Number(params.get('min'))
+  const rawMax = Number(params.get('max'))
+  const minZoom = Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 1
+  const maxZoom = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 4
+  let zoomMin = Math.min(minZoom, maxZoom)
+  const zoomMax = Math.max(minZoom, maxZoom)
+  zoomMin = Math.max(1, zoomMin)
+  return { src, zoomMin, zoomMax }
+}
+
+export default function App() {
+  const { src, zoomMin, zoomMax } = useMemo(readParams, [])
+  return (
+    <div style={{ width: '100vw', height: '100vh'}}>
+      <Canvas
+        dpr={[1, 2]}
+        gl={{ antialias: true }}
+        style={{ touchAction: 'none' }}
+      >
+        <Scene url={src} zoomMin={zoomMin} zoomMax={zoomMax} />
+      </Canvas>
+    </div>
+  )
+}

+ 5 - 0
src/main.jsx

@@ -0,0 +1,5 @@
+import React from 'react'
+import { createRoot } from 'react-dom/client'
+import App from './App.jsx'
+
+createRoot(document.getElementById('root')).render(<App />)

+ 301 - 0
src/scene.jsx

@@ -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

+ 6 - 0
vite.config.js

@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+  plugins: [react()]
+})