lanxin пре 2 недеља
родитељ
комит
9034cdd792
6 измењених фајлова са 2926 додато и 292 уклоњено
  1. 41 0
      eslint.config.js
  2. 2799 55
      package-lock.json
  3. 8 1
      package.json
  4. 38 42
      src/App.jsx
  5. 1 1
      src/main.jsx
  6. 39 193
      src/scene.jsx

+ 41 - 0
eslint.config.js

@@ -0,0 +1,41 @@
+import js from '@eslint/js';
+import globals from 'globals';
+import react from 'eslint-plugin-react';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+
+export default [
+  { ignores: ['dist', 'usdzLoader'] },
+  {
+    files: ['**/*.{js,jsx}'],
+    languageOptions: {
+      ecmaVersion: 2020,
+      globals: globals.browser,
+      parserOptions: {
+        ecmaVersion: 'latest',
+        ecmaFeatures: { jsx: true },
+        sourceType: 'module',
+      },
+    },
+    settings: { react: { version: '18.3' } },
+    plugins: {
+      react,
+      'react-hooks': reactHooks,
+      'react-refresh': reactRefresh,
+    },
+    rules: {
+      ...js.configs.recommended.rules,
+      ...react.configs.recommended.rules,
+      ...react.configs['jsx-runtime'].rules,
+      ...reactHooks.configs.recommended.rules,
+      'react/jsx-no-target-blank': 'off',
+      'react-refresh/only-export-components': [
+        'warn',
+        { allowConstantExport: true },
+      ],
+      'no-unused-vars': 'warn',
+      'react/prop-types': 'off',
+      'react/no-unknown-property': 'off',
+    },
+  },
+];

Разлика између датотеке није приказан због своје велике величине
+ 2799 - 55
package-lock.json


+ 8 - 1
package.json

@@ -6,6 +6,7 @@
   "scripts": {
     "dev": "vite --host",
     "build": "vite build",
+    "lint": "eslint .",
     "preview": "vite preview"
   },
   "dependencies": {
@@ -16,8 +17,14 @@
     "three": "^0.160.1"
   },
   "devDependencies": {
+    "@eslint/js": "^9.39.2",
     "@types/three": "^0.182.0",
     "@vitejs/plugin-react": "^4.0.0",
+    "eslint": "^9.39.2",
+    "eslint-plugin-react": "^7.37.5",
+    "eslint-plugin-react-hooks": "^7.0.1",
+    "eslint-plugin-react-refresh": "^0.4.26",
+    "globals": "^17.1.0",
     "vite": "^5.0.0"
   }
-}
+}

+ 38 - 42
src/App.jsx

