354 lines
10 KiB
Vue
354 lines
10 KiB
Vue
<template>
|
||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||
<h3 class="text-lg font-semibold mb-4 text-blue-400">V5: 3D Space-Time Cube</h3>
|
||
<div class="text-xs text-gray-500 mb-3 flex gap-2 items-center">
|
||
<span>X/Y = 畫面位置</span>
|
||
<span>Z = 深度(bbox 大小)</span>
|
||
<span>T = 時間</span>
|
||
</div>
|
||
|
||
<!-- Trace selector -->
|
||
<div class="flex gap-2 mb-3">
|
||
<select v-model="selectedTraceId"
|
||
class="bg-gray-700 text-white px-3 py-1.5 rounded text-sm flex-1">
|
||
<option :value="null" disabled>選擇 Trace</option>
|
||
<option v-for="t in traceOptions" :key="t.id"
|
||
:value="t.id">{{ t.label }}</option>
|
||
</select>
|
||
<button @click="loadData"
|
||
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded text-sm"
|
||
:disabled="!selectedTraceId || loading">
|
||
{{ loading ? '載入中...' : '載入' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div ref="container" class="w-full h-[400px] bg-gray-900 rounded-lg overflow-hidden"></div>
|
||
|
||
<div class="text-xs text-gray-500 mt-2 flex gap-4">
|
||
<span>🖱 拖曳旋轉</span>
|
||
<span>🔍 滾輪縮放</span>
|
||
<span v-if="faceCount">{{ faceCount }} 個檢測點</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||
import * as THREE from 'three'
|
||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||
|
||
const props = defineProps<{
|
||
fileUuid: string
|
||
traces?: any[]
|
||
frameWidth?: number
|
||
frameHeight?: number
|
||
}>()
|
||
|
||
const container = ref<HTMLElement>()
|
||
const selectedTraceId = ref<number | null>(null)
|
||
const loading = ref(false)
|
||
const faceCount = ref(0)
|
||
|
||
const traceOptions = computed(() => {
|
||
return (props.traces || []).map((t: any) => ({
|
||
id: t.trace_id,
|
||
label: `#${t.trace_id} (${t.face_count} faces, ${(t.duration_sec || 0).toFixed(1)}s)`
|
||
}))
|
||
})
|
||
|
||
let renderer: THREE.WebGLRenderer | null = null
|
||
let scene: THREE.Scene | null = null
|
||
let camera: THREE.PerspectiveCamera | null = null
|
||
let controls: OrbitControls | null = null
|
||
let animId: number
|
||
let objects: THREE.Object3D[] = []
|
||
|
||
function disposeScene() {
|
||
cancelAnimationFrame(animId)
|
||
for (const obj of objects) {
|
||
scene?.remove(obj)
|
||
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points || obj instanceof THREE.Line) {
|
||
obj.geometry?.dispose()
|
||
const mat = (obj as any).material
|
||
if (mat) {
|
||
if (Array.isArray(mat)) mat.forEach((m: any) => m.dispose())
|
||
else mat.dispose()
|
||
}
|
||
}
|
||
}
|
||
objects = []
|
||
controls?.dispose()
|
||
controls = null
|
||
if (renderer) {
|
||
renderer.dispose()
|
||
renderer = null
|
||
}
|
||
scene = null
|
||
camera = null
|
||
}
|
||
|
||
type FacePoint = {
|
||
frame: number
|
||
t: number
|
||
x: number
|
||
y: number
|
||
w: number
|
||
h: number
|
||
z: number
|
||
}
|
||
|
||
function loadData() {
|
||
if (!selectedTraceId.value) return
|
||
loading.value = true
|
||
|
||
const config = getCurrentConfig()
|
||
httpFetch(`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${selectedTraceId.value}/faces?interpolate=true&limit=2000&dimension=3d`)
|
||
.then((res: any) => {
|
||
const faces = res?.faces || []
|
||
const fw = props.frameWidth || 1920
|
||
const fh = props.frameHeight || 1080
|
||
|
||
const points: FacePoint[] = faces.map((f: any) => {
|
||
const w = f.width || 1
|
||
const h = f.height || 1
|
||
const areaPct = (w * h) / (fw * fh)
|
||
const z = f.z_rel !== undefined && f.z_rel !== null
|
||
? f.z_rel
|
||
: 1.0 - Math.min(areaPct * 50, 1.0)
|
||
return {
|
||
frame: f.start_frame || 0,
|
||
t: f.start_time || 0,
|
||
x: f.x || 0,
|
||
y: f.y || 0,
|
||
w,
|
||
h,
|
||
z
|
||
}
|
||
})
|
||
faceCount.value = points.length
|
||
buildScene(points)
|
||
})
|
||
.catch((err: any) => {
|
||
console.error('Failed to load trace faces:', err)
|
||
})
|
||
.finally(() => {
|
||
loading.value = false
|
||
})
|
||
}
|
||
|
||
function buildScene(points: FacePoint[]) {
|
||
if (!container.value) return
|
||
disposeScene()
|
||
|
||
// Normalize coordinates to [-1, 1] range
|
||
const fw = props.frameWidth || 1920
|
||
const fh = props.frameHeight || 1080
|
||
const maxT = points.length > 0 ? points[points.length - 1].t : 100
|
||
|
||
const vertexData = points.map(p => ({
|
||
x: (p.x / fw) * 2 - 1,
|
||
y: -((p.y / fh) * 2 - 1),
|
||
z: p.z * 2 - 1,
|
||
t: (p.t / maxT) * 2 - 1
|
||
}))
|
||
|
||
const rect = container.value.getBoundingClientRect()
|
||
const w = rect.width || 600, h = rect.height || 400
|
||
|
||
scene = new THREE.Scene()
|
||
scene.background = new THREE.Color(0x111827)
|
||
|
||
camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 10)
|
||
camera.position.set(2.5, 1.8, 3)
|
||
camera.lookAt(0, 0, 0)
|
||
|
||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||
renderer.setSize(w, h)
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||
container.value.appendChild(renderer.domElement)
|
||
|
||
controls = new OrbitControls(camera, renderer.domElement)
|
||
controls.enableDamping = true
|
||
controls.dampingFactor = 0.08
|
||
controls.target.set(0, 0, 0)
|
||
controls.update()
|
||
|
||
// ---- Axes helper with labels ----
|
||
const axesLen = 1.2
|
||
const axesMat = (color: number) => new THREE.LineBasicMaterial({ color })
|
||
|
||
// X axis (red) — screen x
|
||
const xLine = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints([
|
||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||
new THREE.Vector3(axesLen, -axesLen, -axesLen)
|
||
]),
|
||
axesMat(0xff4444)
|
||
)
|
||
scene.add(xLine)
|
||
objects.push(xLine)
|
||
|
||
// Y axis (green) — screen y
|
||
const yLine = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints([
|
||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||
new THREE.Vector3(-axesLen, axesLen, -axesLen)
|
||
]),
|
||
axesMat(0x44ff44)
|
||
)
|
||
scene.add(yLine)
|
||
objects.push(yLine)
|
||
|
||
// Z axis (blue) — depth
|
||
const zLine = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints([
|
||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||
new THREE.Vector3(-axesLen, -axesLen, axesLen)
|
||
]),
|
||
axesMat(0x4488ff)
|
||
)
|
||
scene.add(zLine)
|
||
objects.push(zLine)
|
||
|
||
// T axis (yellow) — time (at an angle for 3D effect)
|
||
const tLine = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints([
|
||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||
new THREE.Vector3(axesLen, axesLen, axesLen)
|
||
]),
|
||
axesMat(0xffdd44)
|
||
)
|
||
scene.add(tLine)
|
||
objects.push(tLine)
|
||
|
||
// ---- Cube wireframe ----
|
||
const cubeSize = axesLen * 2
|
||
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize)
|
||
const cubeWire = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(cubeGeo),
|
||
new THREE.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.3 })
|
||
)
|
||
cubeWire.position.set(0, 0, 0)
|
||
scene.add(cubeWire)
|
||
objects.push(cubeWire)
|
||
|
||
// ---- Points: color by time (t) ----
|
||
if (vertexData.length > 0) {
|
||
const positions = new Float32Array(vertexData.length * 3)
|
||
const colors = new Float32Array(vertexData.length * 3)
|
||
const color = new THREE.Color()
|
||
|
||
for (let i = 0; i < vertexData.length; i++) {
|
||
const p = vertexData[i]
|
||
// Position: (x, y, z) with time as movement along diagonal
|
||
positions[i * 3] = p.x
|
||
positions[i * 3 + 1] = p.y
|
||
positions[i * 3 + 2] = p.t * 0.5 // compress time a bit
|
||
|
||
// Color gradient: blue (early) → cyan → yellow → red (late)
|
||
const tNorm = (p.t + 1) / 2 // 0..1
|
||
color.setHSL(0.6 - tNorm * 0.6, 0.9, 0.5)
|
||
colors[i * 3] = color.r
|
||
colors[i * 3 + 1] = color.g
|
||
colors[i * 3 + 2] = color.b
|
||
}
|
||
|
||
const ptGeo = new THREE.BufferGeometry()
|
||
ptGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||
ptGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||
|
||
const ptMat = new THREE.PointsMaterial({
|
||
size: 0.03,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.8,
|
||
sizeAttenuation: true
|
||
})
|
||
const pointsObj = new THREE.Points(ptGeo, ptMat)
|
||
scene.add(pointsObj)
|
||
objects.push(pointsObj)
|
||
|
||
// ---- Trajectory line ----
|
||
const linePositions = new Float32Array(vertexData.length * 3)
|
||
for (let i = 0; i < vertexData.length; i++) {
|
||
const p = vertexData[i]
|
||
linePositions[i * 3] = p.x
|
||
linePositions[i * 3 + 1] = p.y
|
||
linePositions[i * 3 + 2] = p.t * 0.5
|
||
}
|
||
const lineGeo = new THREE.BufferGeometry()
|
||
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3))
|
||
const lineMat = new THREE.LineBasicMaterial({
|
||
color: 0x88ccff,
|
||
transparent: true,
|
||
opacity: 0.35
|
||
})
|
||
const line = new THREE.Line(lineGeo, lineMat)
|
||
scene.add(line)
|
||
objects.push(line)
|
||
}
|
||
|
||
// ---- Lights ----
|
||
const ambient = new THREE.AmbientLight(0x404060)
|
||
scene.add(ambient)
|
||
const dir = new THREE.DirectionalLight(0xffffff, 0.8)
|
||
dir.position.set(1, 2, 1)
|
||
scene.add(dir)
|
||
|
||
// ---- Grid helper (subtle) ----
|
||
const gridHelper = new THREE.GridHelper(2.5, 10, 0x444466, 0x333355)
|
||
gridHelper.position.y = -axesLen - 0.05
|
||
scene.add(gridHelper)
|
||
objects.push(gridHelper)
|
||
|
||
// Resize
|
||
const resizeObserver = new ResizeObserver(() => {
|
||
if (!container.value || !renderer || !camera) return
|
||
const r = container.value.getBoundingClientRect()
|
||
const rw = r.width || 600, rh = r.height || 400
|
||
renderer.setSize(rw, rh)
|
||
camera.aspect = rw / rh
|
||
camera.updateProjectionMatrix()
|
||
})
|
||
resizeObserver.observe(container.value)
|
||
;(container.value as any).__resizeObserver = resizeObserver
|
||
|
||
animate()
|
||
|
||
// Notify demo runner via callback URL if present
|
||
const cb = new URLSearchParams(window.location.search).get("_callback")
|
||
if (cb) {
|
||
fetch(cb, { mode: "no-cors" }).catch(() => {})
|
||
}
|
||
}
|
||
|
||
function animate() {
|
||
animId = requestAnimationFrame(animate)
|
||
controls?.update()
|
||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||
}
|
||
|
||
onMounted(() => {
|
||
tryAutoLoad()
|
||
})
|
||
|
||
watch(() => props.traces, () => {
|
||
tryAutoLoad()
|
||
}, { deep: false })
|
||
|
||
function tryAutoLoad() {
|
||
if (props.traces?.length && !selectedTraceId.value && !loading.value) {
|
||
selectedTraceId.value = props.traces[0].trace_id
|
||
loadData()
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
cancelAnimationFrame(animId)
|
||
if ((container.value as any)?.__resizeObserver) {
|
||
(container.value as any).__resizeObserver.disconnect()
|
||
}
|
||
disposeScene()
|
||
})
|
||
</script> |