|
|
@@ -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);
|
|
|
}
|