@@ -1,60 +1,56 @@
-import React, { Suspense, useEffect, useMemo, useRef ,useState} from 'react'
-import { Canvas, useThree } from '@react-three/fiber'
-import { Html } from '@react-three/drei'
-import Scene, { useFetchLoader, OverlayLoader } from './scene'
+import  {  useEffect, useMemo,  useState } from "react";
+import { Canvas } from "@react-three/fiber";
+import { Html } from "@react-three/drei";
+import Scene, { useFetchLoader, OverlayLoader } from "./scene";
 
 // 获取 URL 参数 src:完整模型链接 max:最大缩放比 min:最小缩放比
 function readParams() {
-  const params = new URLSearchParams(window.location.search)
-  const src = params.get('src') || ''
-  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)
-  const progressColor = params.get('progressColor')
-  return { src, zoomMin, zoomMax, progressColor }
+  const params = new URLSearchParams(window.location.search);
+  const src = params.get("src") || "";
+  const rawMin = Number(params.get("min"));
+  const rawMax = Number(params.get("max"));
+  const minZoom = Number.isFinite(rawMin) && rawMin > 0 ? rawMin : 0.01;
+  const maxZoom = Number.isFinite(rawMax) && rawMax > 0 ? rawMax : 4;
+  let zoomMin = Math.min(minZoom, maxZoom);
+  const zoomMax = Math.max(minZoom, maxZoom);
+  zoomMin = Math.min(1, zoomMin);
+  const progressColor = params.get("progressColor");
+  console.log("当前参数:src:", src);
+  console.log("当前参数:srzoomMinc:", zoomMin);
+  console.log("当前参数:zoomMax:", zoomMax);
+  console.log("当前参数:progressColor:", progressColor);
+  return { src, zoomMin, zoomMax, progressColor };
 }
 
 export default function App() {
-  const { src, zoomMin, zoomMax, progressColor } = useMemo(readParams, [])
+  const { src, zoomMin, zoomMax, progressColor } = useMemo(() => readParams(), []);
   const fetchState = useFetchLoader(src);
   const [isModelReady, setIsModelReady] = useState(false);
 
-  // 当 URL 变化时,重置 isModelReady
   useEffect(() => {
-    setIsModelReady(false);
-  }, [src]);
+    console.log("%c╔════════════════════════════════════════════", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%c示例链接:http://localhost:5173/?src=https://houseoss.4dkankan.com/project/DEMO/usdz/static/yw2-196-high.glb&progressColor=yellow&min=0.1&max=20", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%csrc:完整模型路径,支持glb/gltf,fbx格式", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%cprogressColor:进度条背景色      ", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%cmin:可缩小的最小倍数。默认0.01,选值区间[0.01,1]    ", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%cmax:可放大的最大倍数。默认4,选值区间[1,∞]    ", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+    console.log("%c╚════════════════════════════════════════════", "color: #ff6b6b; font-weight: bold; font-size: 12px;");
+  }, []);
 
   return (
-    <div style={{ width: '100vw', height: '100vh'}}>
-      <OverlayLoader 
-        fetchState={fetchState} 
-        isModelReady={isModelReady} 
-        color={progressColor} 
-        active={!!src}
-      />
-      <Canvas
-        dpr={[1, 2]}
-        gl={{ antialias: true }}
-        style={{ touchAction: 'none' }}
-      >
+    <div style={{ width: "100vw", height: "100vh" }}>
+      <OverlayLoader fetchState={fetchState} isModelReady={isModelReady} color={progressColor} active={!!src} />
+      <Canvas dpr={[1, 2]} gl={{ antialias: true }} style={{ touchAction: "none" }}>
         {fetchState.blobUrl ? (
-          <Scene 
-            blobUrl={fetchState.blobUrl} 
-            zoomMin={zoomMin} 
-            zoomMax={zoomMax} 
-            progressColor={progressColor}
-            onModelReady={() => setIsModelReady(true)}
-          />
+          <Scene blobUrl={fetchState.blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} progressColor={progressColor} onModelReady={() => setIsModelReady(true)} />
         ) : (
-           !src && <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
-            请在地址栏提供 ?src=GLB地址
-          </Html>
+          !src && (
+            <Html center style={{ width: "200px", color: "#222", fontSize: 14 }}>
+              请在地址栏提供 ?src=GLB地址
+            </Html>
+          )
         )}
       </Canvas>
     </div>
-  )
+  );
 }

+ 1 - 1
src/main.jsx

@@ -1,4 +1,4 @@
-import React from 'react'
+
 import { createRoot } from 'react-dom/client'
 import App from './App.jsx'
 

+ 39 - 193
src/scene.jsx

