unknown преди 2 месеца
родител
ревизия
09842e2069
променени са 1 файла, в които са добавени 261 реда и са изтрити 69 реда
  1. 261 69
      src/components/ThreeSixtyViewer.vue

+ 261 - 69
src/components/ThreeSixtyViewer.vue

@@ -13,10 +13,10 @@
       <RefreshCw class="w-8 h-8 text-primary animate-spin" />
       <div class="text-center space-y-1">
         <p class="text-xs uppercase font-black tracking-widest text-primary/80">Loading interactive 3D model</p>
-        <p class="text-[10px] text-muted-foreground">{{ loadedFramesCount }} / {{ framesCount }} frames ({{ Math.round((loadedFramesCount / framesCount) * 100) }}%)</p>
+        <p class="text-[10px] text-muted-foreground">{{ loadedFramesCount }} / {{ totalFramesCount }} frames ({{ Math.round((loadedFramesCount / totalFramesCount) * 100) }}%)</p>
       </div>
       <div class="w-48 h-1 bg-white/5 rounded-full overflow-hidden border border-white/5">
-        <div class="h-full bg-primary transition-all duration-300 shadow-glow" :style="{ width: `${(loadedFramesCount / framesCount) * 100}%` }" />
+        <div class="h-full bg-primary transition-all duration-300 shadow-glow" :style="{ width: `${(loadedFramesCount / totalFramesCount) * 100}%` }" />
       </div>
     </div>
 
@@ -34,7 +34,7 @@
         :class="{ 'opacity-0': isInteracted }"
       >
         <MoveHorizontal class="w-3.5 h-3.5 text-primary" />
-        <span>Drag horizontally to rotate 3D scan</span>
+        <span>{{ projectMetadata?.view_mode === 'spherical' ? 'Drag in any direction to rotate in 3D' : 'Drag horizontally to rotate 3D scan' }}</span>
       </div>
     </div>
 
@@ -45,13 +45,14 @@
     >
       <div class="flex justify-between text-[9px] font-bold uppercase tracking-wider text-muted-foreground/80">
         <span>Manual Rotate</span>
-        <span>Frame {{ currentFrameIndex + 1 }} / {{ framesCount }}</span>
+        <span>Frame {{ currentFrameIndex + 1 }} / {{ totalFramesCount }}</span>
       </div>
       <input 
         type="range" 
-        v-model.number="currentFrameIndex"
+        :value="currentFrameIndex"
+        @input="onSliderInput"
         :min="0"
-        :max="framesCount - 1"
+        :max="totalFramesCount - 1"
         class="w-full accent-primary bg-white/10 cursor-pointer h-1 rounded-full outline-none"
       />
     </div>
