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 { usePartsTreeStore } from '@/stores/partsTree'
|
||||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||||
import { getExplodeService } from '@/services/explodeService'
|
import { getExplodeService } from '@/services/explodeService'
|
||||||
|
import { getMoveService } from '@/services/moveService'
|
||||||
|
|
||||||
const viewerStore = useViewerStore()
|
const viewerStore = useViewerStore()
|
||||||
const partsTreeStore = usePartsTreeStore()
|
const partsTreeStore = usePartsTreeStore()
|
||||||
@@ -11,8 +12,32 @@ const partsTreeStore = usePartsTreeStore()
|
|||||||
// Color submenu state
|
// Color submenu state
|
||||||
const showColorSubmenu = ref(false)
|
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
|
// Check if current part is exploded
|
||||||
const isExploded = computed(() => {
|
const isExploded = computed(() => {
|
||||||
@@ -33,6 +58,9 @@ const isVisible = computed(() => {
|
|||||||
return currentNode.value?.visible ?? true
|
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
|
// Menu position style
|
||||||
const menuStyle = computed(() => {
|
const menuStyle = computed(() => {
|
||||||
const menu = viewerStore.contextMenu
|
const menu = viewerStore.contextMenu
|
||||||
@@ -92,6 +120,15 @@ function handleZoomToFit() {
|
|||||||
|
|
||||||
// Reset all to initial state
|
// Reset all to initial state
|
||||||
function handleResetAll() {
|
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()
|
partsTreeStore.resetAll()
|
||||||
viewerStore.hideContextMenu()
|
viewerStore.hideContextMenu()
|
||||||
}
|
}
|
||||||
@@ -122,11 +159,42 @@ function handleToggleExplode() {
|
|||||||
viewerStore.hideContextMenu()
|
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
|
// Convert color number to hex string
|
||||||
function toHexString(color: number): string {
|
function toHexString(color: number): string {
|
||||||
return '#' + color.toString(16).padStart(6, '0')
|
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
|
// Handle click outside to close menu
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
@@ -175,6 +243,8 @@ onUnmounted(() => {
|
|||||||
<button
|
<button
|
||||||
v-if="isVisible"
|
v-if="isVisible"
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
:class="{ disabled: !hasSelectedPart }"
|
||||||
|
:disabled="!hasSelectedPart"
|
||||||
@click="handleHide"
|
@click="handleHide"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
||||||
@@ -186,6 +256,8 @@ onUnmounted(() => {
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
:class="{ disabled: !hasSelectedPart }"
|
||||||
|
:disabled="!hasSelectedPart"
|
||||||
@click="handleShow"
|
@click="handleShow"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
||||||
@@ -198,6 +270,8 @@ onUnmounted(() => {
|
|||||||
<!-- Isolate: show only this part -->
|
<!-- Isolate: show only this part -->
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
:class="{ disabled: !hasSelectedPart }"
|
||||||
|
:disabled="!hasSelectedPart"
|
||||||
@click="handleIsolate"
|
@click="handleIsolate"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
||||||
@@ -221,6 +295,8 @@ onUnmounted(() => {
|
|||||||
<!-- Transparent -->
|
<!-- Transparent -->
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
:class="{ disabled: !hasSelectedPart }"
|
||||||
|
:disabled="!hasSelectedPart"
|
||||||
@click="handleTransparent"
|
@click="handleTransparent"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
||||||
@@ -231,22 +307,43 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<!-- Change Color with submenu -->
|
<!-- Change Color - click to expand inline -->
|
||||||
<div
|
<button
|
||||||
class="menu-item has-submenu"
|
class="menu-item has-expand"
|
||||||
@mouseenter="showColorSubmenu = true"
|
:class="{ expanded: showColorSubmenu, disabled: !hasSelectedPart }"
|
||||||
@mouseleave="showColorSubmenu = false"
|
:disabled="!hasSelectedPart"
|
||||||
|
@click.stop="toggleColorPanel"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<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"/>
|
<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>
|
</svg>
|
||||||
<span>更改颜色</span>
|
<span>更改颜色</span>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="submenu-arrow">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="expand-arrow" :class="{ rotated: showColorSubmenu }">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Color submenu -->
|
<!-- Color panel (inline, expands below the button) -->
|
||||||
<div v-if="showColorSubmenu" class="color-submenu">
|
<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
|
<button
|
||||||
v-for="color in colorPalette"
|
v-for="color in colorPalette"
|
||||||
:key="color"
|
:key="color"
|
||||||
@@ -256,11 +353,25 @@ onUnmounted(() => {
|
|||||||
@click="handleSetColor(color)"
|
@click="handleSetColor(color)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Explode/Reset button -->
|
<!-- Explode/Reset button -->
|
||||||
<button
|
<button
|
||||||
class="menu-item"
|
class="menu-item"
|
||||||
|
:class="{ disabled: !hasSelectedPart }"
|
||||||
|
:disabled="!hasSelectedPart"
|
||||||
@click="handleToggleExplode"
|
@click="handleToggleExplode"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
<svg viewBox="0 0 20 20" fill="currentColor" class="menu-icon">
|
||||||
@@ -270,6 +381,19 @@ onUnmounted(() => {
|
|||||||
<span>{{ isExploded ? '复位' : '爆炸' }}</span>
|
<span>{{ isExploded ? '复位' : '爆炸' }}</span>
|
||||||
</button>
|
</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>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<!-- Zoom to Fit -->
|
<!-- Zoom to Fit -->
|
||||||
@@ -326,10 +450,23 @@ onUnmounted(() => {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item:hover {
|
.menu-item:hover:not(.disabled) {
|
||||||
background: var(--bg-tertiary);
|
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 {
|
.menu-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -347,31 +484,53 @@ onUnmounted(() => {
|
|||||||
margin: 4px 8px;
|
margin: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-submenu {
|
.has-expand {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu-arrow {
|
.has-expand.expanded {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-submenu {
|
.expand-arrow.rotated {
|
||||||
position: absolute;
|
transform: rotate(180deg);
|
||||||
left: 100%;
|
}
|
||||||
top: 0;
|
|
||||||
margin-left: 4px;
|
.color-panel {
|
||||||
background: var(--bg-secondary);
|
padding: 8px 12px;
|
||||||
border: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
background: var(--bg-tertiary);
|
||||||
padding: 8px;
|
}
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
|
.color-section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 4px;
|
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 {
|
.color-swatch {
|
||||||
@@ -388,4 +547,40 @@ onUnmounted(() => {
|
|||||||
border-color: white;
|
border-color: white;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const axisColors: Record<Axis, string> = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
|
<p v-if="hasActivePlane && viewerStore.crossSection.sectionFlipped" class="hint">
|
||||||
已切换到对角区域
|
已翻转显示区域
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getModel, getModelLodUrls, fetchWithProgress } from '@/api/client'
|
|||||||
import { getPartsTreeService } from '@/services/partsTreeService'
|
import { getPartsTreeService } from '@/services/partsTreeService'
|
||||||
import { getClippingService, type Axis } from '@/services/clippingService'
|
import { getClippingService, type Axis } from '@/services/clippingService'
|
||||||
import { getRenderService } from '@/services/renderService'
|
import { getRenderService } from '@/services/renderService'
|
||||||
|
import { getMoveService } from '@/services/moveService'
|
||||||
import { captureViewerScreenshot, uploadThumbnail } from '@/services/screenshotService'
|
import { captureViewerScreenshot, uploadThumbnail } from '@/services/screenshotService'
|
||||||
import ContextMenu from './ContextMenu.vue'
|
import ContextMenu from './ContextMenu.vue'
|
||||||
import ViewCube from './ViewCube.vue'
|
import ViewCube from './ViewCube.vue'
|
||||||
@@ -261,6 +262,9 @@ async function loadModel(modelId: string) {
|
|||||||
const partsService = getPartsTreeService()
|
const partsService = getPartsTreeService()
|
||||||
partsService.clearColorMaps()
|
partsService.clearColorMaps()
|
||||||
|
|
||||||
|
// Clear moved parts data for new model
|
||||||
|
getMoveService().clear()
|
||||||
|
|
||||||
// Invalidate cached edges for new model
|
// Invalidate cached edges for new model
|
||||||
getRenderService().invalidateEdges()
|
getRenderService().invalidateEdges()
|
||||||
|
|
||||||
@@ -369,6 +373,8 @@ async function loadModel(modelId: string) {
|
|||||||
viewerStore.setLoading(false)
|
viewerStore.setLoading(false)
|
||||||
// Cache mesh data for section cap worker (avoids blocking on drag end)
|
// Cache mesh data for section cap worker (avoids blocking on drag end)
|
||||||
getClippingService().updateMeshDataCache()
|
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
|
// Set scene reference in partsService BEFORE applying auto-coloring
|
||||||
// This fixes the race condition where applyAutoColors runs before buildTree sets the scene
|
// 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
|
let cachedCanvas: HTMLCanvasElement | null = null
|
||||||
// Track mouse down position for click vs drag detection
|
// Track mouse down position for click vs drag detection
|
||||||
let mouseDownPos: { x: number; y: number } | null = null
|
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
|
// Raycaster for click detection - firstHitOnly for better performance
|
||||||
const raycaster = new THREE.Raycaster()
|
const raycaster = new THREE.Raycaster()
|
||||||
raycaster.firstHitOnly = true
|
raycaster.firstHitOnly = true
|
||||||
@@ -461,6 +470,19 @@ function getControls() {
|
|||||||
return (threeViewer as unknown as { navigation?: { camera?: { orbitEnabled: boolean } } })?.navigation?.camera
|
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
|
* Handle mouse down - check for plane intersection
|
||||||
* Locks interaction mode: either plane drag or rotation (no switching mid-drag)
|
* 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
|
// Record mouse down position for click detection
|
||||||
mouseDownPos = { x: event.clientX, y: event.clientY }
|
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
|
// Cancel any pending finalizeDrag from previous interaction
|
||||||
if (pendingFinalizeId !== null) {
|
if (pendingFinalizeId !== null) {
|
||||||
clearTimeout(pendingFinalizeId)
|
clearTimeout(pendingFinalizeId)
|
||||||
pendingFinalizeId = null
|
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()
|
const service = getClippingService()
|
||||||
if (!service.isInitialized()) {
|
if (!service.isInitialized()) {
|
||||||
isRotating = true // Default to rotation if service not ready
|
isRotating = true // Default to rotation if service not ready
|
||||||
@@ -511,6 +558,24 @@ function handleMouseDown(event: MouseEvent) {
|
|||||||
function handleMouseMove(event: MouseEvent) {
|
function handleMouseMove(event: MouseEvent) {
|
||||||
if (!containerRef.value) return
|
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
|
// If rotating, update plane render order for correct occlusion
|
||||||
// and cancel any pending section cap generation (debounce)
|
// and cancel any pending section cap generation (debounce)
|
||||||
if (isRotating) {
|
if (isRotating) {
|
||||||
@@ -599,6 +664,20 @@ function handleClick(event: MouseEvent) {
|
|||||||
return true
|
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()
|
const partsService = getPartsTreeService()
|
||||||
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
@@ -622,6 +701,19 @@ function handleClick(event: MouseEvent) {
|
|||||||
function handleContextMenu(event: MouseEvent) {
|
function handleContextMenu(event: MouseEvent) {
|
||||||
event.preventDefault()
|
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
|
if (!containerRef.value) return
|
||||||
|
|
||||||
const scene = viewerStore.scene
|
const scene = viewerStore.scene
|
||||||
@@ -647,12 +739,13 @@ function handleContextMenu(event: MouseEvent) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
// Found a part - show context menu
|
// Found a part - show context menu with partId
|
||||||
const clickedMesh = intersects[0].object
|
const clickedMesh = intersects[0].object
|
||||||
viewerStore.showContextMenu(event.clientX, event.clientY, clickedMesh.uuid)
|
viewerStore.showContextMenu(event.clientX, event.clientY, clickedMesh.uuid)
|
||||||
} else {
|
} else {
|
||||||
// Right-click on empty space - hide context menu if visible
|
// Right-click on empty space - show context menu without partId
|
||||||
viewerStore.hideContextMenu()
|
// 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
|
* Handle mouse up - end drag and reset rotation mode
|
||||||
*/
|
*/
|
||||||
function handleMouseUp(event: MouseEvent) {
|
function handleMouseUp(event: MouseEvent) {
|
||||||
// Reset cursor IMMEDIATELY for responsive UX
|
// Reset cursor IMMEDIATELY for responsive UX (unless in move mode)
|
||||||
if (containerRef.value) {
|
if (containerRef.value && !viewerStore.moveMode.active) {
|
||||||
containerRef.value.style.cursor = ''
|
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)
|
// Update plane render order and camera light after rotation (before resetting isRotating)
|
||||||
if (isRotating) {
|
if (isRotating) {
|
||||||
getClippingService().updatePlaneRenderOrder()
|
getClippingService().updatePlaneRenderOrder()
|
||||||
@@ -687,11 +795,8 @@ function handleMouseUp(event: MouseEvent) {
|
|||||||
const dragAxis = service.getDragAxis()
|
const dragAxis = service.getDragAxis()
|
||||||
service.endDrag()
|
service.endDrag()
|
||||||
|
|
||||||
// Re-enable orbit controls immediately
|
// Clear navigation state to ensure panning stops
|
||||||
const controls = getControls()
|
clearNavigationState()
|
||||||
if (controls) {
|
|
||||||
controls.orbitEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track this axis for finalization (don't sync store yet - let timeout handle it)
|
// Track this axis for finalization (don't sync store yet - let timeout handle it)
|
||||||
if (dragAxis) {
|
if (dragAxis) {
|
||||||
@@ -721,6 +826,26 @@ function handleMouseUp(event: MouseEvent) {
|
|||||||
pendingFinalizeId = null
|
pendingFinalizeId = null
|
||||||
}, 500) as unknown as number // 500ms debounce - wait for user to finish all interactions
|
}, 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 })
|
canvas.addEventListener('wheel', handleWheel, { passive: true })
|
||||||
// Context menu listener for right-click (capture mode to intercept before OrbitControls)
|
// Context menu listener for right-click (capture mode to intercept before OrbitControls)
|
||||||
canvas.addEventListener('contextmenu', handleContextMenu, { capture: true })
|
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('mouseleave', handleMouseUp, { capture: true })
|
||||||
canvas.removeEventListener('wheel', handleWheel)
|
canvas.removeEventListener('wheel', handleWheel)
|
||||||
canvas.removeEventListener('contextmenu', handleContextMenu, { capture: true })
|
canvas.removeEventListener('contextmenu', handleContextMenu, { capture: true })
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
// Clear cached references
|
// Clear cached references
|
||||||
cachedCanvas = null
|
cachedCanvas = null
|
||||||
@@ -778,6 +906,10 @@ function cleanupPlaneDragEvents() {
|
|||||||
<div ref="containerRef" class="model-viewer">
|
<div ref="containerRef" class="model-viewer">
|
||||||
<ContextMenu />
|
<ContextMenu />
|
||||||
<ViewCube v-if="viewerStore.model" />
|
<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 v-if="viewerStore.isLoading" class="loading-indicator">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span class="loading-text">
|
<span class="loading-text">
|
||||||
@@ -826,6 +958,22 @@ function cleanupPlaneDragEvents() {
|
|||||||
white-space: nowrap;
|
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 */
|
/* Error overlay */
|
||||||
.viewer-overlay {
|
.viewer-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export class ClippingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
material.clipShadows = true
|
material.clipShadows = true
|
||||||
|
material.clipIntersection = this.flipped // Sync flipped state
|
||||||
material.needsUpdate = true
|
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 {
|
flipAllPlaneNormals(): void {
|
||||||
this.flipped = !this.flipped
|
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) => {
|
axes.forEach((axis) => {
|
||||||
if (this.enabledAxes[axis]) {
|
if (this.enabledAxes[axis]) {
|
||||||
const plane = this.planes[axis]
|
|
||||||
plane.negate()
|
|
||||||
this.updatePlaneMeshPosition(axis)
|
this.updatePlaneMeshPosition(axis)
|
||||||
this.updateCapPosition(axis)
|
this.updateCapPosition(axis)
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1397,7 @@ export class ClippingService {
|
|||||||
|
|
||||||
materials.forEach((material) => {
|
materials.forEach((material) => {
|
||||||
material.clippingPlanes = []
|
material.clippingPlanes = []
|
||||||
|
material.clipIntersection = false // Reset flipped state
|
||||||
material.needsUpdate = true
|
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, // 暖灰
|
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
|
* Set scene reference
|
||||||
*/
|
*/
|
||||||
@@ -678,6 +694,7 @@ export class PartsTreeService {
|
|||||||
resetToOriginalColors(): void {
|
resetToOriginalColors(): void {
|
||||||
this.autoColorEnabled = false
|
this.autoColorEnabled = false
|
||||||
this.autoColorPalette.clear()
|
this.autoColorPalette.clear()
|
||||||
|
this.partMaterialOverrides.clear() // Clear manual color overrides
|
||||||
this.refreshAllMaterials()
|
this.refreshAllMaterials()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -815,12 +832,19 @@ export class PartsTreeService {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the CAD color palette
|
* Get the CAD color palette (for auto-coloring)
|
||||||
*/
|
*/
|
||||||
getColorPalette(): number[] {
|
getColorPalette(): number[] {
|
||||||
return [...this.cadColorPalette]
|
return [...this.cadColorPalette]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the manual color palette (for user selection - high contrast colors)
|
||||||
|
*/
|
||||||
|
getManualColorPalette(): number[] {
|
||||||
|
return [...this.manualColorPalette]
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Material System ====================
|
// ==================== Material System ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture a full-resolution screenshot from the Three.js renderer
|
* Capture a full-resolution screenshot from the Three.js renderer
|
||||||
* Preserves the original viewport dimensions
|
* Preserves the original viewport dimensions
|
||||||
@@ -107,7 +105,7 @@ export async function uploadThumbnail(
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('thumbnail', blob, 'thumbnail.png')
|
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',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ export const useViewerStore = defineStore('viewer', () => {
|
|||||||
partId: null as string | null,
|
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
|
// Actions
|
||||||
function setViewer(v: OV.EmbeddedViewer | null) {
|
function setViewer(v: OV.EmbeddedViewer | null) {
|
||||||
viewer.value = v
|
viewer.value = v
|
||||||
@@ -163,8 +170,8 @@ export const useViewerStore = defineStore('viewer', () => {
|
|||||||
selectedPartId.value = id
|
selectedPartId.value = id
|
||||||
}
|
}
|
||||||
|
|
||||||
function showContextMenu(x: number, y: number, partId: string) {
|
function showContextMenu(x: number, y: number, partId?: string | null) {
|
||||||
contextMenu.value = { visible: true, x, y, partId }
|
contextMenu.value = { visible: true, x, y, partId: partId ?? null }
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideContextMenu() {
|
function hideContextMenu() {
|
||||||
@@ -172,6 +179,14 @@ export const useViewerStore = defineStore('viewer', () => {
|
|||||||
contextMenu.value.partId = null
|
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() {
|
function resetFeatures() {
|
||||||
// Preserve global settings that should persist across model switches
|
// Preserve global settings that should persist across model switches
|
||||||
const currentAutoColor = renderSettings.value.autoColorEnabled
|
const currentAutoColor = renderSettings.value.autoColorEnabled
|
||||||
@@ -181,6 +196,7 @@ export const useViewerStore = defineStore('viewer', () => {
|
|||||||
isExplodedViewEnabled.value = false
|
isExplodedViewEnabled.value = false
|
||||||
selectedPartId.value = null
|
selectedPartId.value = null
|
||||||
contextMenu.value = { visible: false, x: 0, y: 0, partId: null }
|
contextMenu.value = { visible: false, x: 0, y: 0, partId: null }
|
||||||
|
moveMode.value = { active: false, partId: null, partObject: null }
|
||||||
crossSection.value = {
|
crossSection.value = {
|
||||||
x: { enabled: false, position: 100 },
|
x: { enabled: false, position: 100 },
|
||||||
y: { 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
|
* Fit the camera to show the entire model
|
||||||
*/
|
*/
|
||||||
function fitToView() {
|
function fitToView() {
|
||||||
if (!viewer.value) return
|
if (!viewer.value) {
|
||||||
|
console.warn('fitToView: viewer not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const threeViewer = viewer.value.GetViewer()
|
const threeViewer = viewer.value.GetViewer()
|
||||||
if (!threeViewer) return
|
if (!threeViewer) {
|
||||||
|
console.warn('fitToView: threeViewer not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Access navigation and bounding sphere from Online3DViewer
|
// Type assertion for Online3DViewer's Viewer class
|
||||||
const nav = (threeViewer as unknown as {
|
const viewerWithMethods = threeViewer as unknown as {
|
||||||
navigation?: {
|
GetBoundingSphere?: (needToProcess: (meshUserData: unknown) => boolean) => { center: { x: number; y: number; z: number }; radius: number } | null
|
||||||
FitSphereToWindow: (sphere: unknown, animate: boolean) => void
|
FitSphereToWindow?: (boundingSphere: { center: { x: number; y: number; z: number }; radius: number }, animate: boolean) => void
|
||||||
}
|
}
|
||||||
}).navigation
|
|
||||||
|
|
||||||
const getBoundingSphere = (threeViewer as unknown as {
|
if (!viewerWithMethods.GetBoundingSphere || !viewerWithMethods.FitSphereToWindow) {
|
||||||
GetBoundingSphere?: () => unknown
|
console.warn('fitToView: required methods not available')
|
||||||
}).GetBoundingSphere
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (nav && getBoundingSphere) {
|
// Get bounding sphere (requires filter function that returns true for all meshes)
|
||||||
const sphere = getBoundingSphere.call(threeViewer)
|
const boundingSphere = viewerWithMethods.GetBoundingSphere(() => true)
|
||||||
nav.FitSphereToWindow(sphere, true) // true = animate
|
|
||||||
|
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,
|
renderSettings,
|
||||||
selectedPartId,
|
selectedPartId,
|
||||||
contextMenu,
|
contextMenu,
|
||||||
|
moveMode,
|
||||||
// Actions
|
// Actions
|
||||||
setViewer,
|
setViewer,
|
||||||
setModel,
|
setModel,
|
||||||
@@ -552,6 +581,8 @@ export const useViewerStore = defineStore('viewer', () => {
|
|||||||
setSelectedPart,
|
setSelectedPart,
|
||||||
showContextMenu,
|
showContextMenu,
|
||||||
hideContextMenu,
|
hideContextMenu,
|
||||||
|
enterMoveMode,
|
||||||
|
exitMoveMode,
|
||||||
resetFeatures,
|
resetFeatures,
|
||||||
forceRender,
|
forceRender,
|
||||||
fitToView,
|
fitToView,
|
||||||
|
|||||||
Reference in New Issue
Block a user