@@ -1,6 +1,6 @@
-import React, { Suspense, useEffect, useMemo, useRef, useState, useCallback } from "react";
-import { Canvas, useThree, useFrame } from "@react-three/fiber";
-import { OrbitControls, Html, useGLTF, Bounds, useFBX, useAnimations, useProgress } from "@react-three/drei";
+import  { Suspense, useEffect, useMemo, useRef, useState, useCallback } from "react";
+import {  useThree, useFrame } from "@react-three/fiber";
+import { OrbitControls,  useGLTF, Bounds, useFBX, useAnimations } from "@react-three/drei";
 import * as THREE from "three";
 
 /**
@@ -82,80 +82,14 @@ export function useFetchLoader(url) {
   return state;
 }
 
-function Loader({ customState, color }) {
-  // 只负责显示手动下载的进度
-  // 如果没有在下载,直接返回 null
-  if (!customState || !customState.loading) return null;
-
-  return <LoaderUI progress={customState.progress} loaded={customState.loaded} total={customState.total} status="downloading" color={color} />;
-}
-
-// 专门用于 Suspense fallback 的解析状态提示
-function ParsingLoader({ color }) {
-  return <LoaderUI progress={100} loaded={0} total={0} status="parsing" color={color} />;
-}
-
-function LoaderUI({ progress, loaded, total, status, color }) {
-  // 判断是否无法计算总大小
-  const isIndeterminate = status === "downloading" && total === 0 && loaded > 0;
-
-  return (
-    <Html fullscreen style={{ zIndex: 9999, pointerEvents: "none" }}>
-      <div
-        style={{
-          width: "100%",
-          height: "100%",
-          background: "#ffffff", // 统一使用白色背景,防止阶段切换时的闪烁
-          display: "flex",
-          alignItems: "center",
-          justifyContent: "center",
-        }}
-      >
-        <div
-          style={{
-            width: 600,
-            height: 10,
-            background: "#fff", // 未加载部分是白色
-            borderRadius: 5, // 稍微圆角一点更好看,或者如果想要完全直角可以设为0
-            overflow: "hidden",
-            position: "relative",
-            boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
-          }}
-        >
-          <div
-            style={{
-              width: isIndeterminate ? "100%" : `${progress}%`,
-              height: "100%",
-              background: color || "#4C7F7A", // 已加载进度颜色
-              transition: isIndeterminate ? "none" : "width 0.2s ease-out",
-              position: "absolute",
-              left: 0,
-              top: 0,
-              backgroundImage: isIndeterminate ? "linear-gradient(45deg,rgba(255,255,255,.3) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.3) 50%,rgba(255,255,255,.3) 75%,transparent 75%,transparent)" : "none",
-              backgroundSize: "20px 20px",
-              animation: isIndeterminate ? "progress-bar-stripes 1s linear infinite" : "none",
-            }}
-          />
-        </div>
-      </div>
-      <style>{`
-        @keyframes progress-bar-stripes {
-          from { background-position: 20px 0; }
-          to { background-position: 0 0; }
-        }
-      `}</style>
-    </Html>
-  );
-}
-
 export function OverlayLoader({ fetchState, isModelReady, color, active }) {
   // active: 是否应该处于激活状态(只要有 src 就应该激活)
-  
+
   // 计算当前状态
   const isDownloading = fetchState && fetchState.loading;
   // 只要有 blobUrl 但场景没好,就是在解析或自适应
   const isProcessing = fetchState && fetchState.blobUrl && !isModelReady;
-  
+
   // 只要 active 为 true,且模型未就绪,就应该显示
   // 如果 active 为 false (无 src),则隐藏
   // 如果模型已就绪 (isModelReady),则隐藏
@@ -288,7 +222,7 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
   const lastState = useRef({ pos: new THREE.Vector3(), target: new THREE.Vector3() });
   const stableFrames = useRef(0);
   const isFitted = useRef(false);
-  const startTime = useRef(Date.now()); // 记录组件挂载时间
+  const startTime = useRef(Infinity); // 记录组件挂载时间,初始设为 Infinity 确保在 Effect 执行前不触发逻辑
 
   // 当 URL 变化时重置检测状态,重新开始监听
   useEffect(() => {
@@ -326,39 +260,16 @@ function FitWatcher({ controlsRef, onFitReady, zoomMin }) {
     if (stableFrames.current > 20) {
       isFitted.current = true;
       const distance = currentPos.distanceTo(currentTarget);
-      
+
       // 核心修复:如果设置了 zoomMin (例如 2),意味着用户希望初始视图就放大2倍
       // Bounds 组件默认只会 fit 到正好填满屏幕 (zoom=1)
-      // 所以我们需要在这里手动应用初始缩放,防止后续 CameraController 里的 useEffect 突然跳变
-      
-      const initialZoom = Math.max(1, Number(zoomMin) || 1);
-      
-      if (initialZoom > 1) {
-          // 计算缩放后的目标位置:保持方向不变,距离缩短
-          const direction = new THREE.Vector3().subVectors(currentPos, currentTarget).normalize();
-          const newDistance = distance / initialZoom;
-          const newPos = currentTarget.clone().add(direction.multiplyScalar(newDistance));
-          
-          // 直接应用新位置,避免动画跳变
-          camera.position.copy(newPos);
-          // 更新 distance 为缩放后的距离,作为后续的基础距离
-          console.log(`Initial Fit adjusted by zoomMin=${initialZoom}: ${distance} -> ${newDistance}`);
-          
-          onFitReady({
-            position: newPos,
-            target: currentTarget.clone(),
-            distance: newDistance, // 这里的 distance 已经是被 zoomMin 除过的了
-            baseDistance: distance // 记录原始的 "1倍" 距离作为真正的 baseDistance
-          });
-      } else {
-          console.log("Fit stable, distance:", distance);
-          onFitReady({
-            position: currentPos.clone(),
-            target: currentTarget.clone(),
-            distance: distance,
-            baseDistance: distance
-          });
-      }
+
+      onFitReady({
+        position: currentPos.clone(),
+        target: currentTarget.clone(),
+        distance: distance,
+        baseDistance: distance,
+      });
     }
   });
 
@@ -414,48 +325,6 @@ function CameraController({ controlsRef, zoomMin, zoomMax, baseDistance, autoRot
  * 组装模型、灯光、交互,并管理全局状态
  */
 
