Files
3Dviewer/frontend/src/components/viewer/ModelViewer.vue
likegears 7af9c323f6 Initial commit: 3D Viewer application
Features:
- Vue 3 frontend with Three.js/Online3DViewer
- Node.js API with PostgreSQL and Redis
- Python worker for model conversion
- Docker Compose for deployment
- ViewCube navigation with drag rotation and 90° snap
- Cross-section, exploded view, and render settings
- Parts tree with visibility controls

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:00:17 +08:00

861 lines
27 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as OV from 'online-3d-viewer'
import * as THREE from 'three'
import { useViewerStore } from '@/stores/viewer'
import { useModelsStore } from '@/stores/models'
import { useThemeStore } from '@/stores/theme'
import { getModel, getModelLodUrls, fetchWithProgress } from '@/api/client'
import { getPartsTreeService } from '@/services/partsTreeService'
import { getClippingService, type Axis } from '@/services/clippingService'
import { getRenderService } from '@/services/renderService'
import { captureViewerScreenshot, uploadThumbnail } from '@/services/screenshotService'
import ContextMenu from './ContextMenu.vue'
import ViewCube from './ViewCube.vue'
const props = defineProps<{
modelId: string
}>()
const containerRef = ref<HTMLDivElement | null>(null)
const viewerStore = useViewerStore()
const modelsStore = useModelsStore()
const themeStore = useThemeStore()
let viewer: OV.EmbeddedViewer | null = null
// Theme-aware background colors
const LIGHT_BG = { r: 248, g: 249, b: 252 } // #f8f9fc
const DARK_BG = { r: 22, g: 24, b: 31 } // #16181f
let loadAbortController: AbortController | null = null
let loadCheckInterval: ReturnType<typeof setInterval> | null = null
let loadTimeoutId: ReturnType<typeof setTimeout> | null = null
// Maximum load timeout: 5 minutes for large files
const MAX_LOAD_TIMEOUT = 5 * 60 * 1000
onMounted(() => {
if (!containerRef.value) return
// Patch canvas.getContext to enable stencil buffer for WebGL
// This is needed for section cap rendering using stencil buffer technique
const originalGetContext = HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = function(
this: HTMLCanvasElement,
contextType: string,
contextAttributes?: WebGLContextAttributes
) {
if (contextType === 'webgl' || contextType === 'webgl2') {
const attrs = { ...contextAttributes, stencil: true }
return originalGetContext.call(this, contextType, attrs)
}
return originalGetContext.call(this, contextType, contextAttributes)
} as typeof HTMLCanvasElement.prototype.getContext
// Initialize Online3DViewer with theme-aware background
const bg = themeStore.isDark ? DARK_BG : LIGHT_BG
viewer = new OV.EmbeddedViewer(containerRef.value, {
backgroundColor: new OV.RGBAColor(bg.r, bg.g, bg.b, 255),
defaultColor: new OV.RGBColor(180, 180, 180),
edgeSettings: new OV.EdgeSettings(false, new OV.RGBColor(0, 0, 0), 1),
})
// Restore original getContext after viewer is created
HTMLCanvasElement.prototype.getContext = originalGetContext
viewerStore.setViewer(viewer)
// Enable free orbit mode for unlimited rotation in all directions
const threeViewer = viewer.GetViewer()
if (threeViewer) {
const navigation = (threeViewer as unknown as { navigation?: { SetNavigationMode: (mode: number) => void } }).navigation
if (navigation) {
navigation.SetNavigationMode(OV.NavigationMode.FreeOrbit)
}
}
// Setup WebGL context loss handling after viewer is created
nextTick(() => {
setupWebGLErrorHandling()
setupPlaneDragEvents()
})
// Load initial model
loadModel(props.modelId)
})
onUnmounted(() => {
cancelLoading()
cleanupPlaneDragEvents()
if (viewer) {
viewer.Destroy()
viewer = null
}
viewerStore.setViewer(null)
viewerStore.resetFeatures()
})
watch(() => props.modelId, (newId) => {
if (newId) {
loadModel(newId)
}
})
// Watch for theme changes to update viewer background
watch(() => themeStore.resolvedTheme, (theme) => {
if (!viewer) return
const bg = theme === 'dark' ? DARK_BG : LIGHT_BG
const threeViewer = viewer.GetViewer()
if (threeViewer) {
const renderer = (threeViewer as unknown as { renderer?: THREE.WebGLRenderer }).renderer
if (renderer) {
const hexColor = (bg.r << 16) | (bg.g << 8) | bg.b
renderer.setClearColor(hexColor)
viewerStore.forceRender()
}
}
})
/**
* Setup WebGL context loss error handling
*/
function setupWebGLErrorHandling() {
const canvas = containerRef.value?.querySelector('canvas')
if (canvas) {
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault()
console.error('WebGL context lost')
cancelLoading()
viewerStore.setError('WebGL 内存不足,模型过大无法加载。请尝试加载较小的模型。')
viewerStore.setLoading(false)
})
canvas.addEventListener('webglcontextrestored', () => {
console.log('WebGL context restored')
})
}
}
/**
* Setup industrial lighting for better model visibility from all angles
*/
function setupIndustrialLighting() {
const scene = viewerStore.scene
if (!scene) return
// Remove existing industrial lights (for model reload)
scene.children
.filter(child => child.name.startsWith('__industrial_'))
.forEach(light => scene.remove(light))
// 1. Ambient light - base brightness
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
ambientLight.name = '__industrial_ambient__'
scene.add(ambientLight)
// 2. Hemisphere light - sky/ground environment
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 0.4)
hemiLight.name = '__industrial_hemi__'
hemiLight.position.set(0, 20, 0)
scene.add(hemiLight)
// 3. Multi-directional fill lights - ensure all angles are lit
const positions = [
[1, 1, 1], [-1, 1, 1], [1, 1, -1], [-1, 1, -1], // Top corners
[1, -1, 1], [-1, -1, 1] // Bottom fill
]
positions.forEach((pos, i) => {
const light = new THREE.DirectionalLight(0xffffff, 0.3)
light.name = `__industrial_dir_${i}__`
light.position.set(pos[0] * 10, pos[1] * 10, pos[2] * 10)
scene.add(light)
})
}
/**
* Auto-capture thumbnail after model loads
* Captures the current view and uploads to server
*/
async function autoCaptureThumbnail(modelId: string) {
// Wait for render to complete (2 frames + delay for all effects)
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => setTimeout(resolve, 800))
const renderer = viewerStore.renderer
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!renderer || !scene || !camera) {
console.warn('Auto-capture: Viewer not ready')
return
}
try {
// Force a final render before capture
viewerStore.forceRender()
await new Promise(resolve => requestAnimationFrame(resolve))
const blob = await captureViewerScreenshot(renderer, scene, camera, 512)
const thumbnailUrl = await uploadThumbnail(modelId, blob)
// Update store to show new thumbnail immediately with cache-busting
const model = modelsStore.models.find(m => m.id === modelId)
if (model) {
// Add timestamp to force browser to fetch fresh image instead of cached old one
const cacheBustedUrl = `${thumbnailUrl}?t=${Date.now()}`
modelsStore.updateModelInStore({ ...model, thumbnail_url: cacheBustedUrl })
}
console.log('Auto-captured thumbnail for model:', modelId)
} catch (error) {
// Log detailed error for debugging
if (error instanceof Error) {
console.warn('Failed to auto-capture thumbnail:', error.message, error.stack)
} else {
console.warn('Failed to auto-capture thumbnail:', String(error))
}
}
}
/**
* Cancel ongoing loading
*/
function cancelLoading() {
if (loadAbortController) {
loadAbortController.abort()
loadAbortController = null
}
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = null
}
}
/**
* Select optimal LOD level based on face count
* - Small models (<100k faces): LOD0 (full quality)
* - Medium models (100k-500k): LOD1 (50% quality)
* - Large models (>500k): LOD2 (25% quality)
*/
function selectOptimalLod(faces: number): number {
if (faces < 100000) return 0
if (faces < 500000) return 1
return 2
}
async function loadModel(modelId: string) {
if (!viewer) return
// Cancel any previous loading
cancelLoading()
viewerStore.setLoading(true)
viewerStore.setError(null)
viewerStore.setLoadingProgress(0, 'downloading')
// Clear color maps BEFORE resetFeatures to avoid stale mesh references
const partsService = getPartsTreeService()
partsService.clearColorMaps()
// Invalidate cached edges for new model
getRenderService().invalidateEdges()
viewerStore.resetFeatures()
// Create abort controller for this load
loadAbortController = new AbortController()
try {
// Get model info to determine face count for LOD selection
const modelInfo = await getModel(modelId)
const metadata = modelInfo.metadata as Record<string, unknown> | null
const faceCount = (metadata?.faces as number) || 0
// Get LOD URLs
let lodUrls: Record<string, string>
try {
lodUrls = await getModelLodUrls(modelId)
} catch {
// Fallback to legacy model_url if LOD not available
lodUrls = { lod0: modelInfo.model_url || '' }
}
// Select optimal LOD based on face count
const optimalLod = selectOptimalLod(faceCount)
const lodKey = `lod${optimalLod}`
const url = lodUrls[lodKey] || lodUrls.lod0 || modelInfo.model_url
if (!url) {
throw new Error('No model URL available')
}
console.log(`Loading model with ${faceCount} faces, using LOD${optimalLod}`)
viewerStore.setCurrentModelUrl(url)
// Extract filename from URL (before query params)
const urlPath = new URL(url).pathname
const filename = urlPath.split('/').pop() || 'model.glb'
// Download with progress tracking
const blob = await fetchWithProgress(
url,
(progress) => {
viewerStore.setLoadingProgress(progress, 'downloading')
},
loadAbortController.signal
)
// Switch to parsing stage
viewerStore.setLoadingProgress(100, 'parsing')
// Create File object with proper filename (online-3d-viewer needs extension)
const file = new File([blob], filename, { type: blob.type })
// Load model from File object
viewer.LoadModelFromFileList([file])
// Wait for model to load (Online3DViewer doesn't have a proper callback)
// We'll use a polling approach - check both model metadata AND mesh in scene
loadCheckInterval = setInterval(() => {
const model = viewer?.GetModel()
if (model) {
// Also check if model mesh is actually in the scene
const threeViewer = viewer?.GetViewer()
const scene = (threeViewer as unknown as { scene?: { children: Array<{ name: string; type: string }> } })?.scene
if (scene) {
// Check scene has non-system objects (actual model mesh)
const hasModelInScene = scene.children.some(child =>
!child.name.startsWith('__') &&
!child.type.includes('Light') &&
!child.type.includes('Camera') &&
!child.type.includes('Helper')
)
if (hasModelInScene) {
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (loadTimeoutId) {
clearTimeout(loadTimeoutId)
loadTimeoutId = null
}
viewerStore.setModel(model)
setupIndustrialLighting()
// Compute BVH for all meshes for fast raycasting (O(log n) instead of O(n))
// Wrapped in try-catch to prevent errors from blocking loading
try {
const threeScene = viewerStore.scene
if (threeScene) {
threeScene.traverse((object: THREE.Object3D) => {
if (object.type === 'Mesh' || (object as THREE.Mesh).isMesh) {
const mesh = object as THREE.Mesh
const geom = mesh.geometry as THREE.BufferGeometry & { boundsTree?: unknown; computeBoundsTree?: () => void }
if (geom && !geom.boundsTree && typeof geom.computeBoundsTree === 'function') {
geom.computeBoundsTree()
}
}
})
}
} catch (e) {
console.warn('BVH computation failed, using standard raycasting:', e)
}
viewerStore.setLoading(false)
// Cache mesh data for section cap worker (avoids blocking on drag end)
getClippingService().updateMeshDataCache()
// Set scene reference in partsService BEFORE applying auto-coloring
// This fixes the race condition where applyAutoColors runs before buildTree sets the scene
const threeSceneForColors = viewerStore.scene
if (threeSceneForColors) {
partsService.setScene(threeSceneForColors)
}
// Apply auto-coloring if enabled (global setting persists across models)
if (viewerStore.renderSettings.autoColorEnabled) {
partsService.applyAutoColors()
}
// Apply edges if enabled (global setting persists across models)
if (viewerStore.renderSettings.edgesEnabled) {
getRenderService().setEdgesEnabled(true)
}
viewerStore.forceRender()
// Auto-capture thumbnail after model loads with all effects applied
autoCaptureThumbnail(modelId)
}
}
}
}, 100)
// Timeout after MAX_LOAD_TIMEOUT (5 minutes)
loadTimeoutId = setTimeout(() => {
if (loadCheckInterval) {
clearInterval(loadCheckInterval)
loadCheckInterval = null
}
if (viewerStore.isLoading) {
viewerStore.setLoading(false)
viewerStore.setError('模型加载超时,文件可能过大或网络较慢。')
}
}, MAX_LOAD_TIMEOUT)
} catch (error) {
viewerStore.setLoading(false)
if (error instanceof Error) {
if (error.name === 'AbortError') {
// Loading was cancelled, don't show error
return
}
viewerStore.setError(error.message)
} else {
viewerStore.setError('加载模型失败')
}
}
}
// ==================== Plane Drag Interaction ====================
// Track if we're in rotation mode (prevents plane detection during rotation drag)
let isRotating = false
// Track pending finalizeDrag callback (to cancel if new interaction starts)
let pendingFinalizeId: number | null = null
// Track ALL axes that need finalizeDrag (persists across interactions until completed)
let pendingFinalizeAxes: Set<Axis> = new Set()
// Cached camera reference for light updates
let cachedCamera: THREE.Camera | null = null
// Cached canvas reference for accurate mouse coordinate calculation
let cachedCanvas: HTMLCanvasElement | null = null
// Track mouse down position for click vs drag detection
let mouseDownPos: { x: number; y: number } | null = null
// Raycaster for click detection - firstHitOnly for better performance
const raycaster = new THREE.Raycaster()
raycaster.firstHitOnly = true
/**
* Get normalized mouse coordinates (-1 to 1)
* Uses the canvas bounding rect for accurate coordinate calculation
*/
function getNormalizedMouse(event: MouseEvent): THREE.Vector2 {
// Use canvas rect for accurate coordinates (canvas may differ from container)
const element = cachedCanvas || containerRef.value!
const rect = element.getBoundingClientRect()
return new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1
)
}
/**
* Get OrbitControls from viewer
*/
function getControls() {
const threeViewer = viewer?.GetViewer()
return (threeViewer as unknown as { navigation?: { camera?: { orbitEnabled: boolean } } })?.navigation?.camera
}
/**
* Handle mouse down - check for plane intersection
* Locks interaction mode: either plane drag or rotation (no switching mid-drag)
*/
function handleMouseDown(event: MouseEvent) {
if (!containerRef.value) return
// Record mouse down position for click detection
mouseDownPos = { x: event.clientX, y: event.clientY }
// Cancel any pending finalizeDrag from previous interaction
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
pendingFinalizeId = null
}
const service = getClippingService()
if (!service.isInitialized()) {
isRotating = true // Default to rotation if service not ready
return
}
const mouse = getNormalizedMouse(event)
const hitAxis = service.checkPlaneIntersection(mouse)
if (hitAxis) {
// Plane drag mode
isRotating = false
service.startDrag(hitAxis)
// Disable orbit controls during drag
const controls = getControls()
if (controls) {
controls.orbitEnabled = false
}
containerRef.value.style.cursor = 'grabbing'
event.preventDefault()
event.stopPropagation()
} else {
// Rotation mode - lock this mode for the entire drag
isRotating = true
}
}
/**
* Handle mouse move - update drag or hover state
* If rotating, skip all plane detection
*/
function handleMouseMove(event: MouseEvent) {
if (!containerRef.value) return
// If rotating, update plane render order for correct occlusion
// and cancel any pending section cap generation (debounce)
if (isRotating) {
getClippingService().updatePlaneRenderOrder()
// Update camera light to follow camera direction
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
pendingFinalizeId = null
}
return
}
const service = getClippingService()
if (!service.isInitialized()) return
const mouse = getNormalizedMouse(event)
if (service.isDragging()) {
// Calculate new position during drag
const newPercent = service.drag(mouse)
if (newPercent !== null) {
const axis = service.getDragAxis()!
// Call setPlanePosition directly with isDragging=true for performance
// This skips expensive cap geometry regeneration during drag
// Don't update store during drag - this would trigger watcher and regenerate cap
service.setPlanePosition(axis, newPercent, true)
// Trigger render to show plane movement and clipping updates
viewerStore.forceRender()
}
containerRef.value.style.cursor = 'grabbing'
} else {
// Check for hover state (only when not dragging and not rotating)
const hoverAxis = service.checkPlaneIntersection(mouse)
containerRef.value.style.cursor = hoverAxis ? 'grab' : ''
}
}
/**
* Handle wheel event - update plane render order and camera light when zooming
*/
function handleWheel() {
getClippingService().updatePlaneRenderOrder()
// Update camera light to follow camera position
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
}
/**
* Handle click for part selection
* Uses the parts tree to find the correct node for the clicked mesh
*/
function handleClick(event: MouseEvent) {
if (!containerRef.value || !mouseDownPos) return
// Check if this was a click (not a drag)
const dx = event.clientX - mouseDownPos.x
const dy = event.clientY - mouseDownPos.y
const distance = Math.sqrt(dx * dx + dy * dy)
// If moved more than 5 pixels, it's a drag, not a click
if (distance > 5) return
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!scene || !camera) return
// Update camera matrices to ensure accurate raycasting after rotation
camera.updateMatrixWorld()
const mouse = getNormalizedMouse(event)
raycaster.setFromCamera(mouse, camera)
// Raycast against all objects, filtering out system objects
const intersects = raycaster.intersectObjects(scene.children, true)
.filter(hit => {
// Skip system objects (prefixed with '__')
if (hit.object.name.startsWith('__')) return false
// Skip non-mesh objects
if (!hit.object.type.includes('Mesh')) return false
// Skip hidden objects
if (!hit.object.visible) return false
return true
})
const partsService = getPartsTreeService()
if (intersects.length > 0) {
// Select the clicked mesh directly - simple and reliable
const clickedMesh = intersects[0].object
partsService.selectPart(clickedMesh)
viewerStore.setSelectedPart(clickedMesh.uuid)
viewerStore.forceRender()
} else {
// Click on empty space - clear selection
partsService.selectPart(null)
viewerStore.setSelectedPart(null)
viewerStore.forceRender()
}
}
/**
* Handle right-click context menu
* Shows a context menu with options for the clicked part
*/
function handleContextMenu(event: MouseEvent) {
event.preventDefault()
if (!containerRef.value) return
const scene = viewerStore.scene
const camera = viewerStore.camera
if (!scene || !camera) return
// Update camera matrices to ensure accurate raycasting after rotation
camera.updateMatrixWorld()
const mouse = getNormalizedMouse(event)
raycaster.setFromCamera(mouse, camera)
// Raycast against all objects, filtering out system objects
const intersects = raycaster.intersectObjects(scene.children, true)
.filter(hit => {
// Skip system objects (prefixed with '__')
if (hit.object.name.startsWith('__')) return false
// Skip non-mesh objects
if (!hit.object.type.includes('Mesh')) return false
// Skip hidden objects
if (!hit.object.visible) return false
return true
})
if (intersects.length > 0) {
// Found a part - show context menu
const clickedMesh = intersects[0].object
viewerStore.showContextMenu(event.clientX, event.clientY, clickedMesh.uuid)
} else {
// Right-click on empty space - hide context menu if visible
viewerStore.hideContextMenu()
}
}
/**
* Handle mouse up - end drag and reset rotation mode
*/
function handleMouseUp(event: MouseEvent) {
// Reset cursor IMMEDIATELY for responsive UX
if (containerRef.value) {
containerRef.value.style.cursor = ''
}
// Update plane render order and camera light after rotation (before resetting isRotating)
if (isRotating) {
getClippingService().updatePlaneRenderOrder()
if (cachedCamera) {
getRenderService().updateCameraLight(cachedCamera)
}
}
// Handle click for part selection (before resetting state)
const service = getClippingService()
if (!service.isDragging()) {
handleClick(event)
}
// Reset rotation mode
isRotating = false
mouseDownPos = null
if (service.isDragging()) {
const dragAxis = service.getDragAxis()
service.endDrag()
// Re-enable orbit controls immediately
const controls = getControls()
if (controls) {
controls.orbitEnabled = true
}
// Track this axis for finalization (don't sync store yet - let timeout handle it)
if (dragAxis) {
pendingFinalizeAxes.add(dragAxis)
}
}
// Schedule finalizeDrag for ALL pending axes (re-schedules after each interaction)
// This ensures section cap is generated after ALL interactions complete
if (pendingFinalizeAxes.size > 0) {
// Cancel any existing timeout first
if (pendingFinalizeId !== null) {
clearTimeout(pendingFinalizeId)
}
pendingFinalizeId = window.setTimeout(() => {
// Finalize ALL pending axes
pendingFinalizeAxes.forEach(axis => {
// Get current visual position (from THREE.Plane.constant)
const currentPercent = service.getPlanePositionPercent(axis)
// Generate section cap asynchronously
service.finalizeDrag(axis)
// Sync store with current visual position
viewerStore.setCrossSectionPosition(axis, currentPercent)
})
pendingFinalizeAxes.clear()
pendingFinalizeId = null
}, 500) as unknown as number // 500ms debounce - wait for user to finish all interactions
}
}
/**
* Setup plane drag event listeners
* Uses capture mode to intercept events before OrbitControls
*/
function setupPlaneDragEvents() {
const canvas = containerRef.value?.querySelector('canvas')
if (!canvas) return
// Cache canvas reference for accurate mouse coordinate calculation
cachedCanvas = canvas
// Set camera reference for clipping service and cache for light updates
const threeViewer = viewer?.GetViewer()
const camera = (threeViewer as unknown as { camera?: THREE.Camera })?.camera
if (camera) {
getClippingService().setCamera(camera)
cachedCamera = camera
// Initialize camera light position
getRenderService().updateCameraLight(camera)
}
// Add event listeners with capture mode to intercept before OrbitControls
canvas.addEventListener('mousedown', handleMouseDown, { capture: true })
canvas.addEventListener('mousemove', handleMouseMove, { capture: true })
canvas.addEventListener('mouseup', handleMouseUp, { capture: true })
canvas.addEventListener('mouseleave', handleMouseUp, { capture: true })
// Wheel listener for zoom - update plane render order
canvas.addEventListener('wheel', handleWheel, { passive: true })
// Context menu listener for right-click (capture mode to intercept before OrbitControls)
canvas.addEventListener('contextmenu', handleContextMenu, { capture: true })
}
/**
* Cleanup plane drag event listeners
*/
function cleanupPlaneDragEvents() {
const canvas = containerRef.value?.querySelector('canvas')
if (!canvas) return
canvas.removeEventListener('mousedown', handleMouseDown, { capture: true })
canvas.removeEventListener('mousemove', handleMouseMove, { capture: true })
canvas.removeEventListener('mouseup', handleMouseUp, { capture: true })
canvas.removeEventListener('mouseleave', handleMouseUp, { capture: true })
canvas.removeEventListener('wheel', handleWheel)
canvas.removeEventListener('contextmenu', handleContextMenu, { capture: true })
// Clear cached references
cachedCanvas = null
}
</script>
<template>
<div ref="containerRef" class="model-viewer">
<ContextMenu />
<ViewCube v-if="viewerStore.model" />
<div v-if="viewerStore.isLoading" class="loading-indicator">
<div class="spinner"></div>
<span class="loading-text">
{{ viewerStore.loadingStage === 'downloading' ? '下载中' : '解析中' }}...
</span>
</div>
<div v-if="viewerStore.error" class="viewer-overlay error">
<p>{{ viewerStore.error }}</p>
</div>
</div>
</template>
<style scoped>
.model-viewer {
width: 100%;
height: 100%;
position: relative;
}
/* Loading indicator - bottom left corner */
.loading-indicator {
position: absolute;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(8px);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.loading-indicator .spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.loading-indicator .loading-text {
font-size: 13px;
color: var(--text-secondary);
margin: 0;
white-space: nowrap;
}
/* Error overlay */
.viewer-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.viewer-overlay p {
margin-top: 12px;
color: var(--text-primary);
}
.viewer-overlay.error {
background: rgba(239, 68, 68, 0.2);
}
.viewer-overlay.error p {
color: var(--danger-color);
max-width: 400px;
text-align: center;
padding: 20px;
background: var(--bg-secondary);
border-radius: 8px;
}
</style>