ソースを参照

feat: implement holographic 3D Orbit camera rotation for scans using Three.js

unknown 1 ヶ月 前
コミット
c2d0c009f3
1 ファイル変更409 行追加25 行削除
  1. 409 25
      src/components/ThreeSixtyViewer.vue

+ 409 - 25
src/components/ThreeSixtyViewer.vue

@@ -1,13 +1,20 @@
 <template>
   <div class="relative w-full aspect-square bg-[#0b0c10] border border-border/40 rounded-3xl overflow-hidden shadow-2xl flex items-center justify-center select-none group">
-    <!-- Active Image Frame -->
+    <!-- Active Image Frame (Fallback 2D Mode) -->
     <img 
-      v-if="isPreloaded && preloadedImages.length > 0"
+      v-if="isPreloaded && preloadedImages.length > 0 && !useThree"
       :src="preloadedImages[currentFrameIndex].src" 
       class="w-full h-full object-contain pointer-events-none" 
       alt="3D Scan 360 View"
     />
 
+    <!-- Three.js Canvas Container -->
+    <div 
+      v-if="isPreloaded && useThree"
+      ref="threeContainer"
+      class="w-full h-full absolute inset-0 z-10"
+    />
+
     <!-- Preloading overlay -->
     <div v-if="!isPreloaded" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0b0c10]/95 backdrop-blur-sm z-10 space-y-4">
       <RefreshCw class="w-8 h-8 text-primary animate-spin" />
@@ -20,22 +27,22 @@
       </div>
     </div>
 
-    <!-- Drag Rotation Hotspot Overlay -->
+    <!-- Drag Rotation Hotspot Overlay (Fallback 2D Mode Only) -->
     <div 
-      v-if="isPreloaded" 
+      v-if="isPreloaded && !useThree" 
       class="absolute inset-0 cursor-grab active:cursor-grabbing z-20 flex items-center justify-center"
       @mousedown="onMouseDown"
       @touchstart="onTouchStart"
+    />
+
+    <!-- Interaction Hint Overlay (Visible in both modes) -->
+    <div 
+      v-if="isPreloaded && showHint" 
+      class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 text-[10px] font-bold text-muted-foreground flex items-center gap-2 pointer-events-none transition-opacity duration-1000 z-30"
+      :class="{ 'opacity-0': isInteracted }"
     >
-      <!-- Interaction Hint Overlay (fades out after interactive movement) -->
-      <div 
-        v-if="showHint" 
-        class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-black/80 backdrop-blur-md px-4 py-2 rounded-xl border border-white/10 text-[10px] font-bold text-muted-foreground flex items-center gap-2 pointer-events-none transition-opacity duration-1000"
-        :class="{ 'opacity-0': isInteracted }"
-      >
-        <MoveHorizontal class="w-3.5 h-3.5 text-primary" />
-        <span>{{ projectMetadata?.view_mode === 'spherical' ? 'Drag in any direction to rotate in 3D' : 'Drag horizontally to rotate 3D scan' }}</span>
-      </div>
+      <MoveHorizontal class="w-3.5 h-3.5 text-primary" />
+      <span>{{ projectMetadata?.view_mode === 'spherical' ? 'Drag in any direction to rotate in 3D' : 'Drag horizontally to rotate 3D scan' }}</span>
     </div>
 
     <!-- Range Scrub Slider Control (Sleek overlay at the bottom) -->
@@ -62,6 +69,8 @@
 <script setup lang="ts">
 import { ref, onMounted, onBeforeUnmount, watch } from "vue";
 import { RefreshCw, MoveHorizontal } from "lucide-vue-next";