-function LoadingOverlay({ visible, color }) {
-  // 修改:即使 visible 为 false,也不返回 null,而是通过 opacity 控制显示/隐藏
-  // 这样 CSS transition 才能生效,实现淡出效果
-  return (
-    <Html fullscreen style={{ pointerEvents: "none", zIndex: 9998 }}>
-      <div
-        style={{
-          width: "100%",
-          height: "100%",
-          background: "#ffffff",
-          display: "flex",
-          alignItems: "center",
-          justifyContent: "center",
-          opacity: visible ? 1 : 0, // 通过透明度控制显隐
-          transition: "opacity 0.8s ease-out", // 增加淡出时长,让过程更平滑
-          pointerEvents: visible ? "auto" : "none", // 隐藏后不阻挡点击
-        }}
-      >
-        <div
-          style={{
-            width: 600,
-            height: 10,
-            background: "#fff",
-            borderRadius: 5,
-            overflow: "hidden",
-            position: "relative",
-            boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
-          }}
-        >
-          <div
-            style={{
-              width: "100%",
-              height: "100%",
-              background: color || "#4C7F7A",
-            }}
-          />
-        </div>
-      </div>
-    </Html>
-  );
-}
-
 function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   const controlsRef = useRef();
   const { camera, gl } = useThree();
@@ -474,32 +343,31 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   // isReady: 标记是否完成初始飞入动画,完成后才解锁交互
   // 修改:默认 false,既控制交互也控制场景的可见性(避免跳变被用户看到)
   const [isReady, setIsReady] = useState(false);
-  
-  // 新增:Fit 状态标记,只有当 FitWatcher 完成调整后才设为 true
-  // 我们用它来控制 LoadingOverlay 的消失
-  // const [fitAdjusted, setFitAdjusted] = useState(false); // 已被 UnifiedLoader 接管
 
   // 记录初始 fit 后的完美状态(位置 + 目标点),用于双击复位
   const initialState = useRef(null);
   const timerRef = useRef(null);
 
   // 当 FitWatcher 告诉我们相机稳定时调用
-  const handleFitReady = useCallback((state) => {
-    initialState.current = {
-      position: state.position,
-      target: state.target,
-    };
-    // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
-    // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
-    // 以便正确计算 maxDistance (baseDistance / zoomMin)
-    setBaseDistance(state.baseDistance || state.distance);
-    setIsReady(true); // 解锁交互
-    
-    // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
-    if (onReady) {
-      setTimeout(() => onReady(), 50);
-    }
-  }, [onReady]);
+  const handleFitReady = useCallback(
+    (state) => {
+      initialState.current = {
+        position: state.position,
+        target: state.target,
+      };
+      // 如果 state 中有 baseDistance (原始1倍距离),则使用它,否则使用当前距离
+      // 这样做的目的是:CameraController 需要知道 "1倍缩放" 对应的距离是多少,
+      // 以便正确计算 maxDistance (baseDistance / zoomMin)
+      setBaseDistance(state.baseDistance || state.distance);
+      setIsReady(true); // 解锁交互
+
+      // 稍微延迟一点点移除遮罩,确保渲染已经更新到新位置
+      if (onReady) {
+        setTimeout(() => onReady(), 50);
+      }
+    },
+    [onReady],
+  );
 
   // 平滑复位函数:使用插值动画让相机回到初始状态
   // 支持 onComplete 回调,用于在复位完成后执行操作(如开始自动旋转)
