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