+import * as THREE from "three";
+import { OrbitControls } from "three/addons/controls/OrbitControls.js";
 
 const props = withDefaults(
   defineProps<{
@@ -83,7 +92,23 @@ const totalFramesCount = ref(props.framesCount);
 const showHint = ref(true);
 const isInteracted = ref(false);
 
-// Drag & Inertia state
+// Three.js refs & variables
+const useThree = ref(true);
+const threeContainer = ref<HTMLElement | null>(null);
+
+let scene: THREE.Scene | null = null;
+let camera: THREE.PerspectiveCamera | null = null;
+let renderer: THREE.WebGLRenderer | null = null;
+let controls: OrbitControls | null = null;
+let billboard: THREE.Mesh | null = null;
+let billboardTexture: THREE.Texture | null = null;
+let points: THREE.Points | null = null;
+let activePointMesh: THREE.Mesh | null = null;
+let sphereLines: THREE.LineSegments | null = null;
+let rotationRing: THREE.Line | null = null;
+let animationFrameId = 0;
+
+// Drag & Inertia state (Fallback 2D mode)
 let isDragging = false;
 let startX = 0;
 let startY = 0;
@@ -94,14 +119,351 @@ let velocityX = 0; // For spherical horizontal velocity
 let velocityY = 0; // For spherical vertical velocity
 let animationId: number | null = null;
 
-// 3D Spherical angles
+// 3D Spherical angles (Fallback 2D mode)
 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
+// Helper: sync point highlight position
+const updateActivePoint = (frameIndex: number, radius: number) => {
+  if (!activePointMesh || !projectMetadata.value) return;
+  const isSpherical = projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames[0]?.vector;
+
+  if (isSpherical) {
+    const fv = projectMetadata.value.frames[frameIndex]?.vector;
+    if (fv) {
+      activePointMesh.position.set(fv[0], fv[1], fv[2]).normalize().multiplyScalar(radius);
+    }
+  } else {
+    const angle = (frameIndex / totalFramesCount.value) * 2 * Math.PI;
+    const cosEl = Math.cos(0.38);
+    const sinEl = Math.sin(0.38);
+    activePointMesh.position.set(
+      radius * cosEl * Math.sin(angle),
+      radius * sinEl,
+      -radius * cosEl * Math.cos(angle)
+    );
+  }
+};
+
+// Helper: sync camera position when frame is selected via slider
+const syncCameraToFrame = (frameIndex: number) => {
+  if (!camera || !controls || !projectMetadata.value) return;
+  const isSpherical = projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames[0]?.vector;
+  const cameraDistance = 72;
+
+  if (isSpherical) {
+    const fv = projectMetadata.value.frames[frameIndex]?.vector;
+    if (fv) {
+      camera.position.set(fv[0], fv[1], fv[2]).normalize().multiplyScalar(cameraDistance);
+    }
+  } else {
+    const angle = (frameIndex / totalFramesCount.value) * 2 * Math.PI;
+    const cosEl = Math.cos(0.38);
+    const sinEl = Math.sin(0.38);
+    camera.position.set(
+      cameraDistance * cosEl * Math.sin(angle),
+      cameraDistance * sinEl,
+      -cameraDistance * cosEl * Math.cos(angle)
+    );
+  }
+  controls.update();
+};
+
+// Helper: match camera rotation back to closest frame texture
+const matchCameraToFrame = (radius: number) => {
+  if (!camera || !projectMetadata.value) return;
+
+  const camDir = new THREE.Vector3().copy(camera.position).normalize();
+  const isSpherical = projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames[0]?.vector;
+
+  let bestIndex = 0;
+  let maxDot = -Infinity;
+
+  if (isSpherical) {
+    for (let i = 0; i < projectMetadata.value.frames.length; i++) {
+      const fv = projectMetadata.value.frames[i].vector;
+      if (!fv) continue;
+      const dot = camDir.x * fv[0] + camDir.y * fv[1] + camDir.z * fv[2];
+      if (dot > maxDot) {
+        maxDot = dot;
+        bestIndex = i;
+      }
+    }
+  } else {
+    for (let i = 0; i < totalFramesCount.value; i++) {
+      const angle = (i / totalFramesCount.value) * 2 * Math.PI;
+      const cosEl = Math.cos(0.38);
+      const sinEl = Math.sin(0.38);
+      const fvx = cosEl * Math.sin(angle);
+      const fvy = sinEl;
+      const fvxZ = -cosEl * Math.cos(angle);
+      
+      const dot = camDir.x * fvx + camDir.y * fvy + camDir.z * fvxZ;
+      if (dot > maxDot) {
+        maxDot = dot;
+        bestIndex = i;
+      }
+    }
+  }
+
+  if (currentFrameIndex.value !== bestIndex) {
+    currentFrameIndex.value = bestIndex;
+    updateActivePoint(bestIndex, radius);
+    
+    // Update billboard texture
+    const activeImg = preloadedImages.value[bestIndex];
+    if (activeImg && billboardTexture) {
+      billboardTexture.image = activeImg;
+      billboardTexture.needsUpdate = true;
+    }
+  }
+};
+
+// Initialize Three.js scene
+const initThree = (containerEl: HTMLElement) => {
+  try {
+    // Dispose old elements if any
+    disposeThree();
+
+    const width = containerEl.clientWidth || 500;
+    const height = containerEl.clientHeight || 500;
+
+    scene = new THREE.Scene();
+
+    camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
+    camera.position.set(0, 25, 72);
+
+    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: "high-performance" });
+    renderer.setSize(width, height);
+    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+    renderer.setClearColor(0x000000, 0);
+    containerEl.appendChild(renderer.domElement);
+
+    controls = new OrbitControls(camera, renderer.domElement);
+    controls.enableDamping = true;
+    controls.dampingFactor = 0.05;
+    controls.enableZoom = false;
+    controls.enablePan = false;
+
+    // Fade overlay instruction on drag start
+    controls.addEventListener('start', () => {
+      isInteracted.value = true;
+      showHint.value = false;
+    });
+
+    const isSpherical = projectMetadata.value && projectMetadata.value.view_mode === 'spherical' && projectMetadata.value.frames && projectMetadata.value.frames[0]?.vector;
+    
+    if (!isSpherical) {
+      // Lock vertical rotation for 1D turntable scans
+      const targetPolarAngle = Math.PI / 2 - 0.38;
+      controls.minPolarAngle = targetPolarAngle;
+      controls.maxPolarAngle = targetPolarAngle;
+    } else {
+      // Allow rotation but clamp near vertical poles to prevent flips
+      controls.minPolarAngle = 0.1;
+      controls.maxPolarAngle = Math.PI - 0.1;
+    }
+
+    // Set up texture and material
+    billboardTexture = new THREE.Texture();
+    billboardTexture.colorSpace = THREE.SRGBColorSpace;
+
+    const activeImg = preloadedImages.value[currentFrameIndex.value];
+    if (activeImg) {
+      billboardTexture.image = activeImg;
+      billboardTexture.needsUpdate = true;
+    }
+
+    const planeGeom = new THREE.PlaneGeometry(50, 50);
+    const planeMat = new THREE.MeshBasicMaterial({
+      map: billboardTexture,
+      transparent: true,
+      side: THREE.DoubleSide,
+      depthWrite: false
+    });
+
+    billboard = new THREE.Mesh(planeGeom, planeMat);
+    scene.add(billboard);
+
+    // Helpers layout
+    const pointsGeometry = new THREE.BufferGeometry();
+    const positions: number[] = [];
+    const radius = 28; // Orbit radius helper
+
+    if (isSpherical) {
+      // Wireframe sphere representing 3D coordinates
+      const sphereGeom = new THREE.SphereGeometry(radius, 16, 12);
+      const wireframeGeom = new THREE.WireframeGeometry(sphereGeom);
+      const lineMat = new THREE.LineBasicMaterial({
+        color: 0x66fcf1,
+        transparent: true,
+        opacity: 0.08,
+        depthWrite: false
+      });
+      sphereLines = new THREE.LineSegments(wireframeGeom, lineMat);
+      scene.add(sphereLines);
+
+      // Camera vector dots
+      projectMetadata.value.frames.forEach((frame: any) => {
+        if (frame.vector) {
+          const v = new THREE.Vector3(frame.vector[0], frame.vector[1], frame.vector[2]).normalize().multiplyScalar(radius);
+          positions.push(v.x, v.y, v.z);
+        }
+      });
+    } else {
+      // Circular track for 1D turntable
+      const ringGeom = new THREE.BufferGeometry();
+      const ringPoints: THREE.Vector3[] = [];
+      const segments = 64;
+      for (let i = 0; i <= segments; i++) {
+        const theta = (i / segments) * 2 * Math.PI;
+        const cosEl = Math.cos(0.38);
+        const sinEl = Math.sin(0.38);
+        ringPoints.push(new THREE.Vector3(
+          radius * cosEl * Math.sin(theta),
+          radius * sinEl,
+          -radius * cosEl * Math.cos(theta)
+        ));
+      }
+      ringGeom.setFromPoints(ringPoints);
+      const ringMat = new THREE.LineBasicMaterial({
+        color: 0x66fcf1,
+        transparent: true,
+        opacity: 0.12,
+        depthWrite: false
+      });
+      rotationRing = new THREE.Line(ringGeom, ringMat);
+      scene.add(rotationRing);
+
+      // Distribute points evenly
+      for (let i = 0; i < totalFramesCount.value; i++) {
+        const angle = (i / totalFramesCount.value) * 2 * Math.PI;
+        const cosEl = Math.cos(0.38);
+        const sinEl = Math.sin(0.38);
+        positions.push(
+          radius * cosEl * Math.sin(angle),
+          radius * sinEl,
+          -radius * cosEl * Math.cos(angle)
+        );
+      }
+    }
+
+    if (positions.length > 0) {
+      pointsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+      const pointsMat = new THREE.PointsMaterial({
+        color: 0x45a29e,
+        size: 1.8,
+        transparent: true,
+        opacity: 0.4,
+        sizeAttenuation: true
+      });
+      points = new THREE.Points(pointsGeometry, pointsMat);
+      scene.add(points);
+    }
+
+    // Glowing active vector dot indicator
+    const activePtGeom = new THREE.SphereGeometry(0.7, 8, 8);
+    const activePtMat = new THREE.MeshBasicMaterial({
+      color: 0x66fcf1,
+      transparent: true,
+      opacity: 0.8
+    });
+    activePointMesh = new THREE.Mesh(activePtGeom, activePtMat);
+    scene.add(activePointMesh);
+
+    updateActivePoint(currentFrameIndex.value, radius);
+    syncCameraToFrame(currentFrameIndex.value);
+
+    // Anim loop
+    const animate = () => {
+      animationFrameId = requestAnimationFrame(animate);
+      if (controls) controls.update();
+      if (billboard && camera) billboard.quaternion.copy(camera.quaternion);
+      matchCameraToFrame(radius);
+
+      if (renderer && scene && camera) {
+        renderer.render(scene, camera);
+      }
+    };
+    animate();
+
+    // Resize Handler
+    window.addEventListener("resize", handleResize);
+    useThree.value = true;
+  } catch (err) {
+    console.error("Three.js initialization failed. Falling back to 2D view.", err);
+    useThree.value = false;
+    startPhysicsLoop();
+  }
+};
+
+const handleResize = () => {
+  if (!threeContainer.value || !renderer || !camera) return;
+  const w = threeContainer.value.clientWidth;
+  const h = threeContainer.value.clientHeight;
+  camera.aspect = w / h;
+  camera.updateProjectionMatrix();
+  renderer.setSize(w, h);
+};
+
+const disposeThree = () => {
+  if (animationFrameId) {
+    cancelAnimationFrame(animationFrameId);
+    animationFrameId = 0;
+  }
+  window.removeEventListener("resize", handleResize);
+
+  if (controls) {
+    controls.dispose();
+    controls = null;
+  }
+
+  if (renderer) {
+    if (renderer.domElement.parentNode) {
+      renderer.domElement.parentNode.removeChild(renderer.domElement);
+    }
+    renderer.dispose();
+    renderer = null;
+  }
+
+  if (scene) {
+    scene.traverse((object: any) => {
+      if (object.geometry) object.geometry.dispose();
+      if (object.material) {
+        if (Array.isArray(object.material)) {
+          object.material.forEach((mat: any) => mat.dispose());
+        } else {
+          object.material.dispose();
+        }
+      }
+    });
+    scene = null;
+  }
+
+  if (billboardTexture) {
+    billboardTexture.dispose();
+    billboardTexture = null;
+  }
+
+  billboard = null;
+  points = null;
+  activePointMesh = null;
+  sphereLines = null;
+  rotationRing = null;
+};
+
+const initThreeViewer = () => {
+  setTimeout(() => {
+    if (threeContainer.value) {
+      initThree(threeContainer.value);
+    }
+  }, 50);
+};
+
+// Match current angles to the closest frame vector (Fallback 2D mode)
 const matchSphericalFrame = () => {
   if (!projectMetadata.value || !projectMetadata.value.frames) return;
 
@@ -125,7 +487,7 @@ const matchSphericalFrame = () => {
   currentFrameIndex.value = bestIndex;
 };
 
-// Physics loop for smooth inertia
+// Physics loop for smooth inertia (Fallback 2D mode)
 const updatePhysics = () => {
   if (!isDragging) {
     if (projectMetadata.value && projectMetadata.value.view_mode === 'spherical') {
@@ -197,7 +559,11 @@ const startPreloading = async () => {
         if (loaded === totalFramesCount.value) {
           preloadedImages.value = list;
           isPreloaded.value = true;
-          startPhysicsLoop();
+          if (useThree.value) {
+            initThreeViewer();
+          } else {
+            startPhysicsLoop();
+          }
         }
       };
 
@@ -207,7 +573,11 @@ const startPreloading = async () => {
         if (loaded === totalFramesCount.value) {
           preloadedImages.value = list;
           isPreloaded.value = true;
-          startPhysicsLoop();
+          if (useThree.value) {
+            initThreeViewer();
+          } else {
+            startPhysicsLoop();
+          }
         }
       };
 
@@ -235,7 +605,11 @@ const startPreloading = async () => {
         if (loaded === totalFramesCount.value) {
           preloadedImages.value = list;
           isPreloaded.value = true;
-          startPhysicsLoop();
+          if (useThree.value) {
+            initThreeViewer();
+          } else {
+            startPhysicsLoop();
+          }
         }
       };
 
@@ -245,7 +619,11 @@ const startPreloading = async () => {
         if (loaded === totalFramesCount.value) {
           preloadedImages.value = list;
           isPreloaded.value = true;
-          startPhysicsLoop();
+          if (useThree.value) {
+            initThreeViewer();
+          } else {
+            startPhysicsLoop();
+          }
         }
       };
 
@@ -267,7 +645,7 @@ const startPhysicsLoop = () => {
   }
 };
 
-// Drag Interactions
+// Drag Interactions (Fallback 2D mode)
 const startDrag = (clientX: number, clientY: number) => {
   isDragging = true;
   isInteracted.value = true;
@@ -313,7 +691,7 @@ const moveDrag = (clientX: number, clientY: number) => {
   }
 };
 
-// Mouse Events
+// Mouse Events (Fallback 2D mode)
 const onMouseDown = (e: MouseEvent) => {
   if (e.button !== 0) return; // Only left click
   startDrag(e.clientX, e.clientY);
@@ -332,7 +710,7 @@ const onMouseUp = () => {
   window.removeEventListener("mouseup", onMouseUp);
 };
 
-// Touch Events
+// Touch Events (Fallback 2D mode)
 const onTouchStart = (e: TouchEvent) => {
   if (e.touches.length !== 1) return;
   startDrag(e.touches[0].clientX, e.touches[0].clientY);
@@ -362,7 +740,12 @@ const onSliderInput = (e: Event) => {
   velocityX = 0;
   velocityY = 0;
 
-  // Sync angles
+  // Sync Three.js camera position
+  if (useThree.value && camera && controls) {
+    syncCameraToFrame(frameVal);
+  }
+
+  // Sync angles (Fallback mode)
   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]);
@@ -376,6 +759,7 @@ watch(() => props.folderName, () => {
 }, { immediate: true });
 
 onBeforeUnmount(() => {
+  disposeThree();
   if (animationId) {
     cancelAnimationFrame(animationId);
   }