@@ -733,6 +601,7 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
         }
       },
     };
+    console.log("已提出方法:", window.sceneFc);
     return () => {
       // 组件卸载时清理全局对象
       if (window.sceneFc) {
@@ -761,23 +630,12 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
 
   return (
     <>
-      {/* Bounds: 自动计算模型包围盒并调整相机视角 */}
-      {/* 不渲染任何 UI,仅负责逻辑检测 */}
-      {/* 
-         注意:Bounds 需要渲染其中的 children 才能计算。
-      */}
-      
       {/* 恢复 Bounds 的渲染,不被 visible 控制,改用 CSS 遮挡或 LoadingUI 延长显示 */}
       <Bounds fit clip margin={1.5}>
-          <ModelLoader url={url} fileType={fileType} />
+        <ModelLoader url={url} fileType={fileType} />
       </Bounds>
 
-      {/* 遮挡层:在 Fit 调整完成前一直显示白色背景,遮住 "先1倍再2倍" 的跳变过程 */}
-      {/* <LoadingOverlay visible={!fitAdjusted} color={progressColor} /> */}
-      
-      <CameraController
-        controlsRef={controlsRef}
-        zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
+      <CameraController controlsRef={controlsRef} zoomMin={zoomMin} zoomMax={zoomMax} baseDistance={baseDistance} autoRotateSpeed={effectiveSpeed} enabled={isReady} onStart={handleInteractionStart} onEnd={handleInteractionEnd} />
 
       {/* 不渲染任何 UI,仅负责逻辑检测 */}
       <FitWatcher controlsRef={controlsRef} onFitReady={handleFitReady} zoomMin={zoomMin} />
@@ -785,35 +643,23 @@ function SceneContent({ url, zoomMin, zoomMax, fileType, onReady }) {
   );
 }
 
-export default function Scene({ blobUrl, zoomMin, zoomMax, progressColor, onModelReady }) {
+export default function Scene({ blobUrl, zoomMin, zoomMax, onModelReady }) {
   // 推断文件类型,传递给 ModelLoader
   const fileType = useMemo(() => {
     if (!blobUrl) return null;
-    // 由于 blobUrl 没有后缀,这里需要外部传入 fileType 或者我们假设它是 gltf
-    // 之前是根据 url 推断,现在 url 可能是 blob:http://...
-    // 但 fetchState.blobUrl 是 blob,原来的 url 是真实的 url
-    // 我们需要原来的 url 来推断类型,或者让 App 传进来
-    // 简单起见,我们假设外部会处理好,或者我们这里只负责渲染
-    // 实际上 SceneContent 需要 fileType
     return "gltf"; // 默认
   }, [blobUrl]);
 
   // 为了保持兼容,我们还是需要真实的 URL 来推断类型,或者修改 App 传 fileType
   // 这里暂时简化,假设都是 gltf,如果需要支持 fbx,App 需要传 fileType
-  
+
   return (
     <>
       <ambientLight intensity={0.8} />
       <directionalLight position={[5, 5, 5]} intensity={1.0} />
       {blobUrl && (
         <Suspense fallback={null}>
-          <SceneContent 
-            url={blobUrl} 
-            zoomMin={zoomMin} 
-            zoomMax={zoomMax} 
-            fileType={fileType} 
-            onReady={onModelReady}
-          />
+          <SceneContent url={blobUrl} zoomMin={zoomMin} zoomMax={zoomMax} fileType={fileType} onReady={onModelReady} />
         </Suspense>
       )}
     </>