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>
861 lines
27 KiB
Vue
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>
|