diff --git a/frontend/src/components/viewer/ContextMenu.vue b/frontend/src/components/viewer/ContextMenu.vue index bae242b..0fb5555 100644 --- a/frontend/src/components/viewer/ContextMenu.vue +++ b/frontend/src/components/viewer/ContextMenu.vue @@ -4,6 +4,7 @@ import { useViewerStore } from '@/stores/viewer' import { usePartsTreeStore } from '@/stores/partsTree' import { getPartsTreeService } from '@/services/partsTreeService' import { getExplodeService } from '@/services/explodeService' +import { getMoveService } from '@/services/moveService' const viewerStore = useViewerStore() const partsTreeStore = usePartsTreeStore() @@ -11,8 +12,32 @@ const partsTreeStore = usePartsTreeStore() // Color submenu state const showColorSubmenu = ref(false) -// Get CAD color palette from service -const colorPalette = computed(() => getPartsTreeService().getColorPalette()) + +// Recent colors storage +const RECENT_COLORS_KEY = 'viewer3d_recent_colors' +const MAX_RECENT_COLORS = 5 + +function loadRecentColors(): number[] { + try { + const saved = localStorage.getItem(RECENT_COLORS_KEY) + return saved ? JSON.parse(saved) : [] + } catch { + return [] + } +} + +const recentColors = ref(loadRecentColors()) + +function saveRecentColor(color: number) { + // Remove duplicate, add to front, keep only MAX_RECENT_COLORS + const colors = recentColors.value.filter(c => c !== color) + colors.unshift(color) + recentColors.value = colors.slice(0, MAX_RECENT_COLORS) + localStorage.setItem(RECENT_COLORS_KEY, JSON.stringify(recentColors.value)) +} + +// Get manual color palette (high contrast) from service +const colorPalette = computed(() => getPartsTreeService().getManualColorPalette()) // Check if current part is exploded const isExploded = computed(() => { @@ -33,6 +58,9 @@ const isVisible = computed(() => { return currentNode.value?.visible ?? true }) +// Check if a part is selected (for disabling part-specific options) +const hasSelectedPart = computed(() => !!viewerStore.contextMenu.partId) + // Menu position style const menuStyle = computed(() => { const menu = viewerStore.contextMenu @@ -92,6 +120,15 @@ function handleZoomToFit() { // Reset all to initial state function handleResetAll() { + // Reset moved parts to original positions + const scene = viewerStore.scene + if (scene) { + getMoveService().resetAllInScene(scene) + } + // Exit move mode if active + if (viewerStore.moveMode.active) { + viewerStore.exitMoveMode() + } partsTreeStore.resetAll() viewerStore.hideContextMenu() } @@ -122,11 +159,42 @@ function handleToggleExplode() { viewerStore.hideContextMenu() } +// Enter move mode for the part +function handleMove() { + const partId = viewerStore.contextMenu.partId + if (!partId) return + + // Find the mesh object in the scene + const scene = viewerStore.scene + if (!scene) return + + const object = scene.getObjectByProperty('uuid', partId) + if (!object) return + + // Enter move mode + viewerStore.enterMoveMode(partId, object) + viewerStore.hideContextMenu() +} + // Convert color number to hex string function toHexString(color: number): string { return '#' + color.toString(16).padStart(6, '0') } +// Handle custom color selection from color picker +function handleCustomColor(event: Event) { + const input = event.target as HTMLInputElement + const hexColor = input.value + const color = parseInt(hexColor.slice(1), 16) + saveRecentColor(color) + handleSetColor(color) +} + +// Toggle color panel visibility (click to expand/collapse) +function toggleColorPanel() { + showColorSubmenu.value = !showColorSubmenu.value +} + // Handle click outside to close menu function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement @@ -175,6 +243,8 @@ onUnmounted(() => { - -
+ +
+ + + + + +
+ + +
+
+ + + @@ -326,10 +450,23 @@ onUnmounted(() => { text-align: left; } -.menu-item:hover { +.menu-item:hover:not(.disabled) { background: var(--bg-tertiary); } +.menu-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.menu-item.disabled:hover { + background: transparent; +} + +.menu-item.disabled .menu-icon { + color: var(--text-secondary); +} + .menu-icon { width: 16px; height: 16px; @@ -347,31 +484,53 @@ onUnmounted(() => { margin: 4px 8px; } -.has-submenu { +.has-expand { position: relative; } -.submenu-arrow { +.has-expand.expanded { + background: var(--bg-tertiary); +} + +.expand-arrow { width: 14px; height: 14px; margin-left: auto; color: var(--text-secondary); + transition: transform 0.2s ease; } -.color-submenu { - position: absolute; - left: 100%; - top: 0; - margin-left: 4px; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +.expand-arrow.rotated { + transform: rotate(180deg); +} + +.color-panel { + padding: 8px 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-tertiary); +} + +.color-section-label { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 6px; + padding-left: 2px; +} + +.color-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(6, 1fr); gap: 4px; - min-width: 120px; +} + +.color-grid.recent-colors { + grid-template-columns: repeat(5, 1fr); +} + +.color-divider { + height: 1px; + background: var(--border-color); + margin: 8px 0; } .color-swatch { @@ -388,4 +547,40 @@ onUnmounted(() => { border-color: white; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } + +.custom-color-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 4px 2px; + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary); + transition: background-color 0.15s; +} + +.custom-color-label:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.color-picker-input { + width: 24px; + height: 24px; + border: none; + border-radius: 4px; + cursor: pointer; + padding: 0; + background: transparent; +} + +.color-picker-input::-webkit-color-swatch-wrapper { + padding: 0; +} + +.color-picker-input::-webkit-color-swatch { + border: 2px solid var(--border-color); + border-radius: 4px; +} diff --git a/frontend/src/components/viewer/CrossSection.vue b/frontend/src/components/viewer/CrossSection.vue index f1d826a..1ea5783 100644 --- a/frontend/src/components/viewer/CrossSection.vue +++ b/frontend/src/components/viewer/CrossSection.vue @@ -179,7 +179,7 @@ const axisColors: Record = {

- 已切换到对角区域 + 已翻转显示区域

diff --git a/frontend/src/components/viewer/ModelViewer.vue b/frontend/src/components/viewer/ModelViewer.vue index a3a0f75..fa24799 100644 --- a/frontend/src/components/viewer/ModelViewer.vue +++ b/frontend/src/components/viewer/ModelViewer.vue @@ -9,6 +9,7 @@ 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 { getMoveService } from '@/services/moveService' import { captureViewerScreenshot, uploadThumbnail } from '@/services/screenshotService' import ContextMenu from './ContextMenu.vue' import ViewCube from './ViewCube.vue' @@ -261,6 +262,9 @@ async function loadModel(modelId: string) { const partsService = getPartsTreeService() partsService.clearColorMaps() + // Clear moved parts data for new model + getMoveService().clear() + // Invalidate cached edges for new model getRenderService().invalidateEdges() @@ -369,6 +373,8 @@ async function loadModel(modelId: string) { viewerStore.setLoading(false) // Cache mesh data for section cap worker (avoids blocking on drag end) getClippingService().updateMeshDataCache() + // Update bounds for new model (fixes cutting plane size after model switch) + getClippingService().updateBounds() // Set scene reference in partsService BEFORE applying auto-coloring // This fixes the race condition where applyAutoColors runs before buildTree sets the scene @@ -435,6 +441,9 @@ let cachedCamera: THREE.Camera | null = null let cachedCanvas: HTMLCanvasElement | null = null // Track mouse down position for click vs drag detection let mouseDownPos: { x: number; y: number } | null = null +// Track right-click start position for context menu (distinguish click vs drag) +let rightClickStart: { x: number; y: number } | null = null +const RIGHT_CLICK_THRESHOLD = 5 // pixels - if moved more, it's a drag not a click // Raycaster for click detection - firstHitOnly for better performance const raycaster = new THREE.Raycaster() raycaster.firstHitOnly = true @@ -461,6 +470,19 @@ function getControls() { return (threeViewer as unknown as { navigation?: { camera?: { orbitEnabled: boolean } } })?.navigation?.camera } +/** + * Clear Online3DViewer's navigation button state + * This forces the navigation to stop any ongoing pan/rotate operation + * The buttons array tracks which mouse buttons are pressed - clearing it stops panning + */ +function clearNavigationState() { + const threeViewer = viewer?.GetViewer() + const navigation = (threeViewer as unknown as { navigation?: { mouse?: { buttons: number[] } } })?.navigation + if (navigation?.mouse?.buttons) { + navigation.mouse.buttons.length = 0 + } +} + /** * Handle mouse down - check for plane intersection * Locks interaction mode: either plane drag or rotation (no switching mid-drag) @@ -471,12 +493,37 @@ function handleMouseDown(event: MouseEvent) { // Record mouse down position for click detection mouseDownPos = { x: event.clientX, y: event.clientY } + // Record right-click start position for context menu click vs drag detection + if (event.button === 2) { + rightClickStart = { x: event.clientX, y: event.clientY } + } + // Cancel any pending finalizeDrag from previous interaction if (pendingFinalizeId !== null) { clearTimeout(pendingFinalizeId) pendingFinalizeId = null } + // Handle move mode - left click starts dragging the part + if (viewerStore.moveMode.active && event.button === 0) { + const moveService = getMoveService() + const camera = viewerStore.camera + if (camera && viewerStore.moveMode.partObject) { + moveService.setCamera(camera) + moveService.startDrag(viewerStore.moveMode.partObject, getNormalizedMouse(event)) + + // Disable orbit controls during move + const controls = getControls() + if (controls) { + controls.orbitEnabled = false + } + containerRef.value.style.cursor = 'move' + event.preventDefault() + event.stopPropagation() + return + } + } + const service = getClippingService() if (!service.isInitialized()) { isRotating = true // Default to rotation if service not ready @@ -511,6 +558,24 @@ function handleMouseDown(event: MouseEvent) { function handleMouseMove(event: MouseEvent) { if (!containerRef.value) return + // Handle move mode - update part position during drag + if (viewerStore.moveMode.active) { + const moveService = getMoveService() + if (moveService.isDragging()) { + const delta = moveService.drag(getNormalizedMouse(event)) + if (delta) { + // Sync edge lines with moved object + getRenderService().syncEdgeTransforms() + viewerStore.forceRender() + } + containerRef.value.style.cursor = 'move' + return + } + // Show move cursor when in move mode but not dragging + containerRef.value.style.cursor = 'move' + return + } + // If rotating, update plane render order for correct occlusion // and cancel any pending section cap generation (debounce) if (isRotating) { @@ -599,6 +664,20 @@ function handleClick(event: MouseEvent) { return true }) + // Exit move mode if clicking on empty space or different part + if (viewerStore.moveMode.active) { + const clickedPart = intersects.length > 0 ? intersects[0].object.uuid : null + // Exit if clicking on empty space or a different part + if (!clickedPart || clickedPart !== viewerStore.moveMode.partId) { + viewerStore.exitMoveMode() + if (containerRef.value) { + containerRef.value.style.cursor = '' + } + } + // Don't do normal selection in move mode + return + } + const partsService = getPartsTreeService() if (intersects.length > 0) { @@ -622,6 +701,19 @@ function handleClick(event: MouseEvent) { function handleContextMenu(event: MouseEvent) { event.preventDefault() + // Check if this was a click (not a drag) - only show menu for clicks + if (rightClickStart) { + const dx = event.clientX - rightClickStart.x + const dy = event.clientY - rightClickStart.y + const distance = Math.sqrt(dx * dx + dy * dy) + rightClickStart = null + + // If moved more than threshold, this was a drag (pan) - don't show menu + if (distance >= RIGHT_CLICK_THRESHOLD) { + return + } + } + if (!containerRef.value) return const scene = viewerStore.scene @@ -647,12 +739,13 @@ function handleContextMenu(event: MouseEvent) { }) if (intersects.length > 0) { - // Found a part - show context menu + // Found a part - show context menu with partId 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() + // Right-click on empty space - show context menu without partId + // Part-specific options will be disabled in the menu + viewerStore.showContextMenu(event.clientX, event.clientY, null) } } @@ -660,11 +753,26 @@ function handleContextMenu(event: MouseEvent) { * Handle mouse up - end drag and reset rotation mode */ function handleMouseUp(event: MouseEvent) { - // Reset cursor IMMEDIATELY for responsive UX - if (containerRef.value) { + // Reset cursor IMMEDIATELY for responsive UX (unless in move mode) + if (containerRef.value && !viewerStore.moveMode.active) { containerRef.value.style.cursor = '' } + // Handle move mode - end dragging + const moveService = getMoveService() + if (moveService.isDragging()) { + moveService.endDrag() + + // Clear navigation state to ensure panning stops + clearNavigationState() + + // Keep move mode active until user exits (ESC or click elsewhere) + if (containerRef.value) { + containerRef.value.style.cursor = 'move' + } + return + } + // Update plane render order and camera light after rotation (before resetting isRotating) if (isRotating) { getClippingService().updatePlaneRenderOrder() @@ -687,11 +795,8 @@ function handleMouseUp(event: MouseEvent) { const dragAxis = service.getDragAxis() service.endDrag() - // Re-enable orbit controls immediately - const controls = getControls() - if (controls) { - controls.orbitEnabled = true - } + // Clear navigation state to ensure panning stops + clearNavigationState() // Track this axis for finalization (don't sync store yet - let timeout handle it) if (dragAxis) { @@ -721,6 +826,26 @@ function handleMouseUp(event: MouseEvent) { pendingFinalizeId = null }, 500) as unknown as number // 500ms debounce - wait for user to finish all interactions } + + // Bug fix: Clear navigation button state on right-click release + // Online3DViewer tracks pressed buttons in navigation.mouse.buttons array + // If not cleared, panning continues after right-click release + if (event.button === 2) { + clearNavigationState() + } +} + +/** + * Handle keyboard events for move mode + */ +function handleKeyDown(event: KeyboardEvent) { + // ESC key exits move mode + if (event.key === 'Escape' && viewerStore.moveMode.active) { + viewerStore.exitMoveMode() + if (containerRef.value) { + containerRef.value.style.cursor = '' + } + } } /** @@ -753,6 +878,8 @@ function setupPlaneDragEvents() { canvas.addEventListener('wheel', handleWheel, { passive: true }) // Context menu listener for right-click (capture mode to intercept before OrbitControls) canvas.addEventListener('contextmenu', handleContextMenu, { capture: true }) + // Keyboard listener for ESC key to exit move mode + document.addEventListener('keydown', handleKeyDown) } /** @@ -768,6 +895,7 @@ function cleanupPlaneDragEvents() { canvas.removeEventListener('mouseleave', handleMouseUp, { capture: true }) canvas.removeEventListener('wheel', handleWheel) canvas.removeEventListener('contextmenu', handleContextMenu, { capture: true }) + document.removeEventListener('keydown', handleKeyDown) // Clear cached references cachedCanvas = null @@ -778,6 +906,10 @@ function cleanupPlaneDragEvents() {
+ +
+ 移动模式 - 拖拽移动零件,ESC退出 +
@@ -826,6 +958,22 @@ function cleanupPlaneDragEvents() { white-space: nowrap; } +/* Move mode indicator - top center */ +.move-mode-indicator { + position: absolute; + top: 20px; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + background: rgba(59, 130, 246, 0.9); + color: white; + border-radius: 20px; + font-size: 13px; + z-index: 10; + box-shadow: 0 2px 12px rgba(59, 130, 246, 0.3); + pointer-events: none; +} + /* Error overlay */ .viewer-overlay { position: absolute; diff --git a/frontend/src/services/clippingService.ts b/frontend/src/services/clippingService.ts index df46709..e857f55 100644 --- a/frontend/src/services/clippingService.ts +++ b/frontend/src/services/clippingService.ts @@ -188,6 +188,7 @@ export class ClippingService { } material.clipShadows = true + material.clipIntersection = this.flipped // Sync flipped state material.needsUpdate = true }) } @@ -452,16 +453,23 @@ export class ClippingService { } /** - * Flip all enabled plane normals (show opposite region) + * Flip clipping to show complementary region by negating plane normals + * This shows the previously hidden region and hides the previously shown region */ flipAllPlaneNormals(): void { this.flipped = !this.flipped - const axes: Axis[] = ['x', 'y', 'z'] + // Negate all plane normals and constants to flip the clipping region + const axes: Axis[] = ['x', 'y', 'z'] + axes.forEach((axis) => { + const plane = this.planes[axis] + plane.normal.negate() + plane.constant = -plane.constant + }) + + // Update plane meshes and section caps for all enabled axes axes.forEach((axis) => { if (this.enabledAxes[axis]) { - const plane = this.planes[axis] - plane.negate() this.updatePlaneMeshPosition(axis) this.updateCapPosition(axis) } @@ -1389,6 +1397,7 @@ export class ClippingService { materials.forEach((material) => { material.clippingPlanes = [] + material.clipIntersection = false // Reset flipped state material.needsUpdate = true }) } diff --git a/frontend/src/services/moveService.ts b/frontend/src/services/moveService.ts new file mode 100644 index 0000000..7182878 --- /dev/null +++ b/frontend/src/services/moveService.ts @@ -0,0 +1,207 @@ +import * as THREE from 'three' + +interface MoveData { + originalPosition: THREE.Vector3 // Original position before any moves + currentOffset: THREE.Vector3 // Accumulated offset from original +} + +/** + * Service for moving parts in the 3D scene + * Handles drag interactions along the camera-facing plane + */ +export class MoveService { + private movedParts: Map = new Map() + private camera: THREE.Camera | null = null + private dragPlane: THREE.Plane = new THREE.Plane() + private dragStartPoint: THREE.Vector3 = new THREE.Vector3() + private dragStartPosition: THREE.Vector3 = new THREE.Vector3() + private draggingObject: THREE.Object3D | null = null + private raycaster: THREE.Raycaster = new THREE.Raycaster() + + /** + * Set the camera reference for raycasting + */ + setCamera(camera: THREE.Camera): void { + this.camera = camera + } + + /** + * Start dragging a part + * Sets up the drag plane perpendicular to camera view + */ + startDrag(object: THREE.Object3D, mouse: THREE.Vector2): void { + if (!this.camera) return + + this.draggingObject = object + + // Store original position if first time moving this part + if (!this.movedParts.has(object.uuid)) { + this.movedParts.set(object.uuid, { + originalPosition: object.position.clone(), + currentOffset: new THREE.Vector3() + }) + } + + // Get object's world position for plane placement + const objectWorldPos = new THREE.Vector3() + object.getWorldPosition(objectWorldPos) + + // Create drag plane facing camera, through object center + const cameraDir = new THREE.Vector3() + this.camera.getWorldDirection(cameraDir) + + this.dragPlane.setFromNormalAndCoplanarPoint(cameraDir, objectWorldPos) + + // Find initial intersection point + this.raycaster.setFromCamera(mouse, this.camera) + this.raycaster.ray.intersectPlane(this.dragPlane, this.dragStartPoint) + + // Store object's current local position + this.dragStartPosition.copy(object.position) + } + + /** + * Process drag movement + * Returns the delta or null if invalid + */ + drag(mouse: THREE.Vector2): THREE.Vector3 | null { + if (!this.camera || !this.draggingObject) return null + + this.raycaster.setFromCamera(mouse, this.camera) + + const intersection = new THREE.Vector3() + const hit = this.raycaster.ray.intersectPlane(this.dragPlane, intersection) + if (!hit) return null + + // Calculate world-space delta + const worldDelta = intersection.clone().sub(this.dragStartPoint) + + // Convert to local-space delta (account for parent transforms) + const parent = this.draggingObject.parent + if (parent) { + const parentWorldMatrixInverse = new THREE.Matrix4().copy(parent.matrixWorld).invert() + worldDelta.transformDirection(parentWorldMatrixInverse) + } + + // Apply to object position + this.draggingObject.position.copy(this.dragStartPosition).add(worldDelta) + + // Update stored offset + const moveData = this.movedParts.get(this.draggingObject.uuid) + if (moveData) { + moveData.currentOffset.copy(this.draggingObject.position).sub(moveData.originalPosition) + } + + return worldDelta + } + + /** + * End dragging + */ + endDrag(): void { + this.draggingObject = null + } + + /** + * Check if currently dragging + */ + isDragging(): boolean { + return this.draggingObject !== null + } + + /** + * Get moved offset for a part (for sync after other transforms) + */ + getMovedOffset(uuid: string): THREE.Vector3 | null { + const data = this.movedParts.get(uuid) + return data ? data.currentOffset.clone() : null + } + + /** + * Check if a part has been moved + */ + hasMoved(uuid: string): boolean { + return this.movedParts.has(uuid) + } + + /** + * Reset a specific part to original position + */ + resetPart(uuid: string, object: THREE.Object3D): void { + const data = this.movedParts.get(uuid) + if (data) { + object.position.copy(data.originalPosition) + this.movedParts.delete(uuid) + } + } + + /** + * Reset all moved parts to their original positions + * Requires scene reference to find objects + */ + resetAllInScene(scene: THREE.Scene): void { + this.movedParts.forEach((data, uuid) => { + const object = scene.getObjectByProperty('uuid', uuid) + if (object) { + object.position.copy(data.originalPosition) + } + }) + this.movedParts.clear() + } + + /** + * Clear all move data (without resetting positions) + */ + clear(): void { + this.movedParts.clear() + this.draggingObject = null + } + + /** + * Re-apply move offset after other transformations (e.g., explosion reset) + * Call this after the object's base position changes + */ + reapplyOffset(uuid: string, object: THREE.Object3D): void { + const data = this.movedParts.get(uuid) + if (data) { + // Update originalPosition to current base position (without our offset) + data.originalPosition.copy(object.position).sub(data.currentOffset) + // Re-apply offset + object.position.add(data.currentOffset) + } + } + + /** + * Update base position after external changes (e.g., explosion) + * This records the new "base" position while preserving the move offset + */ + updateBasePosition(uuid: string, newBasePosition: THREE.Vector3): void { + const data = this.movedParts.get(uuid) + if (data) { + data.originalPosition.copy(newBasePosition) + } + } +} + +// Singleton instance +let moveService: MoveService | null = null + +/** + * Get the singleton MoveService instance + */ +export function getMoveService(): MoveService { + if (!moveService) { + moveService = new MoveService() + } + return moveService +} + +/** + * Reset the MoveService singleton + */ +export function resetMoveService(): void { + if (moveService) { + moveService.clear() + } + moveService = null +} diff --git a/frontend/src/services/partsTreeService.ts b/frontend/src/services/partsTreeService.ts index d6da788..84a51ae 100644 --- a/frontend/src/services/partsTreeService.ts +++ b/frontend/src/services/partsTreeService.ts @@ -52,6 +52,22 @@ export class PartsTreeService { 0x8B7B7B, // 暖灰 ] + // Manual color palette - vibrant colors with high differentiation for user selection + private readonly manualColorPalette = [ + // 红色系 + 0xFF4444, 0xE91E63, 0xF44336, + // 橙黄色系 + 0xFF9800, 0xFFC107, 0xFFEB3B, + // 绿色系 + 0x4CAF50, 0x8BC34A, 0x009688, + // 蓝色系 + 0x2196F3, 0x03A9F4, 0x00BCD4, + // 紫色系 + 0x9C27B0, 0x673AB7, 0x3F51B5, + // 中性色 + 0x795548, 0x607D8B, 0x9E9E9E, + ] + /** * Set scene reference */ @@ -678,6 +694,7 @@ export class PartsTreeService { resetToOriginalColors(): void { this.autoColorEnabled = false this.autoColorPalette.clear() + this.partMaterialOverrides.clear() // Clear manual color overrides this.refreshAllMaterials() } @@ -815,12 +832,19 @@ export class PartsTreeService { /** - * Get the CAD color palette + * Get the CAD color palette (for auto-coloring) */ getColorPalette(): number[] { return [...this.cadColorPalette] } + /** + * Get the manual color palette (for user selection - high contrast colors) + */ + getManualColorPalette(): number[] { + return [...this.manualColorPalette] + } + // ==================== Material System ==================== /** diff --git a/frontend/src/services/screenshotService.ts b/frontend/src/services/screenshotService.ts index 7c6dc62..06947e8 100644 --- a/frontend/src/services/screenshotService.ts +++ b/frontend/src/services/screenshotService.ts @@ -1,7 +1,5 @@ import * as THREE from 'three' -const API_URL = import.meta.env.VITE_API_URL || '' - /** * Capture a full-resolution screenshot from the Three.js renderer * Preserves the original viewport dimensions @@ -107,7 +105,7 @@ export async function uploadThumbnail( const formData = new FormData() formData.append('thumbnail', blob, 'thumbnail.png') - const response = await fetch(`${API_URL}/api/models/${modelId}/thumbnail`, { + const response = await fetch(`/api/models/${modelId}/thumbnail`, { method: 'POST', body: formData, }) diff --git a/frontend/src/stores/viewer.ts b/frontend/src/stores/viewer.ts index d937843..8266c8c 100644 --- a/frontend/src/stores/viewer.ts +++ b/frontend/src/stores/viewer.ts @@ -54,6 +54,13 @@ export const useViewerStore = defineStore('viewer', () => { partId: null as string | null, }) + // Move mode state - for moving parts in the scene + const moveMode = ref({ + active: false, + partId: null as string | null, + partObject: null as THREE.Object3D | null, + }) + // Actions function setViewer(v: OV.EmbeddedViewer | null) { viewer.value = v @@ -163,8 +170,8 @@ export const useViewerStore = defineStore('viewer', () => { selectedPartId.value = id } - function showContextMenu(x: number, y: number, partId: string) { - contextMenu.value = { visible: true, x, y, partId } + function showContextMenu(x: number, y: number, partId?: string | null) { + contextMenu.value = { visible: true, x, y, partId: partId ?? null } } function hideContextMenu() { @@ -172,6 +179,14 @@ export const useViewerStore = defineStore('viewer', () => { contextMenu.value.partId = null } + function enterMoveMode(partId: string, partObject: THREE.Object3D) { + moveMode.value = { active: true, partId, partObject } + } + + function exitMoveMode() { + moveMode.value = { active: false, partId: null, partObject: null } + } + function resetFeatures() { // Preserve global settings that should persist across model switches const currentAutoColor = renderSettings.value.autoColorEnabled @@ -181,6 +196,7 @@ export const useViewerStore = defineStore('viewer', () => { isExplodedViewEnabled.value = false selectedPartId.value = null contextMenu.value = { visible: false, x: 0, y: 0, partId: null } + moveMode.value = { active: false, partId: null, partObject: null } crossSection.value = { x: { enabled: false, position: 100 }, y: { enabled: false, position: 100 }, @@ -213,25 +229,37 @@ export const useViewerStore = defineStore('viewer', () => { * Fit the camera to show the entire model */ function fitToView() { - if (!viewer.value) return + if (!viewer.value) { + console.warn('fitToView: viewer not available') + return + } const threeViewer = viewer.value.GetViewer() - if (!threeViewer) return + if (!threeViewer) { + console.warn('fitToView: threeViewer not available') + return + } - // Access navigation and bounding sphere from Online3DViewer - const nav = (threeViewer as unknown as { - navigation?: { - FitSphereToWindow: (sphere: unknown, animate: boolean) => void - } - }).navigation + // Type assertion for Online3DViewer's Viewer class + const viewerWithMethods = threeViewer as unknown as { + GetBoundingSphere?: (needToProcess: (meshUserData: unknown) => boolean) => { center: { x: number; y: number; z: number }; radius: number } | null + FitSphereToWindow?: (boundingSphere: { center: { x: number; y: number; z: number }; radius: number }, animate: boolean) => void + } - const getBoundingSphere = (threeViewer as unknown as { - GetBoundingSphere?: () => unknown - }).GetBoundingSphere + if (!viewerWithMethods.GetBoundingSphere || !viewerWithMethods.FitSphereToWindow) { + console.warn('fitToView: required methods not available') + return + } - if (nav && getBoundingSphere) { - const sphere = getBoundingSphere.call(threeViewer) - nav.FitSphereToWindow(sphere, true) // true = animate + // Get bounding sphere (requires filter function that returns true for all meshes) + const boundingSphere = viewerWithMethods.GetBoundingSphere(() => true) + + if (boundingSphere && boundingSphere.center && typeof boundingSphere.radius === 'number') { + // FitSphereToWindow is a Viewer method, not Navigation method + // It expects the raw { center, radius } object, not THREE.Sphere + viewerWithMethods.FitSphereToWindow(boundingSphere, true) // true = animate + } else { + console.warn('fitToView: cannot get bounding sphere') } } @@ -528,6 +556,7 @@ export const useViewerStore = defineStore('viewer', () => { renderSettings, selectedPartId, contextMenu, + moveMode, // Actions setViewer, setModel, @@ -552,6 +581,8 @@ export const useViewerStore = defineStore('viewer', () => { setSelectedPart, showContextMenu, hideContextMenu, + enterMoveMode, + exitMoveMode, resetFeatures, forceRender, fitToView,