Add move/drag parts feature with camera-facing plane interaction

- Add moveService for dragging parts along camera-perpendicular plane
- Update ContextMenu with move/reset options
- Add drag event handling to ModelViewer
- Update viewer store with move state management
- Minor updates to clipping, parts tree, and screenshot services

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
likegears
2025-12-12 17:07:56 +08:00
parent 7af9c323f6
commit a9971ea0b1
8 changed files with 673 additions and 61 deletions

View File

@@ -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<number[]>(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(() => {
<button
v-if="isVisible"
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleHide"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
@@ -186,6 +256,8 @@ onUnmounted(() => {
<button
v-else
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleShow"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
@@ -198,6 +270,8 @@ onUnmounted(() => {
<!-- Isolate: show only this part -->
<button
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleIsolate"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
@@ -221,6 +295,8 @@ onUnmounted(() => {
<!-- Transparent -->
<button
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleTransparent"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
@@ -231,22 +307,43 @@ onUnmounted(() => {
<div class="menu-divider"></div>
<!-- Change Color with submenu -->
<div
class="menu-item has-submenu"
@mouseenter="showColorSubmenu = true"
@mouseleave="showColorSubmenu = false"
<!-- Change Color - click to expand inline -->
<button
class="menu-item has-expand"
:class="{ expanded: showColorSubmenu, disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click.stop="toggleColorPanel"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clip-rule="evenodd"/>
</svg>
<span>更改颜色</span>
<svg viewBox="0 0 20 20" fill="currentColor" class="submenu-arrow">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
<svg viewBox="0 0 20 20" fill="currentColor" class="expand-arrow" :class="{ rotated: showColorSubmenu }">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<!-- Color submenu -->
<div v-if="showColorSubmenu" class="color-submenu">
<!-- Color panel (inline, expands below the button) -->
<div v-if="showColorSubmenu && hasSelectedPart" class="color-panel" @click.stop>
<!-- Recent colors section -->
<template v-if="recentColors.length > 0">
<div class="color-section-label">最近使用</div>
<div class="color-grid recent-colors">
<button
v-for="color in recentColors"
:key="'recent-' + color"
class="color-swatch"
:style="{ backgroundColor: toHexString(color) }"
:title="toHexString(color)"
@click="handleSetColor(color)"
/>
</div>
<div class="color-divider"></div>
</template>
<!-- Preset colors -->
<div class="color-section-label">预设颜色</div>
<div class="color-grid">
<button
v-for="color in colorPalette"
:key="color"
@@ -256,11 +353,25 @@ onUnmounted(() => {
@click="handleSetColor(color)"
/>
</div>
<!-- Custom color picker -->
<div class="color-divider"></div>
<label class="custom-color-label">
<input
type="color"
class="color-picker-input"
@change="handleCustomColor"
title="自定义颜色"
/>
<span>自定义颜色...</span>
</label>
</div>
<!-- Explode/Reset button -->
<button
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleToggleExplode"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
@@ -270,6 +381,19 @@ onUnmounted(() => {
<span>{{ isExploded ? '复位' : '爆炸' }}</span>
</button>
<!-- Move button -->
<button
class="menu-item"
:class="{ disabled: !hasSelectedPart }"
:disabled="!hasSelectedPart"
@click="handleMove"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
<path d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L11 6.414V10h3.586l-1.293-1.293a1 1 0 111.414-1.414l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L14.586 12H11v3.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L9 15.586V12H5.414l1.293 1.293a1 1 0 11-1.414 1.414l-3-3a1 1 0 010-1.414l3-3a1 1 0 011.414 1.414L5.414 10H9V6.414L7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3z"/>
</svg>
<span>移动</span>
</button>
<div class="menu-divider"></div>
<!-- Zoom to Fit -->
@@ -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;
}
</style>

View File

@@ -179,7 +179,7 @@ const axisColors: Record<Axis, string> = {
</div>
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
切换到对角区域
翻转显示区域
</p>
</div>
</template>

View File

@@ -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() {
<div ref="containerRef" class="model-viewer">
<ContextMenu />
<ViewCube v-if="viewerStore.model" />
<!-- Move mode indicator -->
<div v-if="viewerStore.moveMode.active" class="move-mode-indicator">
移动模式 - 拖拽移动零件ESC退出
</div>
<div v-if="viewerStore.isLoading" class="loading-indicator">
<div class="spinner"></div>
<span class="loading-text">
@@ -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;

View File

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

View File

@@ -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<string, MoveData> = 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
}

View File

@@ -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 ====================
/**

View File

@@ -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,
})

View File

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