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:
@@ -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>
|
||||
|
||||
@@ -179,7 +179,7 @@ const axisColors: Record<Axis, string> = {
|
||||
</div>
|
||||
|
||||
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
|
||||
已切换到对角区域
|
||||
已翻转显示区域
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
207
frontend/src/services/moveService.ts
Normal file
207
frontend/src/services/moveService.ts
Normal 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
|
||||
}
|
||||
@@ -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 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user