@@ -59,7 +60,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, watch } from "vue";
+import { ref, onMounted, onBeforeUnmount, watch } from "vue";
 import { RefreshCw, MoveHorizontal } from "lucide-vue-next";
 
 const props = withDefaults(
@@ -77,79 +78,248 @@ const isPreloaded = ref(false);
 const loadedFramesCount = ref(0);
 const preloadedImages = ref<HTMLImageElement[]>([]);
 const currentFrameIndex = ref(0);
+const totalFramesCount = ref(props.framesCount);
 
 const showHint = ref(true);
 const isInteracted = ref(false);
 
-// Drag state
+// Drag & Inertia state
 let isDragging = false;
 let startX = 0;
-let baseIndex = 0;
+let startY = 0;
+let lastX = 0;
+let lastY = 0;
+let velocity = 0; // For 1D turntable velocity
+let velocityX = 0; // For spherical horizontal velocity
+let velocityY = 0; // For spherical vertical velocity
+let animationId: number | null = null;
 
-// Preload helper
-const startPreloading = () => {
+// 3D Spherical angles
+const projectMetadata = ref<any>(null);
+let azimuth = 0;
+let elevation = 0.38; // Default elevation (approx 21.8 degrees)
+const friction = 0.94; // Deceleration factor
+const dragSensitivity = 0.08;
+
+// Match current angles to the closest frame vector
+const matchSphericalFrame = () => {
+  if (!projectMetadata.value || !projectMetadata.value.frames) return;
+
+  const cosEl = Math.cos(elevation);
+  const x = cosEl * Math.sin(azimuth);
+  const y = -cosEl * Math.cos(azimuth);
+  const z = Math.sin(elevation);
+
+  let bestIndex = 0;
+  let maxDot = -Infinity;
+
+  for (let i = 0; i < projectMetadata.value.frames.length; i++) {
+    const fv = projectMetadata.value.frames[i].vector;
+    if (!fv) continue;
+    const dot = x * fv[0] + y * fv[1] + z * fv[2];
+    if (dot > maxDot) {
+      maxDot = dot;
+      bestIndex = i;
+    }
+  }
+  currentFrameIndex.value = bestIndex;
+};
+
+// Physics loop for smooth inertia
+const updatePhysics = () => {
+  if (!isDragging) {
+    if (projectMetadata.value && projectMetadata.value.view_mode === 'spherical') {
+      velocityX *= friction;
+      velocityY *= friction;
+
+      if (Math.abs(velocityX) > 0.0001 || Math.abs(velocityY) > 0.0001) {
+        azimuth += velocityX;
+        elevation += velocityY;
+        elevation = Math.max(-1.48, Math.min(1.48, elevation));
+        matchSphericalFrame();
+      }
+    } else {
+      velocity *= friction;
+      if (Math.abs(velocity) > 0.05) {
+        let nextFrame = currentFrameIndex.value - velocity;
+        currentFrameIndex.value = Math.floor(((nextFrame % totalFramesCount.value) + totalFramesCount.value) % totalFramesCount.value);
+      }
+    }
+  }
+  animationId = requestAnimationFrame(updatePhysics);
+};
+
+// Preload Helper
+const startPreloading = async () => {
   isPreloaded.value = false;
   loadedFramesCount.value = 0;
   preloadedImages.value = [];
   currentFrameIndex.value = 0;
+  projectMetadata.value = null;
+  totalFramesCount.value = props.framesCount;
 
-  let loaded = 0;
-  const list: HTMLImageElement[] = [];
-
-  for (let i = 0; i < props.framesCount; i++) {
-    const img = new Image();
-    const frameNum = String(i).padStart(3, '0');
-    img.src = `${props.resourcesBaseUrl}/uploads/scans/${props.folderName}/${frameNum}.webp`;
-    
-    img.onload = () => {
-      loaded++;
-      loadedFramesCount.value = loaded;
-      if (loaded === props.framesCount) {
-        preloadedImages.value = list;
-        isPreloaded.value = true;
-        // Fade hint out after 4 seconds if no interaction occurs
-        setTimeout(() => {
-          if (!isInteracted.value) {
-            isInteracted.value = true;
-          }
-        }, 4000);
-      }
-    };
-
-    img.onerror = () => {
-      // Handle failed frames gracefully to prevent locking
-      loaded++;
-      loadedFramesCount.value = loaded;
-      if (loaded === props.framesCount) {
-        preloadedImages.value = list;
-        isPreloaded.value = true;
-      }
-    };
+  let meta: any = null;
+  const metadataUrl = `${props.resourcesBaseUrl}/uploads/scans/${props.folderName}/metadata.json`;
 
-    list.push(img);
+  try {
+    const res = await fetch(metadataUrl);
+    if (res.ok) {
+      meta = await res.json();
+    }
+  } catch (e) {
+    console.log("No metadata.json found or failed to load. Falling back to default turntable.", e);
+  }
+
+  if (meta) {
+    projectMetadata.value = meta;
+    if (meta.frames && meta.frames.length) {
+      totalFramesCount.value = meta.frames.length;
+    }
+
+    // Sync angles from the first frame's vector
+    if (meta.frames[0] && meta.frames[0].vector) {
+      const vec = meta.frames[0].vector;
+      elevation = Math.asin(vec[2]);
+      azimuth = Math.atan2(vec[0], -vec[1]);
+    } else {
+      azimuth = 0;
+      elevation = 0.38;
+    }
+
+    let loaded = 0;
+    const list: HTMLImageElement[] = [];
+
+    meta.frames.forEach((frame: any, index: number) => {
+      const img = new Image();
+      img.src = `${props.resourcesBaseUrl}/uploads/scans/${props.folderName}/${frame.file}`;
+
+      img.onload = () => {
+        loaded++;
+        loadedFramesCount.value = loaded;
+        if (loaded === totalFramesCount.value) {
+          preloadedImages.value = list;
+          isPreloaded.value = true;
+          startPhysicsLoop();
+        }
+      };
+
+      img.onerror = () => {
+        loaded++;
+        loadedFramesCount.value = loaded;
+        if (loaded === totalFramesCount.value) {
+          preloadedImages.value = list;
+          isPreloaded.value = true;
+          startPhysicsLoop();
+        }
+      };
+
+      list.push(img);
+    });
+  } else {
+    // Fallback: 1D horizontal turntable
+    let loaded = 0;
+    const list: HTMLImageElement[] = [];
+
+    for (let i = 0; i < totalFramesCount.value; i++) {
+      const img = new Image();
+      const frameNum = String(i).padStart(3, '0');
+      img.src = `${props.resourcesBaseUrl}/uploads/scans/${props.folderName}/${frameNum}.webp`;
+
+      img.onload = () => {
+        loaded++;
+        loadedFramesCount.value = loaded;
+        if (loaded === totalFramesCount.value) {
+          preloadedImages.value = list;
+          isPreloaded.value = true;
+          startPhysicsLoop();
+        }
+      };
+
+      img.onerror = () => {
+        loaded++;
+        loadedFramesCount.value = loaded;
+        if (loaded === totalFramesCount.value) {
+          preloadedImages.value = list;
+          isPreloaded.value = true;
+          startPhysicsLoop();
+        }
+      };
+
+      list.push(img);
+    }
   }
 };
 
-// Mouse Events
-const onMouseDown = (e: MouseEvent) => {
+const startPhysicsLoop = () => {
+  // Fade hint out after 4 seconds if no interaction occurs
+  setTimeout(() => {
+    if (!isInteracted.value) {
+      isInteracted.value = true;
+    }
+  }, 4000);
+
+  if (!animationId) {
+    updatePhysics();
+  }
+};
+
+// Drag Interactions
+const startDrag = (clientX: number, clientY: number) => {
   isDragging = true;
   isInteracted.value = true;
-  startX = e.clientX;
-  baseIndex = currentFrameIndex.value;
-  
+  showHint.value = false;
+
+  startX = clientX;
+  startY = clientY;
+  lastX = clientX;
+  lastY = clientY;
+
+  velocity = 0;
+  velocityX = 0;
+  velocityY = 0;
+};
+
+const moveDrag = (clientX: number, clientY: number) => {
+  if (!isDragging) return;
+
+  if (projectMetadata.value && projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames.length > 0 && projectMetadata.value.frames[0].vector) {
+    const deltaX = clientX - lastX;
+    const deltaY = clientY - lastY;
+    lastX = clientX;
+    lastY = clientY;
+
+    // Calculate velocity for momentum on release
+    velocityX = -deltaX * 0.005;
+    velocityY = deltaY * 0.005;
+
+    azimuth += velocityX;
+    elevation += velocityY;
+    elevation = Math.max(-1.48, Math.min(1.48, elevation)); // Clamp elevation
+
+    matchSphericalFrame();
+  } else {
+    const deltaX = clientX - lastX;
+    lastX = clientX;
+
+    velocity = deltaX * dragSensitivity;
+
+    const frameShift = Math.round(deltaX * 0.2);
+    let targetIndex = currentFrameIndex.value - frameShift;
+    currentFrameIndex.value = ((targetIndex % totalFramesCount.value) + totalFramesCount.value) % totalFramesCount.value;
+  }
+};
+
+// Mouse Events
+const onMouseDown = (e: MouseEvent) => {
+  if (e.button !== 0) return; // Only left click
+  startDrag(e.clientX, e.clientY);
+
   window.addEventListener("mousemove", onMouseMove);
   window.addEventListener("mouseup", onMouseUp);
 };
 
 const onMouseMove = (e: MouseEvent) => {
-  if (!isDragging) return;
-  const deltaX = e.clientX - startX;
-  // Rotate 1 frame per 10 pixels of horizontal drag
-  const frameShift = Math.floor(deltaX / 10);
-  
-  let targetIndex = baseIndex + frameShift;
-  // Loop index
-  currentFrameIndex.value = ((targetIndex % props.framesCount) + props.framesCount) % props.framesCount;
+  moveDrag(e.clientX, e.clientY);
 };
 
 const onMouseUp = () => {
@@ -160,22 +330,16 @@ const onMouseUp = () => {
 
 // Touch Events
 const onTouchStart = (e: TouchEvent) => {
-  isDragging = true;
-  isInteracted.value = true;
-  startX = e.touches[0].clientX;
-  baseIndex = currentFrameIndex.value;
-  
-  window.addEventListener("touchmove", onTouchMove);
+  if (e.touches.length !== 1) return;
+  startDrag(e.touches[0].clientX, e.touches[0].clientY);
+
+  window.addEventListener("touchmove", onTouchMove, { passive: true });
   window.addEventListener("touchend", onTouchEnd);
 };
 
 const onTouchMove = (e: TouchEvent) => {
-  if (!isDragging) return;
-  const deltaX = e.touches[0].clientX - startX;
-  const frameShift = Math.floor(deltaX / 10);
-  
-  let targetIndex = baseIndex + frameShift;
-  currentFrameIndex.value = ((targetIndex % props.framesCount) + props.framesCount) % props.framesCount;
+  if (e.touches.length !== 1) return;
+  moveDrag(e.touches[0].clientX, e.touches[0].clientY);
 };
 
 const onTouchEnd = () => {
@@ -184,10 +348,38 @@ const onTouchEnd = () => {
   window.removeEventListener("touchend", onTouchEnd);
 };
 
+// Slider Manual Control
+const onSliderInput = (e: Event) => {
+  const target = e.target as HTMLInputElement;
+  const frameVal = parseInt(target.value);
+  currentFrameIndex.value = frameVal;
+
+  velocity = 0;
+  velocityX = 0;
+  velocityY = 0;
+
+  // Sync angles
+  if (projectMetadata.value && projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames[frameVal] && projectMetadata.value.frames[frameVal].vector) {
+    const vec = projectMetadata.value.frames[frameVal].vector;
+    elevation = Math.asin(vec[2]);
+    azimuth = Math.atan2(vec[0], -vec[1]);
+  }
+};
+
 // Watch for folder change to trigger reload
 watch(() => props.folderName, () => {
   startPreloading();
 }, { immediate: true });
+
+onBeforeUnmount(() => {
+  if (animationId) {
+    cancelAnimationFrame(animationId);
+  }
+  window.removeEventListener("mousemove", onMouseMove);
+  window.removeEventListener("mouseup", onMouseUp);
+  window.removeEventListener("touchmove", onTouchMove);
+  window.removeEventListener("touchend", onTouchEnd);
+});
 </script>
 
 <style scoped>